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#[cfg(feature = "s3")]
16use std::time::{Duration, UNIX_EPOCH};
17
18use async_trait::async_trait;
19use corium_log::{LogError, MemLogRegistry, TransactionLog, TxRecord, VersionedLog};
20#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
21use corium_log::{NativeLogStorage, NativeVersionedLog};
22use corium_store::{BlobId, BlobIdStream, BlobStore, FsStore, MemoryStore, RootStore, StoreError};
23
24#[cfg(feature = "postgres")]
25use corium_store::PostgresBlobStore;
26#[cfg(feature = "turso")]
27use corium_store::TursoBlobStore;
28#[cfg(feature = "s3")]
29use corium_store::{S3BlobStore, S3ClientConfig, normalize_s3_prefix};
30
31/// Selects the transactor's storage-service backend (blobs + roots).
32#[derive(Clone, Default)]
33pub enum StoreSpec {
34    /// In-memory blobs and roots; fully ephemeral and confined to one
35    /// process. The transaction log is in memory too, so the whole database
36    /// vanishes when the process exits — ideal for demos and tests.
37    Memory,
38    /// Blobs and roots under `{data_dir}/store`, log under `{data_dir}/logs`.
39    #[default]
40    Fs,
41    /// Blobs, roots, and transaction logs in `PostgreSQL`.
42    #[cfg(feature = "postgres")]
43    Postgres {
44        /// `PostgreSQL` URL or keyword/value connection string.
45        connection_string: String,
46    },
47    /// Blobs, roots, and transaction logs in a Turso (embeddable `SQLite`)
48    /// database at `path`. `path` is a local database file.
49    #[cfg(feature = "turso")]
50    Turso {
51        /// Filesystem path of the Turso database.
52        path: String,
53    },
54    /// Blobs, roots, and transaction logs in an S3 (or S3-compatible) bucket.
55    #[cfg(feature = "s3")]
56    S3 {
57        /// Target bucket name.
58        bucket: String,
59        /// Key prefix namespacing every object this store touches.
60        prefix: String,
61        /// Explicit primary-client overrides. Empty values use the standard
62        /// AWS configuration chain.
63        client: S3ClientConfig,
64    },
65}
66
67impl fmt::Debug for StoreSpec {
68    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
69        match self {
70            Self::Memory => formatter.write_str("Memory"),
71            Self::Fs => formatter.write_str("Fs"),
72            #[cfg(feature = "postgres")]
73            Self::Postgres { .. } => formatter
74                .debug_struct("Postgres")
75                .field("connection_string", &"[REDACTED]")
76                .finish(),
77            #[cfg(feature = "turso")]
78            Self::Turso { path } => formatter.debug_struct("Turso").field("path", path).finish(),
79            #[cfg(feature = "s3")]
80            Self::S3 {
81                bucket,
82                prefix,
83                client,
84            } => formatter
85                .debug_struct("S3")
86                .field("bucket", bucket)
87                .field("prefix", prefix)
88                .field("client", client)
89                .finish(),
90        }
91    }
92}
93
94/// Credentials advertised by `GetStorageInfo` for S3 read-only clients.
95#[cfg(feature = "s3")]
96#[derive(Clone)]
97pub enum S3ReadOnlyCredentials {
98    /// Operator-provisioned credentials whose IAM/bucket policy permits only
99    /// reads of the configured Corium prefix.
100    Static {
101        /// Access-key id.
102        access_key_id: String,
103        /// Secret access key.
104        secret_access_key: String,
105        /// Optional session token.
106        session_token: Option<String>,
107    },
108    /// Temporary credentials obtained from AWS STS for this request.
109    ///
110    /// Corium attaches a restrictive session policy allowing only
111    /// `GetObject` and prefix-scoped `ListBucket`, so the issued credential
112    /// cannot write even if the role itself has broader permissions.
113    AssumeRole {
114        /// IAM role to assume.
115        role_arn: String,
116        /// STS role-session name.
117        session_name: String,
118        /// Requested token lifetime in seconds.
119        duration_seconds: i32,
120        /// Optional trust-policy external id.
121        external_id: Option<String>,
122    },
123}
124
125#[cfg(feature = "s3")]
126impl fmt::Debug for S3ReadOnlyCredentials {
127    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
128        match self {
129            Self::Static { .. } => formatter.write_str("Static([REDACTED])"),
130            Self::AssumeRole {
131                role_arn,
132                session_name,
133                duration_seconds,
134                external_id,
135            } => formatter
136                .debug_struct("AssumeRole")
137                .field("role_arn", role_arn)
138                .field("session_name", session_name)
139                .field("duration_seconds", duration_seconds)
140                .field("external_id", &external_id.as_ref().map(|_| "[REDACTED]"))
141                .finish(),
142        }
143    }
144}
145
146/// S3 endpoint metadata and read-only credential source advertised to peers.
147#[cfg(feature = "s3")]
148#[derive(Clone)]
149pub struct S3ReadOnlyConfig {
150    /// Region clients should use.
151    pub region: Option<String>,
152    /// Optional S3-compatible endpoint.
153    pub endpoint_url: Option<String>,
154    /// Static or STS-generated credentials.
155    pub credentials: S3ReadOnlyCredentials,
156    runtime: Arc<S3ReadOnlyRuntime>,
157}
158
159#[cfg(feature = "s3")]
160impl fmt::Debug for S3ReadOnlyConfig {
161    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
162        formatter
163            .debug_struct("S3ReadOnlyConfig")
164            .field("region", &self.region)
165            .field("endpoint_url", &self.endpoint_url)
166            .field("credentials", &self.credentials)
167            .finish_non_exhaustive()
168    }
169}
170
171#[cfg(feature = "s3")]
172#[derive(Default)]
173struct S3ReadOnlyRuntime {
174    aws: tokio::sync::OnceCell<ResolvedAwsConfig>,
175    cached_credentials: tokio::sync::Mutex<Option<CachedS3Credentials>>,
176}
177
178#[cfg(feature = "s3")]
179struct ResolvedAwsConfig {
180    sts: aws_sdk_sts::Client,
181    region: Option<String>,
182    endpoint_url: Option<String>,
183}
184
185#[cfg(feature = "s3")]
186#[derive(Clone)]
187struct CachedS3Credentials {
188    access_key_id: String,
189    secret_access_key: String,
190    session_token: String,
191    expires_unix_seconds: i64,
192    refresh_at: SystemTime,
193}
194
195/// Separately provisioned credentials returned by `GetStorageInfo`.
196///
197/// Local storage backends do not consult this value. Service backends fail
198/// storage discovery rather than falling back to the transactor's primary
199/// read/write credentials.
200#[derive(Clone, Default)]
201pub struct StorageInfoConfig {
202    /// Read-only `PostgreSQL` URL.
203    pub postgres_connection_string: Option<String>,
204    /// Read-only S3 access.
205    #[cfg(feature = "s3")]
206    pub s3: Option<S3ReadOnlyConfig>,
207}
208
209impl fmt::Debug for StorageInfoConfig {
210    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
211        let mut debug = formatter.debug_struct("StorageInfoConfig");
212        debug.field(
213            "postgres_connection_string",
214            &self
215                .postgres_connection_string
216                .as_ref()
217                .map(|_| "[REDACTED]"),
218        );
219        #[cfg(feature = "s3")]
220        debug.field("s3", &self.s3);
221        debug.finish()
222    }
223}
224
225/// Failure translating a transactor's advertised [`StorageConnection`] into a
226/// [`StoreSpec`] the client can open directly.
227///
228/// [`StorageConnection`]: corium_protocol::pb::StorageConnection
229#[derive(Debug, thiserror::Error)]
230pub enum StorageConnectionError {
231    /// The response carried no storage backend.
232    #[error("transactor returned no storage backend")]
233    Missing,
234    /// The backend cannot be opened by another process (memory), or this
235    /// build lacks support for it.
236    #[error("{0}")]
237    Unsupported(String),
238}
239
240impl StoreSpec {
241    /// Reconstructs a spec (and the filesystem data directory, empty for
242    /// backends that carry their own location) from the connection info a
243    /// transactor advertises through `GetStorageInfo`. The inverse of
244    /// [`Self::connection_info`], letting a client open the transactor's
245    /// storage service directly from just the transactor address.
246    ///
247    /// # Errors
248    /// Returns an error for a missing backend, an in-memory backend (confined
249    /// to the transactor process), or a backend omitted from this build.
250    pub fn from_connection(
251        connection: corium_protocol::pb::StorageConnection,
252    ) -> Result<(Self, PathBuf), StorageConnectionError> {
253        use corium_protocol::pb::storage_connection::Backend;
254
255        let backend = connection.backend.ok_or(StorageConnectionError::Missing)?;
256        let resolved = match backend {
257            Backend::Memory(_) => {
258                return Err(StorageConnectionError::Unsupported(
259                    "memory storage is confined to the transactor process".into(),
260                ));
261            }
262            Backend::Filesystem(storage) => (Self::Fs, PathBuf::from(storage.data_dir)),
263            Backend::Postgres(storage) => {
264                #[cfg(feature = "postgres")]
265                {
266                    (
267                        Self::Postgres {
268                            connection_string: storage.connection_string,
269                        },
270                        PathBuf::new(),
271                    )
272                }
273                #[cfg(not(feature = "postgres"))]
274                {
275                    let _ = storage;
276                    return Err(StorageConnectionError::Unsupported(
277                        "this build lacks PostgreSQL support".into(),
278                    ));
279                }
280            }
281            Backend::Turso(storage) => {
282                #[cfg(feature = "turso")]
283                {
284                    (Self::Turso { path: storage.path }, PathBuf::new())
285                }
286                #[cfg(not(feature = "turso"))]
287                {
288                    let _ = storage;
289                    return Err(StorageConnectionError::Unsupported(
290                        "this build lacks Turso support".into(),
291                    ));
292                }
293            }
294            Backend::S3(storage) => {
295                #[cfg(feature = "s3")]
296                {
297                    let access_key_id = nonempty(storage.access_key_id).ok_or_else(|| {
298                        StorageConnectionError::Unsupported(
299                            "S3 storage info did not include read-only credentials".into(),
300                        )
301                    })?;
302                    let secret_access_key =
303                        nonempty(storage.secret_access_key).ok_or_else(|| {
304                            StorageConnectionError::Unsupported(
305                                "S3 storage info did not include read-only credentials".into(),
306                            )
307                        })?;
308                    (
309                        Self::S3 {
310                            bucket: storage.bucket,
311                            prefix: storage.prefix,
312                            client: S3ClientConfig {
313                                region: nonempty(storage.region),
314                                endpoint_url: nonempty(storage.endpoint_url),
315                                access_key_id: Some(access_key_id),
316                                secret_access_key: Some(secret_access_key),
317                                session_token: nonempty(storage.session_token),
318                                expires_after: unix_timestamp(storage.expires_unix_seconds),
319                                ..S3ClientConfig::default()
320                            },
321                        },
322                        PathBuf::new(),
323                    )
324                }
325                #[cfg(not(feature = "s3"))]
326                {
327                    let _ = storage;
328                    return Err(StorageConnectionError::Unsupported(
329                        "this build lacks S3 support".into(),
330                    ));
331                }
332            }
333        };
334        Ok(resolved)
335    }
336
337    /// Describes how an administrative client can independently open this
338    /// node's storage service.
339    ///
340    /// Local paths are made absolute because the client need not share the
341    /// transactor's working directory. The memory backend is described too,
342    /// but cannot be opened by another process.
343    ///
344    /// # Errors
345    /// Returns an error when a local path cannot be represented on the wire.
346    #[cfg_attr(not(feature = "s3"), allow(clippy::unused_async))]
347    pub async fn connection_info(
348        &self,
349        data_dir: &std::path::Path,
350        #[cfg_attr(
351            not(any(feature = "postgres", feature = "s3")),
352            allow(unused_variables)
353        )]
354        read_only: &StorageInfoConfig,
355    ) -> Result<corium_protocol::pb::StorageConnection, String> {
356        use corium_protocol::pb;
357        use pb::storage_connection::Backend;
358
359        fn absolute(path: &std::path::Path) -> Result<String, String> {
360            let path = if path.is_absolute() {
361                path.to_path_buf()
362            } else {
363                std::env::current_dir()
364                    .map_err(|error| error.to_string())?
365                    .join(path)
366            };
367            path.into_os_string()
368                .into_string()
369                .map_err(|_| "storage path is not valid UTF-8".to_owned())
370        }
371
372        let backend = match self {
373            Self::Memory => Backend::Memory(pb::MemoryStorage {}),
374            Self::Fs => Backend::Filesystem(pb::FilesystemStorage {
375                data_dir: absolute(data_dir)?,
376            }),
377            #[cfg(feature = "postgres")]
378            Self::Postgres { .. } => {
379                let connection_string =
380                    read_only
381                        .postgres_connection_string
382                        .clone()
383                        .ok_or_else(|| {
384                            "PostgreSQL storage discovery requires a separately configured \
385                         read-only connection string"
386                                .to_owned()
387                        })?;
388                Backend::Postgres(pb::PostgreSqlStorage { connection_string })
389            }
390            #[cfg(feature = "turso")]
391            Self::Turso { path } => Backend::Turso(pb::TursoStorage {
392                path: absolute(std::path::Path::new(path))?,
393            }),
394            #[cfg(feature = "s3")]
395            Self::S3 { bucket, prefix, .. } => {
396                let read_only = read_only.s3.as_ref().ok_or_else(|| {
397                    "S3 storage discovery requires separately configured read-only credentials"
398                        .to_owned()
399                })?;
400                Backend::S3(read_only.s3_storage(bucket, prefix).await?)
401            }
402        };
403        Ok(pb::StorageConnection {
404            backend: Some(backend),
405        })
406    }
407}
408
409#[cfg(feature = "s3")]
410fn nonempty(value: String) -> Option<String> {
411    (!value.is_empty()).then_some(value)
412}
413
414#[cfg(feature = "s3")]
415fn unix_timestamp(seconds: i64) -> Option<SystemTime> {
416    u64::try_from(seconds)
417        .ok()
418        .filter(|seconds| *seconds > 0)
419        .and_then(|seconds| UNIX_EPOCH.checked_add(Duration::from_secs(seconds)))
420}
421
422#[cfg(feature = "s3")]
423impl S3ReadOnlyConfig {
424    /// Creates read-only S3 discovery configuration with a shared AWS client
425    /// and temporary-credential cache.
426    #[must_use]
427    pub fn new(
428        region: Option<String>,
429        endpoint_url: Option<String>,
430        credentials: S3ReadOnlyCredentials,
431    ) -> Self {
432        Self {
433            region,
434            endpoint_url,
435            credentials,
436            runtime: Arc::new(S3ReadOnlyRuntime::default()),
437        }
438    }
439
440    async fn aws(&self) -> &ResolvedAwsConfig {
441        self.runtime
442            .aws
443            .get_or_init(|| async {
444                let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest());
445                if let Some(region) = &self.region {
446                    loader = loader.region(aws_sdk_sts::config::Region::new(region.clone()));
447                }
448                let config = loader.load().await;
449                let region = self
450                    .region
451                    .clone()
452                    .or_else(|| config.region().map(ToString::to_string));
453                let endpoint_url = self.endpoint_url.clone().or_else(ambient_s3_endpoint);
454                ResolvedAwsConfig {
455                    sts: aws_sdk_sts::Client::new(&config),
456                    region,
457                    endpoint_url,
458                }
459            })
460            .await
461    }
462
463    pub(crate) async fn initialize(&self) {
464        self.aws().await;
465    }
466
467    async fn s3_storage(
468        &self,
469        bucket: &str,
470        prefix: &str,
471    ) -> Result<corium_protocol::pb::S3Storage, String> {
472        let prefix = normalize_s3_prefix(prefix.to_owned());
473        let aws = self.aws().await;
474        let (access_key_id, secret_access_key, session_token, expires_unix_seconds) =
475            match &self.credentials {
476                S3ReadOnlyCredentials::Static {
477                    access_key_id,
478                    secret_access_key,
479                    session_token,
480                } => (
481                    access_key_id.clone(),
482                    secret_access_key.clone(),
483                    session_token.clone().unwrap_or_default(),
484                    0,
485                ),
486                S3ReadOnlyCredentials::AssumeRole {
487                    role_arn,
488                    session_name,
489                    duration_seconds,
490                    external_id,
491                } => {
492                    let issued = self
493                        .assume_role_credentials(
494                            bucket,
495                            &prefix,
496                            role_arn,
497                            session_name,
498                            *duration_seconds,
499                            external_id.as_deref(),
500                        )
501                        .await?;
502                    (
503                        issued.access_key_id,
504                        issued.secret_access_key,
505                        issued.session_token,
506                        issued.expires_unix_seconds,
507                    )
508                }
509            };
510        Ok(corium_protocol::pb::S3Storage {
511            bucket: bucket.to_owned(),
512            prefix,
513            access_key_id,
514            secret_access_key,
515            session_token,
516            region: aws.region.clone().unwrap_or_default(),
517            endpoint_url: aws.endpoint_url.clone().unwrap_or_default(),
518            expires_unix_seconds,
519        })
520    }
521
522    async fn assume_role_credentials(
523        &self,
524        bucket: &str,
525        prefix: &str,
526        role_arn: &str,
527        session_name: &str,
528        duration_seconds: i32,
529        external_id: Option<&str>,
530    ) -> Result<CachedS3Credentials, String> {
531        let mut cached = self.runtime.cached_credentials.lock().await;
532        if let Some(credentials) = cached
533            .as_ref()
534            .filter(|credentials| SystemTime::now() < credentials.refresh_at)
535        {
536            return Ok(credentials.clone());
537        }
538        let partition = role_arn.split(':').nth(1).unwrap_or("aws");
539        let policy = serde_json::json!({
540            "Version": "2012-10-17",
541            "Statement": [
542                {
543                    "Effect": "Allow",
544                    "Action": ["s3:GetObject"],
545                    "Resource": [format!("arn:{partition}:s3:::{bucket}/{prefix}*")]
546                },
547                {
548                    "Effect": "Allow",
549                    "Action": ["s3:ListBucket"],
550                    "Resource": [format!("arn:{partition}:s3:::{bucket}")],
551                    "Condition": {
552                        "StringLike": {
553                            "s3:prefix": [format!("{prefix}*")]
554                        }
555                    }
556                }
557            ]
558        })
559        .to_string();
560        let issued_at = SystemTime::now();
561        let output = self
562            .aws()
563            .await
564            .sts
565            .assume_role()
566            .role_arn(role_arn)
567            .role_session_name(session_name)
568            .duration_seconds(duration_seconds)
569            .policy(policy)
570            .set_external_id(external_id.map(str::to_owned))
571            .send()
572            .await
573            .map_err(|error| format!("cannot assume S3 read-only role: {error}"))?;
574        let credentials = output.credentials().ok_or_else(|| {
575            "AWS STS returned no credentials for the S3 read-only role".to_owned()
576        })?;
577        let expires_unix_seconds = credentials.expiration().secs();
578        let expiration = unix_timestamp(expires_unix_seconds).unwrap_or(issued_at);
579        let lifetime = expiration
580            .duration_since(issued_at)
581            .unwrap_or(Duration::ZERO);
582        let issued = CachedS3Credentials {
583            access_key_id: credentials.access_key_id().to_owned(),
584            secret_access_key: credentials.secret_access_key().to_owned(),
585            session_token: credentials.session_token().to_owned(),
586            expires_unix_seconds,
587            refresh_at: issued_at
588                .checked_add(lifetime.mul_f64(0.8))
589                .unwrap_or(issued_at),
590        };
591        *cached = Some(issued.clone());
592        Ok(issued)
593    }
594}
595
596#[cfg(feature = "s3")]
597fn ambient_s3_endpoint() -> Option<String> {
598    std::env::var("AWS_ENDPOINT_URL_S3")
599        .ok()
600        .filter(|value| !value.is_empty())
601        .or_else(|| {
602            std::env::var("AWS_ENDPOINT_URL")
603                .ok()
604                .filter(|value| !value.is_empty())
605        })
606}
607
608impl StorageInfoConfig {
609    #[cfg_attr(not(feature = "s3"), allow(clippy::unused_async))]
610    pub(crate) async fn initialize(&self) {
611        #[cfg(feature = "s3")]
612        if let Some(s3) = &self.s3 {
613            s3.initialize().await;
614        }
615    }
616}
617
618/// The blob + root storage service a [`crate::node::TransactorNode`] runs
619/// over, chosen by [`StoreSpec`]. Dispatch is an enum rather than a trait
620/// object so every existing `impl BlobStore + RootStore` / `&dyn RootStore`
621/// call site keeps working unchanged.
622pub enum NodeStore {
623    /// In-memory backend.
624    Mem(MemoryStore),
625    /// Filesystem backend.
626    Fs(FsStore),
627    /// `PostgreSQL` backend.
628    #[cfg(feature = "postgres")]
629    Postgres(PostgresBlobStore),
630    /// Turso backend.
631    #[cfg(feature = "turso")]
632    Turso(TursoBlobStore),
633    /// S3 backend.
634    #[cfg(feature = "s3")]
635    S3(S3BlobStore),
636}
637
638impl NodeStore {
639    /// Opens the storage service for `spec`, relative to `data_dir` for the
640    /// filesystem backend.
641    ///
642    /// # Errors
643    /// Returns an error when the backing store cannot be opened.
644    // Only optional database-backed arms await; mem/fs are synchronous.
645    #[allow(clippy::unused_async)]
646    pub async fn open(spec: &StoreSpec, data_dir: &std::path::Path) -> Result<Self, StoreError> {
647        match spec {
648            StoreSpec::Memory => Ok(Self::Mem(MemoryStore::default())),
649            StoreSpec::Fs => Ok(Self::Fs(FsStore::open(data_dir.join("store"))?)),
650            #[cfg(feature = "postgres")]
651            StoreSpec::Postgres { connection_string } => Ok(Self::Postgres(
652                PostgresBlobStore::connect(connection_string).await?,
653            )),
654            #[cfg(feature = "turso")]
655            StoreSpec::Turso { path } => Ok(Self::Turso(TursoBlobStore::open(path).await?)),
656            #[cfg(feature = "s3")]
657            StoreSpec::S3 {
658                bucket,
659                prefix,
660                client,
661            } => Ok(Self::S3(
662                S3BlobStore::connect_with_config(bucket, prefix, client).await?,
663            )),
664        }
665    }
666
667    /// Opens an existing storage service for peer reads without running
668    /// backend schema initialization.
669    ///
670    /// # Errors
671    /// Returns an error when the backing store cannot be opened.
672    #[allow(clippy::unused_async)]
673    pub async fn open_existing(
674        spec: &StoreSpec,
675        data_dir: &std::path::Path,
676    ) -> Result<Self, StoreError> {
677        match spec {
678            StoreSpec::Memory => Ok(Self::Mem(MemoryStore::default())),
679            StoreSpec::Fs => Ok(Self::Fs(FsStore::open(data_dir.join("store"))?)),
680            #[cfg(feature = "postgres")]
681            StoreSpec::Postgres { connection_string } => Ok(Self::Postgres(
682                PostgresBlobStore::connect_existing(connection_string).await?,
683            )),
684            #[cfg(feature = "turso")]
685            StoreSpec::Turso { path } => {
686                Ok(Self::Turso(TursoBlobStore::open_existing(path).await?))
687            }
688            #[cfg(feature = "s3")]
689            StoreSpec::S3 {
690                bucket,
691                prefix,
692                client,
693            } => Ok(Self::S3(
694                S3BlobStore::connect_existing_with_config(bucket, prefix, client).await?,
695            )),
696        }
697    }
698}
699
700#[async_trait]
701impl BlobStore for NodeStore {
702    async fn put(&self, bytes: &[u8]) -> Result<BlobId, StoreError> {
703        match self {
704            Self::Mem(store) => store.put(bytes).await,
705            Self::Fs(store) => store.put(bytes).await,
706            #[cfg(feature = "postgres")]
707            Self::Postgres(store) => store.put(bytes).await,
708            #[cfg(feature = "turso")]
709            Self::Turso(store) => store.put(bytes).await,
710            #[cfg(feature = "s3")]
711            Self::S3(store) => store.put(bytes).await,
712        }
713    }
714
715    async fn get(&self, id: &BlobId) -> Result<Option<Vec<u8>>, StoreError> {
716        match self {
717            Self::Mem(store) => store.get(id).await,
718            Self::Fs(store) => store.get(id).await,
719            #[cfg(feature = "postgres")]
720            Self::Postgres(store) => store.get(id).await,
721            #[cfg(feature = "turso")]
722            Self::Turso(store) => store.get(id).await,
723            #[cfg(feature = "s3")]
724            Self::S3(store) => store.get(id).await,
725        }
726    }
727
728    async fn contains(&self, id: &BlobId) -> Result<bool, StoreError> {
729        match self {
730            Self::Mem(store) => store.contains(id).await,
731            Self::Fs(store) => store.contains(id).await,
732            #[cfg(feature = "postgres")]
733            Self::Postgres(store) => store.contains(id).await,
734            #[cfg(feature = "turso")]
735            Self::Turso(store) => store.contains(id).await,
736            #[cfg(feature = "s3")]
737            Self::S3(store) => store.contains(id).await,
738        }
739    }
740
741    async fn delete(&self, id: &BlobId) -> Result<(), StoreError> {
742        match self {
743            Self::Mem(store) => store.delete(id).await,
744            Self::Fs(store) => store.delete(id).await,
745            #[cfg(feature = "postgres")]
746            Self::Postgres(store) => store.delete(id).await,
747            #[cfg(feature = "turso")]
748            Self::Turso(store) => store.delete(id).await,
749            #[cfg(feature = "s3")]
750            Self::S3(store) => store.delete(id).await,
751        }
752    }
753
754    async fn list(&self) -> Result<BlobIdStream, StoreError> {
755        match self {
756            Self::Mem(store) => store.list().await,
757            Self::Fs(store) => store.list().await,
758            #[cfg(feature = "postgres")]
759            Self::Postgres(store) => store.list().await,
760            #[cfg(feature = "turso")]
761            Self::Turso(store) => store.list().await,
762            #[cfg(feature = "s3")]
763            Self::S3(store) => store.list().await,
764        }
765    }
766
767    async fn modified_at(&self, id: &BlobId) -> Result<Option<SystemTime>, StoreError> {
768        match self {
769            Self::Mem(store) => store.modified_at(id).await,
770            Self::Fs(store) => store.modified_at(id).await,
771            #[cfg(feature = "postgres")]
772            Self::Postgres(store) => store.modified_at(id).await,
773            #[cfg(feature = "turso")]
774            Self::Turso(store) => store.modified_at(id).await,
775            #[cfg(feature = "s3")]
776            Self::S3(store) => store.modified_at(id).await,
777        }
778    }
779}
780
781#[async_trait]
782impl RootStore for NodeStore {
783    async fn get_root(&self, name: &str) -> Result<Option<Vec<u8>>, StoreError> {
784        match self {
785            Self::Mem(store) => store.get_root(name).await,
786            Self::Fs(store) => store.get_root(name).await,
787            #[cfg(feature = "postgres")]
788            Self::Postgres(store) => store.get_root(name).await,
789            #[cfg(feature = "turso")]
790            Self::Turso(store) => store.get_root(name).await,
791            #[cfg(feature = "s3")]
792            Self::S3(store) => store.get_root(name).await,
793        }
794    }
795
796    async fn cas_root(
797        &self,
798        name: &str,
799        expected: Option<&[u8]>,
800        new: &[u8],
801    ) -> Result<(), StoreError> {
802        match self {
803            Self::Mem(store) => store.cas_root(name, expected, new).await,
804            Self::Fs(store) => store.cas_root(name, expected, new).await,
805            #[cfg(feature = "postgres")]
806            Self::Postgres(store) => store.cas_root(name, expected, new).await,
807            #[cfg(feature = "turso")]
808            Self::Turso(store) => store.cas_root(name, expected, new).await,
809            #[cfg(feature = "s3")]
810            Self::S3(store) => store.cas_root(name, expected, new).await,
811        }
812    }
813
814    async fn delete_root(&self, name: &str) -> Result<(), StoreError> {
815        match self {
816            Self::Mem(store) => store.delete_root(name).await,
817            Self::Fs(store) => store.delete_root(name).await,
818            #[cfg(feature = "postgres")]
819            Self::Postgres(store) => store.delete_root(name).await,
820            #[cfg(feature = "turso")]
821            Self::Turso(store) => store.delete_root(name).await,
822            #[cfg(feature = "s3")]
823            Self::S3(store) => store.delete_root(name).await,
824        }
825    }
826
827    async fn list_roots(&self, prefix: &str) -> Result<Vec<String>, StoreError> {
828        match self {
829            Self::Mem(store) => store.list_roots(prefix).await,
830            Self::Fs(store) => store.list_roots(prefix).await,
831            #[cfg(feature = "postgres")]
832            Self::Postgres(store) => store.list_roots(prefix).await,
833            #[cfg(feature = "turso")]
834            Self::Turso(store) => store.list_roots(prefix).await,
835            #[cfg(feature = "s3")]
836            Self::S3(store) => store.list_roots(prefix).await,
837        }
838    }
839}
840
841/// Where a node's per-database transaction logs live.
842pub enum LogBackend {
843    /// Versioned log files under this directory.
844    Fs(PathBuf),
845    /// In-memory versioned logs shared across a process.
846    Mem(MemLogRegistry),
847    /// Versioned logs stored through the native root store.
848    #[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
849    Native(Arc<dyn NativeLogStorage>),
850}
851
852/// Runs filesystem log operations on Tokio's blocking pool while exposing the
853/// same async interface used by native storage logs.
854struct BlockingTransactionLog(Arc<dyn TransactionLog>);
855
856#[async_trait]
857impl TransactionLog for BlockingTransactionLog {
858    fn append(&self, record: &TxRecord) -> Result<(), LogError> {
859        self.0.append(record)
860    }
861
862    async fn append_async(&self, record: &TxRecord) -> Result<(), LogError> {
863        let log = Arc::clone(&self.0);
864        let record = record.clone();
865        tokio::task::spawn_blocking(move || log.append(&record))
866            .await
867            .map_err(|error| LogError::Native(format!("log task failed: {error}")))?
868    }
869
870    fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
871        self.0.tx_range(start, end)
872    }
873
874    async fn tx_range_async(
875        &self,
876        start: u64,
877        end: Option<u64>,
878    ) -> Result<Vec<TxRecord>, LogError> {
879        let log = Arc::clone(&self.0);
880        tokio::task::spawn_blocking(move || log.tx_range(start, end))
881            .await
882            .map_err(|error| LogError::Native(format!("log task failed: {error}")))?
883    }
884}
885
886/// Prevents a storage handle opened for replay from being used to append even
887/// when its concrete implementation also supports writes.
888struct ReadOnlyTransactionLog(Arc<dyn TransactionLog>);
889
890#[async_trait]
891impl TransactionLog for ReadOnlyTransactionLog {
892    fn append(&self, _record: &TxRecord) -> Result<(), LogError> {
893        Err(LogError::Native("transaction log is read-only".into()))
894    }
895
896    fn tx_range(&self, start: u64, end: Option<u64>) -> Result<Vec<TxRecord>, LogError> {
897        self.0.tx_range(start, end)
898    }
899
900    async fn tx_range_async(
901        &self,
902        start: u64,
903        end: Option<u64>,
904    ) -> Result<Vec<TxRecord>, LogError> {
905        self.0.tx_range_async(start, end).await
906    }
907}
908
909impl LogBackend {
910    /// The log backend that pairs with `spec`.
911    #[must_use]
912    #[allow(clippy::needless_pass_by_value)]
913    pub fn for_spec(
914        spec: &StoreSpec,
915        data_dir: &std::path::Path,
916        #[cfg_attr(
917            not(any(feature = "postgres", feature = "turso", feature = "s3")),
918            allow(unused_variables)
919        )]
920        store: Arc<NodeStore>,
921    ) -> Self {
922        match spec {
923            StoreSpec::Memory => Self::Mem(MemLogRegistry::new()),
924            StoreSpec::Fs => Self::Fs(data_dir.join("logs")),
925            #[cfg(feature = "postgres")]
926            StoreSpec::Postgres { .. } => Self::Native(Arc::new(NativeRootLogStore::new(store))),
927            #[cfg(feature = "turso")]
928            StoreSpec::Turso { .. } => Self::Native(Arc::new(NativeRootLogStore::new(store))),
929            #[cfg(feature = "s3")]
930            StoreSpec::S3 { .. } => Self::Native(Arc::new(NativeRootLogStore::new(store))),
931        }
932    }
933
934    /// Opens the named log for writing under `write_version`.
935    ///
936    /// # Errors
937    /// Returns an error when a transaction log cannot be opened.
938    pub async fn open(
939        &self,
940        name: &str,
941        write_version: u64,
942    ) -> Result<Arc<dyn TransactionLog>, LogError> {
943        match self {
944            Self::Fs(dir) => {
945                let dir = dir.clone();
946                let name = name.to_owned();
947                let log = tokio::task::spawn_blocking(move || {
948                    VersionedLog::open(dir, &name, write_version)
949                })
950                .await
951                .map_err(|error| LogError::Native(format!("log task failed: {error}")))??;
952                Ok(Arc::new(BlockingTransactionLog(Arc::new(log))))
953            }
954            Self::Mem(registry) => Ok(Arc::new(registry.open(name, write_version))),
955            #[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
956            Self::Native(storage) => Ok(Arc::new(
957                NativeVersionedLog::open(Arc::clone(storage), name, write_version).await?,
958            )),
959        }
960    }
961
962    /// Opens the named log for independent read-only replay.
963    ///
964    /// Native logs use a non-writing versioned-log handle; filesystem logs
965    /// use the explicit read-only opener so this path never creates or
966    /// truncates a source log.
967    ///
968    /// # Errors
969    /// Returns an error when a transaction log cannot be opened.
970    pub async fn open_read_only(&self, name: &str) -> Result<Arc<dyn TransactionLog>, LogError> {
971        match self {
972            Self::Fs(dir) => {
973                let dir = dir.clone();
974                let name = name.to_owned();
975                let log =
976                    tokio::task::spawn_blocking(move || VersionedLog::open_read_only(dir, &name))
977                        .await
978                        .map_err(|error| LogError::Native(format!("log task failed: {error}")))??;
979                Ok(Arc::new(ReadOnlyTransactionLog(Arc::new(
980                    BlockingTransactionLog(Arc::new(log)),
981                ))))
982            }
983            Self::Mem(registry) => Ok(Arc::new(ReadOnlyTransactionLog(Arc::new(
984                registry.open(name, 0),
985            )))),
986            #[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
987            Self::Native(storage) => Ok(Arc::new(ReadOnlyTransactionLog(Arc::new(
988                NativeVersionedLog::open_read_only(Arc::clone(storage), name),
989            )))),
990        }
991    }
992
993    /// Reports whether any log exists for `name`.
994    #[must_use]
995    pub async fn exists(&self, name: &str) -> bool {
996        match self {
997            Self::Fs(dir) => {
998                let dir = dir.clone();
999                let name = name.to_owned();
1000                tokio::task::spawn_blocking(move || VersionedLog::exists(dir, &name))
1001                    .await
1002                    .unwrap_or(false)
1003            }
1004            Self::Mem(registry) => registry.exists(name),
1005            #[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
1006            Self::Native(storage) => {
1007                storage
1008                    .list_records(name)
1009                    .await
1010                    .is_ok_and(|records| !records.is_empty())
1011                    || storage
1012                        .list_legacy_chunks(name)
1013                        .await
1014                        .is_ok_and(|chunks| !chunks.is_empty())
1015            }
1016        }
1017    }
1018
1019    /// Deletes every log for `name`.
1020    ///
1021    /// # Errors
1022    /// Returns an error when a transaction log cannot be removed.
1023    pub async fn delete_all(&self, name: &str) -> Result<(), LogError> {
1024        match self {
1025            Self::Fs(dir) => {
1026                let dir = dir.clone();
1027                let name = name.to_owned();
1028                tokio::task::spawn_blocking(move || VersionedLog::delete_all(dir, &name))
1029                    .await
1030                    .map_err(|error| LogError::Native(format!("log task failed: {error}")))?
1031            }
1032            Self::Mem(registry) => {
1033                registry.delete_all(name);
1034                Ok(())
1035            }
1036            #[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
1037            Self::Native(storage) => storage.delete_all(name).await,
1038        }
1039    }
1040}
1041
1042#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
1043struct NativeRootLogStore {
1044    store: Arc<NodeStore>,
1045}
1046
1047#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
1048impl NativeRootLogStore {
1049    fn new(store: Arc<NodeStore>) -> Self {
1050        Self { store }
1051    }
1052}
1053
1054/// A parsed log object key: either a per-transaction record or a legacy chunk
1055/// written by the pre-per-record layout.
1056#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
1057enum LogKey {
1058    /// Per-transaction record object `(version, t)`.
1059    Record(u64, u64),
1060    /// Legacy chunk object `(version, chunk)`.
1061    Legacy(u64, u64),
1062}
1063
1064#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
1065impl NativeRootLogStore {
1066    /// Object key for one per-transaction record: `log:<db>:v<version>:r<t>`,
1067    /// with both numbers zero-padded so a listing sorts by `(version, t)`.
1068    fn record_key(name: &str, version: u64, t: u64) -> String {
1069        format!("log:{name}:v{version:020}:r{t:020}")
1070    }
1071
1072    /// Object key for one legacy chunk. Chunk `0` keeps the historical
1073    /// unsuffixed key, so logs written by earlier releases read back without
1074    /// migration.
1075    fn legacy_key(name: &str, version: u64, chunk: u64) -> String {
1076        if chunk == 0 {
1077            format!("log:{name}:v{version:020}")
1078        } else {
1079            format!("log:{name}:v{version:020}:c{chunk:020}")
1080        }
1081    }
1082
1083    fn prefix(name: &str) -> String {
1084        format!("log:{name}:v")
1085    }
1086
1087    /// Classifies a listed log key. A `:r` suffix marks a per-record object; a
1088    /// `:c` suffix or a bare version marks a legacy chunk (chunk `0` when
1089    /// bare). The version is pure digits, so it never contains either marker.
1090    fn parse_key(prefix: &str, key: &str) -> Option<LogKey> {
1091        let rest = key.strip_prefix(prefix)?;
1092        if let Some((version, t)) = rest.split_once(":r") {
1093            Some(LogKey::Record(version.parse().ok()?, t.parse().ok()?))
1094        } else if let Some((version, chunk)) = rest.split_once(":c") {
1095            Some(LogKey::Legacy(version.parse().ok()?, chunk.parse().ok()?))
1096        } else {
1097            Some(LogKey::Legacy(rest.parse().ok()?, 0))
1098        }
1099    }
1100}
1101
1102#[cfg(any(feature = "postgres", feature = "turso", feature = "s3"))]
1103#[async_trait]
1104impl NativeLogStorage for NativeRootLogStore {
1105    async fn put_batch(
1106        &self,
1107        name: &str,
1108        version: u64,
1109        records: &[(u64, Vec<u8>)],
1110    ) -> Result<bool, LogError> {
1111        let Some((last_t, _)) = records.last() else {
1112            return Ok(true);
1113        };
1114        // The whole batch is one immutable object keyed by its last `t`,
1115        // holding the batch's framed records concatenated — the same encoding
1116        // a multi-record chunk uses, so the reader decodes it unchanged. On
1117        // SQL backends this is one row insert (one fsync for the batch); on an
1118        // object store, one create-only `PUT`. A create-only root CAS
1119        // (expected `None`) makes it atomic and fenced; a lost race surfaces
1120        // as `CasFailed`, which the caller maps to `false`.
1121        let mut bytes = Vec::new();
1122        for (_, framed) in records {
1123            bytes.extend_from_slice(framed);
1124        }
1125        match self
1126            .store
1127            .cas_root(&Self::record_key(name, version, *last_t), None, &bytes)
1128            .await
1129        {
1130            Ok(()) => Ok(true),
1131            Err(StoreError::CasFailed { .. }) => Ok(false),
1132            Err(error) => Err(LogError::Native(error.to_string())),
1133        }
1134    }
1135
1136    async fn read_record(
1137        &self,
1138        name: &str,
1139        version: u64,
1140        t: u64,
1141    ) -> Result<Option<Vec<u8>>, LogError> {
1142        self.store
1143            .get_root(&Self::record_key(name, version, t))
1144            .await
1145            .map_err(|error| LogError::Native(error.to_string()))
1146    }
1147
1148    async fn list_records(&self, name: &str) -> Result<Vec<(u64, u64)>, LogError> {
1149        let prefix = Self::prefix(name);
1150        let names = self
1151            .store
1152            .list_roots(&prefix)
1153            .await
1154            .map_err(|error| LogError::Native(error.to_string()))?;
1155        names
1156            .into_iter()
1157            .filter_map(|key| match Self::parse_key(&prefix, &key) {
1158                Some(LogKey::Record(version, t)) => Some(Ok((version, t))),
1159                Some(LogKey::Legacy(..)) => None,
1160                None => Some(Err(LogError::Corrupt)),
1161            })
1162            .collect()
1163    }
1164
1165    async fn read_legacy_chunk(
1166        &self,
1167        name: &str,
1168        version: u64,
1169        chunk: u64,
1170    ) -> Result<Option<Vec<u8>>, LogError> {
1171        self.store
1172            .get_root(&Self::legacy_key(name, version, chunk))
1173            .await
1174            .map_err(|error| LogError::Native(error.to_string()))
1175    }
1176
1177    async fn list_legacy_chunks(&self, name: &str) -> Result<Vec<(u64, u64)>, LogError> {
1178        let prefix = Self::prefix(name);
1179        let names = self
1180            .store
1181            .list_roots(&prefix)
1182            .await
1183            .map_err(|error| LogError::Native(error.to_string()))?;
1184        names
1185            .into_iter()
1186            .filter_map(|key| match Self::parse_key(&prefix, &key) {
1187                Some(LogKey::Legacy(version, chunk)) => Some(Ok((version, chunk))),
1188                Some(LogKey::Record(..)) => None,
1189                None => Some(Err(LogError::Corrupt)),
1190            })
1191            .collect()
1192    }
1193
1194    async fn delete_all(&self, name: &str) -> Result<(), LogError> {
1195        let prefix = Self::prefix(name);
1196        let names = self
1197            .store
1198            .list_roots(&prefix)
1199            .await
1200            .map_err(|error| LogError::Native(error.to_string()))?;
1201        for key in names {
1202            self.store
1203                .delete_root(&key)
1204                .await
1205                .map_err(|error| LogError::Native(error.to_string()))?;
1206        }
1207        Ok(())
1208    }
1209}
1210
1211#[cfg(all(test, any(feature = "postgres", feature = "turso", feature = "s3")))]
1212mod tests {
1213    use super::*;
1214    use corium_core::{Datom, EntityId, Value};
1215
1216    #[cfg(feature = "postgres")]
1217    #[tokio::test]
1218    async fn postgres_storage_info_uses_only_the_read_only_url() {
1219        let primary = "postgresql://writer:primary-secret@db/corium";
1220        let read_only = "postgresql://reader:read-secret@db/corium";
1221        let spec = StoreSpec::Postgres {
1222            connection_string: primary.into(),
1223        };
1224        let missing = spec
1225            .connection_info(std::path::Path::new("."), &StorageInfoConfig::default())
1226            .await
1227            .expect_err("primary URL must not be advertised");
1228        assert!(missing.contains("read-only"));
1229
1230        let info = spec
1231            .connection_info(
1232                std::path::Path::new("."),
1233                &StorageInfoConfig {
1234                    postgres_connection_string: Some(read_only.into()),
1235                    #[cfg(feature = "s3")]
1236                    s3: None,
1237                },
1238            )
1239            .await
1240            .expect("read-only info");
1241        let Some(corium_protocol::pb::storage_connection::Backend::Postgres(postgres)) =
1242            info.backend
1243        else {
1244            panic!("PostgreSQL storage info");
1245        };
1246        assert_eq!(postgres.connection_string, read_only);
1247        assert!(!postgres.connection_string.contains("primary-secret"));
1248    }
1249
1250    #[cfg(feature = "s3")]
1251    #[tokio::test]
1252    async fn s3_storage_info_carries_static_read_only_credentials() {
1253        let missing = StoreSpec::from_connection(corium_protocol::pb::StorageConnection {
1254            backend: Some(corium_protocol::pb::storage_connection::Backend::S3(
1255                corium_protocol::pb::S3Storage {
1256                    bucket: "bucket".into(),
1257                    prefix: String::new(),
1258                    ..Default::default()
1259                },
1260            )),
1261        })
1262        .expect_err("S3 clients must not fall back to ambient credentials");
1263        assert!(missing.to_string().contains("read-only credentials"));
1264
1265        let spec = StoreSpec::S3 {
1266            bucket: "bucket".into(),
1267            prefix: "tenant/".into(),
1268            client: S3ClientConfig {
1269                access_key_id: Some("PRIMARY".into()),
1270                secret_access_key: Some("primary-secret".into()),
1271                ..S3ClientConfig::default()
1272            },
1273        };
1274        let info = spec
1275            .connection_info(
1276                std::path::Path::new("."),
1277                &StorageInfoConfig {
1278                    postgres_connection_string: None,
1279                    s3: Some(S3ReadOnlyConfig::new(
1280                        Some("us-west-2".into()),
1281                        Some("https://objects.example".into()),
1282                        S3ReadOnlyCredentials::Static {
1283                            access_key_id: "READONLY".into(),
1284                            secret_access_key: "read-secret".into(),
1285                            session_token: Some("session".into()),
1286                        },
1287                    )),
1288                },
1289            )
1290            .await
1291            .expect("read-only info");
1292        let Some(corium_protocol::pb::storage_connection::Backend::S3(mut s3)) = info.backend
1293        else {
1294            panic!("S3 storage info");
1295        };
1296        assert_eq!(s3.bucket, "bucket");
1297        assert_eq!(s3.prefix, "tenant/");
1298        assert_eq!(s3.access_key_id, "READONLY");
1299        assert_eq!(s3.secret_access_key, "read-secret");
1300        assert_eq!(s3.session_token, "session");
1301        assert_eq!(s3.region, "us-west-2");
1302        assert_eq!(s3.endpoint_url, "https://objects.example");
1303        assert_eq!(s3.expires_unix_seconds, 0);
1304        assert_ne!(s3.access_key_id, "PRIMARY");
1305
1306        s3.expires_unix_seconds = 1_900_000_000;
1307        let (StoreSpec::S3 { client, .. }, _) =
1308            StoreSpec::from_connection(corium_protocol::pb::StorageConnection {
1309                backend: Some(corium_protocol::pb::storage_connection::Backend::S3(s3)),
1310            })
1311            .expect("parse S3 storage info")
1312        else {
1313            panic!("S3 store spec");
1314        };
1315        assert_eq!(
1316            client
1317                .expires_after
1318                .expect("temporary credential expiration")
1319                .duration_since(UNIX_EPOCH)
1320                .expect("timestamp after epoch")
1321                .as_secs(),
1322            1_900_000_000
1323        );
1324    }
1325
1326    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1327    async fn native_root_log_writes_one_object_per_record_on_the_runtime() {
1328        let run = async {
1329            let dir = tempfile::tempdir().expect("tempdir");
1330            let store = Arc::new(
1331                NodeStore::open(&StoreSpec::Memory, dir.path())
1332                    .await
1333                    .expect("memory store"),
1334            );
1335            let storage = Arc::new(NativeRootLogStore::new(store));
1336            let log = NativeVersionedLog::open(Arc::clone(&storage), "db", 1)
1337                .await
1338                .expect("open native log");
1339            // Large records too: each transaction is its own object, so there
1340            // is no chunk cap to cross.
1341            for t in 1..=3 {
1342                log.append_async(&TxRecord {
1343                    t,
1344                    tx_instant: i64::try_from(t).expect("small t"),
1345                    datoms: vec![Datom {
1346                        e: EntityId::from_raw(t),
1347                        a: EntityId::from_raw(1),
1348                        v: Value::Bytes(vec![0; 300 * 1024].into()),
1349                        tx: EntityId::from_raw(t),
1350                        added: true,
1351                    }],
1352                })
1353                .await
1354                .expect("append");
1355            }
1356            let records = storage.list_records("db").await.expect("list records");
1357            assert_eq!(records.len(), 3);
1358            assert!(
1359                storage
1360                    .list_legacy_chunks("db")
1361                    .await
1362                    .expect("list legacy")
1363                    .is_empty()
1364            );
1365            assert_eq!(log.replay_async().await.expect("replay").len(), 3);
1366        };
1367        tokio::time::timeout(std::time::Duration::from_secs(5), run)
1368            .await
1369            .expect("native log operation stalled on its runtime");
1370    }
1371}