use std::collections::HashSet;
use std::path::Path;
use std::sync::Arc;
use boatramp_core::project::DEFAULT_PROJECT;
use boatramp_core::{Storage, StorageError};
const BLOB_FAMILIES: &[&str] = &["hblob/", "mqp/", "mqgp/"];
pub const BLOB_RETIRED_MARKER: &str = "schema/default-scope-blob-retired";
pub fn libsql_bare_default_idents(libsql_dir: &Path, projects: &HashSet<String>) -> Vec<String> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(libsql_dir) else {
return out; };
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
let Ok(ft) = entry.file_type() else { continue };
if ft.is_file() {
if let Some(site) = name.strip_suffix(".db")
&& !site.is_empty()
{
out.push(format!("site `{site}` ({})", entry.path().display()));
}
} else if ft.is_dir() && name != DEFAULT_PROJECT && !projects.contains(&name) {
out.push(format!(
"`{name}/` named/preview databases ({})",
entry.path().display()
));
}
}
out.sort();
out
}
pub async fn blob_default_outstanding(
storage: &Arc<dyn Storage>,
projects: &HashSet<String>,
) -> Result<bool, StorageError> {
for family in BLOB_FAMILIES {
for meta in storage.list(family).await? {
let Some(rest) = meta.key.strip_prefix(family) else {
continue;
};
let seg = rest.split('/').next().unwrap_or("");
if !seg.is_empty() && seg != DEFAULT_PROJECT && !projects.contains(seg) {
return Ok(true); }
}
}
Ok(false)
}
pub async fn known_projects(
kv: &dyn boatramp_core::kv::KvStore,
) -> Result<HashSet<String>, boatramp_core::error::KvError> {
let mut set = HashSet::new();
for key in kv
.list_prefix(boatramp_core::project::POINTER_PREFIX)
.await?
{
if let Some(name) = key.strip_prefix(boatramp_core::project::POINTER_PREFIX)
&& !name.is_empty()
&& !name.contains('/')
{
set.insert(name.to_string());
}
}
Ok(set)
}
pub fn libsql_dir_for(
config: &crate::config::ServerConfig,
data_dir: &Path,
) -> Option<std::path::PathBuf> {
let sql = config
.handlers
.as_ref()
.and_then(|h| h.bindings.sql.as_ref());
if sql.and_then(|c| c.url.as_ref()).is_some() {
return None;
}
Some(
sql.and_then(|c| c.dir.clone())
.unwrap_or_else(|| data_dir.join("handlers-sql")),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn libsql_detector_flags_bare_default_and_skips_rehomed_and_projects() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
std::fs::write(dir.join("blog.db"), b"x").unwrap();
std::fs::write(dir.join("blog.db-wal"), b"x").unwrap();
std::fs::create_dir_all(dir.join("fn")).unwrap();
std::fs::write(dir.join("fn").join("resize.db"), b"x").unwrap();
std::fs::create_dir_all(dir.join("default")).unwrap();
std::fs::write(dir.join("default").join("shop.db"), b"x").unwrap();
std::fs::create_dir_all(dir.join("acme")).unwrap();
std::fs::write(dir.join("acme").join("blog.db"), b"x").unwrap();
let projects: HashSet<String> = ["default", "acme"].iter().map(|s| s.to_string()).collect();
let bare = libsql_bare_default_idents(dir, &projects);
assert_eq!(bare.len(), 2, "got {bare:?}");
assert!(bare.iter().any(|s| s.contains("site `blog`")));
assert!(bare.iter().any(|s| s.contains("`fn/`")));
assert!(!bare.iter().any(|s| s.contains("acme")));
assert!(!bare.iter().any(|s| s.contains("shop")));
}
#[test]
fn libsql_detector_empty_when_all_rehomed_or_absent() {
assert!(
libsql_bare_default_idents(Path::new("/nonexistent/xyz"), &HashSet::new()).is_empty()
);
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("default")).unwrap();
std::fs::write(tmp.path().join("default").join("blog.db"), b"x").unwrap();
assert!(libsql_bare_default_idents(tmp.path(), &HashSet::new()).is_empty());
}
#[derive(Default)]
struct KeyStore {
keys: Vec<String>,
}
#[async_trait::async_trait]
impl Storage for KeyStore {
async fn get(&self, key: &str) -> Result<boatramp_core::GetObject, StorageError> {
Err(StorageError::NotFound(key.into()))
}
async fn get_range(
&self,
key: &str,
_: u64,
_: Option<u64>,
) -> Result<boatramp_core::GetObject, StorageError> {
Err(StorageError::NotFound(key.into()))
}
async fn put(
&self,
key: &str,
_: boatramp_core::ByteStream,
_: boatramp_core::PutMeta,
) -> Result<boatramp_core::ObjectMeta, StorageError> {
Err(StorageError::unsupported(key))
}
async fn head(&self, key: &str) -> Result<boatramp_core::ObjectMeta, StorageError> {
Err(StorageError::NotFound(key.into()))
}
async fn delete(&self, _: &str) -> Result<(), StorageError> {
Ok(())
}
async fn list(&self, prefix: &str) -> Result<Vec<boatramp_core::ObjectMeta>, StorageError> {
Ok(self
.keys
.iter()
.filter(|k| k.starts_with(prefix))
.map(|k| boatramp_core::ObjectMeta {
key: k.clone(),
size: Some(1),
content_type: None,
etag: None,
})
.collect())
}
}
#[tokio::test]
async fn blob_detector_true_for_bare_default_false_when_rehomed_or_named() {
let projects: HashSet<String> = ["default", "acme"].iter().map(|s| s.to_string()).collect();
let bare: Arc<dyn Storage> = Arc::new(KeyStore {
keys: vec![
"hblob/default/shop/c/ok".into(), "hblob/acme/site/c/ok".into(), "hblob/blog/c/needs".into(), ],
});
assert!(
blob_default_outstanding(&bare, &projects).await.unwrap(),
"a bare default-scoped blob object must read as outstanding"
);
let clean: Arc<dyn Storage> = Arc::new(KeyStore {
keys: vec![
"hblob/default/blog/c/ok".into(),
"mqp/default/blog/orders/17".into(),
"mqgp/default/blog/orders/18".into(),
"hblob/acme/site/c/ok".into(),
],
});
assert!(
!blob_default_outstanding(&clean, &projects).await.unwrap(),
"re-homed + named-project objects only must NOT read as outstanding"
);
}
}