1use std::path::Path;
11use std::sync::Arc;
12
13use boatramp_core::kv::{KvOpenPolicy, KvStore, MemoryKv};
14
15use crate::error::Result;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
23#[serde(rename_all = "lowercase")]
24#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
25pub enum BlobBackend {
26 Fs,
28 S3,
30 Gcs,
32 Azure,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
39pub enum KvBackend {
40 Slatedb,
43 Memory,
45 Cloudflare,
47 Sql,
52}
53
54pub const CONTROL_PLANE_FLUSH: std::time::Duration = std::time::Duration::from_millis(5);
57
58#[derive(Debug, Clone)]
63pub struct SlateKvS3 {
64 pub bucket: String,
66 pub endpoint: Option<String>,
68 pub region: Option<String>,
70 pub path_style: bool,
72 pub prefix: String,
74}
75
76pub async fn build_kv(
88 kv: KvBackend,
89 data_dir: &Path,
90 slate_s3: Option<&SlateKvS3>,
91 policy: KvOpenPolicy,
92 sql: Option<&crate::config::SqlKvConfig>,
93) -> Result<Arc<dyn KvStore>> {
94 match kv {
95 KvBackend::Slatedb => build_slatedb_kv(data_dir, slate_s3, policy).await,
96 KvBackend::Memory => Ok(Arc::new(MemoryKv::new())),
97 KvBackend::Cloudflare => build_cloudflare_kv(),
98 KvBackend::Sql => build_sql_kv(sql).await,
99 }
100}
101
102#[cfg(feature = "sql")]
109async fn build_sql_kv(sql: Option<&crate::config::SqlKvConfig>) -> Result<Arc<dyn KvStore>> {
110 use crate::error::Error;
111 let cfg = sql.ok_or_else(|| {
112 Error::SqlKvConfig(
113 "`--kv sql` needs a `[serve.kv.sql]` config block (or the `BOATRAMP_KV_SQL_*` env)"
114 .to_string(),
115 )
116 })?;
117 match cfg.kind.trim().to_ascii_lowercase().as_str() {
119 "" | "sqlite" | "sqlite3" | "libsql" => {
120 let path = cfg
121 .path
122 .as_deref()
123 .filter(|p| !p.is_empty())
124 .ok_or_else(|| {
125 Error::SqlKvConfig(
126 "`[serve.kv.sql] kind = sqlite` needs `path` (an on-disk file) — set it or \
127 `BOATRAMP_KV_SQL_PATH`"
128 .to_string(),
129 )
130 })?;
131 Ok(Arc::new(
132 boatramp_storage::SqlKv::open_sqlite_local(path).await?,
133 ))
134 }
135 engine @ ("postgres" | "postgresql" | "pg") => build_pg_kv(cfg, engine).await,
136 engine @ ("mysql" | "mariadb") => build_mysql_kv(cfg, engine).await,
137 other => Err(Error::SqlKvConfig(format!(
138 "unknown `[serve.kv.sql] kind` {other:?}: expected sqlite | postgres | mysql"
139 ))),
140 }
141}
142
143#[cfg(all(feature = "sql", any(feature = "sql-postgres", feature = "sql-mysql")))]
147fn resolve_kv_url(cfg: &crate::config::SqlKvConfig, engine: &str) -> Result<String> {
148 use crate::error::Error;
149 let url_env = cfg
150 .url_env
151 .as_deref()
152 .map(str::trim)
153 .filter(|s| !s.is_empty())
154 .ok_or_else(|| {
155 Error::SqlKvConfig(format!(
156 "`[serve.kv.sql] kind = {engine}` needs `url_env` (the NAME of the env var holding \
157 the connection URL — never a raw URL in config) — set it or `BOATRAMP_KV_SQL_URL_ENV`"
158 ))
159 })?;
160 std::env::var(url_env)
161 .ok()
162 .map(|v| v.trim().to_string())
163 .filter(|v| !v.is_empty())
164 .ok_or_else(|| {
165 Error::SqlKvConfig(format!(
166 "`[serve.kv.sql] url_env = {url_env:?}` names an unset or empty env var; set \
167 {url_env} to the {engine} connection URL"
168 ))
169 })
170}
171
172#[cfg(all(feature = "sql", feature = "sql-postgres"))]
176async fn build_pg_kv(cfg: &crate::config::SqlKvConfig, engine: &str) -> Result<Arc<dyn KvStore>> {
177 let url = resolve_kv_url(cfg, engine)?;
178 Ok(Arc::new(
179 boatramp_storage::SqlKv::open_postgres(url, cfg.pool_max).await?,
180 ))
181}
182
183#[cfg(all(feature = "sql", not(feature = "sql-postgres")))]
184async fn build_pg_kv(_cfg: &crate::config::SqlKvConfig, engine: &str) -> Result<Arc<dyn KvStore>> {
185 Err(crate::error::Error::SqlKvConfig(format!(
186 "the `{engine}` SQL KV backend needs the Postgres engine — rebuild with `--features sql-postgres`"
187 )))
188}
189
190#[cfg(all(feature = "sql", feature = "sql-mysql"))]
194async fn build_mysql_kv(
195 cfg: &crate::config::SqlKvConfig,
196 engine: &str,
197) -> Result<Arc<dyn KvStore>> {
198 let url = resolve_kv_url(cfg, engine)?;
199 Ok(Arc::new(
200 boatramp_storage::SqlKv::open_mysql(url, cfg.pool_max).await?,
201 ))
202}
203
204#[cfg(all(feature = "sql", not(feature = "sql-mysql")))]
205async fn build_mysql_kv(
206 _cfg: &crate::config::SqlKvConfig,
207 engine: &str,
208) -> Result<Arc<dyn KvStore>> {
209 Err(crate::error::Error::SqlKvConfig(format!(
210 "the `{engine}` SQL KV backend needs the MySQL engine — rebuild with `--features sql-mysql`"
211 )))
212}
213
214#[cfg(not(feature = "sql"))]
215async fn build_sql_kv(_sql: Option<&crate::config::SqlKvConfig>) -> Result<Arc<dyn KvStore>> {
216 Err(crate::error::Error::NoSqlSupport)
217}
218
219#[cfg(feature = "slatedb")]
220async fn build_slatedb_kv(
221 data_dir: &Path,
222 slate_s3: Option<&SlateKvS3>,
223 policy: KvOpenPolicy,
224) -> Result<Arc<dyn KvStore>> {
225 match slate_s3 {
226 Some(s3) => Ok(Arc::new(
227 boatramp_storage::SlateKv::open_s3_with_flush_policy(
228 &boatramp_storage::S3StoreConfig {
229 bucket: s3.bucket.clone(),
230 endpoint: s3.endpoint.clone(),
231 region: s3.region.clone(),
232 path_style: s3.path_style,
233 },
234 &s3.prefix,
235 CONTROL_PLANE_FLUSH,
236 policy,
237 )
238 .await?,
239 )),
240 None => Ok(Arc::new(
241 boatramp_storage::SlateKv::open_local_with_flush_policy(
242 data_dir.join("kv-slate"),
243 CONTROL_PLANE_FLUSH,
244 policy,
245 )
246 .await?,
247 )),
248 }
249}
250
251#[cfg(not(feature = "slatedb"))]
252async fn build_slatedb_kv(
253 _data_dir: &Path,
254 _slate_s3: Option<&SlateKvS3>,
255 _policy: KvOpenPolicy,
256) -> Result<Arc<dyn KvStore>> {
257 Err(crate::error::Error::NoSlatedbSupport)
258}
259
260#[cfg(feature = "cloudflare-kv")]
261fn build_cloudflare_kv() -> Result<Arc<dyn KvStore>> {
262 Ok(Arc::new(boatramp_storage::CloudflareKv::from_env()?))
263}
264
265#[cfg(not(feature = "cloudflare-kv"))]
266fn build_cloudflare_kv() -> Result<Arc<dyn KvStore>> {
267 Err(crate::error::Error::NoCloudflareKvSupport)
268}