use crate::error::{ErrorData, Result};
use alien_error::{AlienError, Context as _, IntoAlienError as _};
use std::future::Future;
use std::path::PathBuf;
use std::time::Duration;
use turso::{Connection, Database, IntoParams, Value};
const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug)]
pub(crate) struct StoreSpec {
pub db_filename: &'static str,
pub format_version: &'static str,
pub binding_type: &'static str,
pub schema_ddl: &'static str,
}
#[derive(Debug)]
pub(crate) struct LocalStore {
data_dir: PathBuf,
db: Database,
spec: &'static StoreSpec,
}
pub(crate) async fn open_database(path: &std::path::Path, binding_type: &str) -> Result<Database> {
turso::Builder::new_local(&path.to_string_lossy())
.experimental_multiprocess_wal(true)
.build()
.await
.into_alien_error()
.context(ErrorData::BindingSetupFailed {
binding_type: binding_type.to_string(),
reason: format!("failed to open local store database at {}", path.display()),
})
}
pub(crate) async fn query_all(
conn: &Connection,
sql: &str,
params: impl IntoParams,
) -> turso::Result<Vec<Vec<Value>>> {
let mut rows = conn.query(sql, params).await?;
let mut out = Vec::new();
while let Some(row) = rows.next().await? {
let mut values = Vec::with_capacity(row.column_count());
for idx in 0..row.column_count() {
values.push(row.get_value(idx)?);
}
out.push(values);
}
Ok(out)
}
pub(crate) fn as_text(value: &Value) -> Option<String> {
match value {
Value::Text(s) => Some(s.clone()),
_ => None,
}
}
pub(crate) fn as_i64(value: &Value) -> Option<i64> {
match value {
Value::Integer(i) => Some(*i),
_ => None,
}
}
pub(crate) fn as_opt_i64(value: &Value) -> Option<Option<i64>> {
match value {
Value::Null => Some(None),
Value::Integer(i) => Some(Some(*i)),
_ => None,
}
}
pub(crate) fn as_blob(value: &Value) -> Option<Vec<u8>> {
match value {
Value::Blob(b) => Some(b.clone()),
_ => None,
}
}
pub(crate) fn opt_i64_value(v: Option<i64>) -> Value {
match v {
Some(i) => Value::Integer(i),
None => Value::Null,
}
}
impl LocalStore {
pub(crate) async fn open(data_dir: PathBuf, spec: &'static StoreSpec) -> Result<Self> {
tokio::fs::create_dir_all(&data_dir)
.await
.into_alien_error()
.context(ErrorData::LocalFilesystemError {
path: data_dir.display().to_string(),
operation: "create_dir_all".to_string(),
})?;
let db_path = data_dir.join(spec.db_filename);
let db = open_database(&db_path, spec.binding_type).await?;
let store = Self { data_dir, db, spec };
let conn = store.connect().await?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);",
)
.await
.into_alien_error()
.context(ErrorData::BindingSetupFailed {
binding_type: spec.binding_type.to_string(),
reason: "failed to create meta table".to_string(),
})?;
conn.execute(
"INSERT OR IGNORE INTO meta (key, value) VALUES ('format', ?1)",
(spec.format_version,),
)
.await
.into_alien_error()
.context(ErrorData::BindingSetupFailed {
binding_type: spec.binding_type.to_string(),
reason: "failed to write format marker".to_string(),
})?;
let rows = query_all(&conn, "SELECT value FROM meta WHERE key = 'format'", ())
.await
.into_alien_error()
.context(ErrorData::BindingSetupFailed {
binding_type: spec.binding_type.to_string(),
reason: "failed to read format marker from meta table".to_string(),
})?;
let format = rows
.first()
.and_then(|row| row.first())
.and_then(as_text)
.ok_or_else(|| {
AlienError::new(ErrorData::BindingSetupFailed {
binding_type: spec.binding_type.to_string(),
reason: "format marker missing from meta table".to_string(),
})
})?;
if format != spec.format_version {
return Err(AlienError::new(ErrorData::BindingSetupFailed {
binding_type: spec.binding_type.to_string(),
reason: format!(
"unsupported store format '{format}' (this implementation supports '{}')",
spec.format_version
),
}));
}
conn.execute_batch(spec.schema_ddl)
.await
.into_alien_error()
.context(ErrorData::BindingSetupFailed {
binding_type: spec.binding_type.to_string(),
reason: format!("failed to initialize {} schema", spec.format_version),
})?;
Ok(store)
}
pub(crate) fn data_dir(&self) -> &PathBuf {
&self.data_dir
}
async fn connect(&self) -> Result<Connection> {
let conn = self
.db
.connect()
.into_alien_error()
.context(ErrorData::BindingSetupFailed {
binding_type: self.spec.binding_type.to_string(),
reason: "failed to open store connection".to_string(),
})?;
conn.busy_timeout(BUSY_TIMEOUT).into_alien_error().context(
ErrorData::BindingSetupFailed {
binding_type: self.spec.binding_type.to_string(),
reason: "failed to set busy_timeout".to_string(),
},
)?;
Ok(conn)
}
pub(crate) async fn with_conn<T, F, Fut>(&self, f: F) -> Result<T>
where
F: FnOnce(Connection) -> Fut,
Fut: Future<Output = Result<T>>,
{
let conn = self.connect().await?;
f(conn).await
}
}
#[cfg(test)]
mod tests {
use super::*;
static TEST_SPEC: StoreSpec = StoreSpec {
db_filename: "teststore.sqlite",
format_version: "teststore.v1",
binding_type: "test store",
schema_ddl: "CREATE TABLE IF NOT EXISTS widgets (id INTEGER PRIMARY KEY);",
};
#[tokio::test]
async fn foreign_format_rejected_before_provider_ddl() {
let temp = tempfile::tempdir().expect("temp dir");
let dir = temp.path().join("store");
std::fs::create_dir_all(&dir).expect("create dir");
let db_path = dir.join(TEST_SPEC.db_filename);
{
let db = open_database(&db_path, "test store")
.await
.expect("seed open");
let conn = db.connect().expect("seed connect");
conn.execute_batch("CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);")
.await
.expect("seed meta table");
conn.execute(
"INSERT INTO meta (key, value) VALUES ('format', 'teststore.v2')",
(),
)
.await
.expect("seed meta marker");
}
let err = LocalStore::open(dir.clone(), &TEST_SPEC)
.await
.expect_err("foreign format must be rejected");
let msg = err.to_string();
assert!(
msg.contains("teststore.v2"),
"must name found format: {msg}"
);
assert!(
msg.contains("teststore.v1"),
"must name expected format: {msg}"
);
let db = open_database(&db_path, "test store")
.await
.expect("verify open");
let conn = db.connect().expect("verify connect");
let rows = query_all(
&conn,
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='widgets')",
(),
)
.await
.expect("table existence query");
let widgets_exists = rows
.first()
.and_then(|row| row.first())
.and_then(as_i64)
.expect("existence value");
assert_eq!(
widgets_exists, 0,
"provider DDL must not run on a rejected foreign-format store"
);
}
}