use anyhow::Context;
use chrono::{DateTime, Utc};
use std::future::Future;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::sync::OnceCell;
use turso::Builder;
pub use turso::{Error as TursoError, IntoParams, Row, Value, params, params_from_iter};
#[must_use]
pub fn now() -> String {
Utc::now().to_rfc3339()
}
pub fn parse_utc_timestamp(s: &str) -> Result<DateTime<Utc>, chrono::ParseError> {
DateTime::parse_from_rfc3339(s).map(|dt| dt.with_timezone(&Utc))
}
pub const EXPERIMENTAL_FEATURES: &[&str] = &["index_method", "multiprocess_wal"];
#[must_use]
pub fn experimental_database_opts() -> turso::core::DatabaseOpts {
turso::core::DatabaseOpts::new()
.with_multiprocess_wal(true)
.with_index_method(true)
}
pub async fn register_global_store<T, F, Fut>(
cell: &OnceCell<T>,
name: &str,
open_fn: F,
) -> anyhow::Result<()>
where
F: FnOnce() -> Fut,
Fut: Future<Output = anyhow::Result<T>> + Send,
{
let store = open_fn().await?;
cell.set(store)
.map_err(|_| anyhow::anyhow!("{name} already initialized"))?;
Ok(())
}
#[macro_export]
macro_rules! global_store {
(
$(#[$attr:meta])*
pub static $name:ident: $ty:ty,
constructor = $constructor:expr,
) => {
$crate::global_store! {
$(#[$attr])*
pub static $name: $ty,
constructor = $constructor,
expect = concat!(
stringify!($name),
" not initialized — call init_global() first"
),
}
};
(
$(#[$attr:meta])*
pub static $name:ident: $ty:ty,
constructor = $constructor:expr,
expect = $expect:expr,
) => {
$(#[$attr])*
pub static $name: ::tokio::sync::OnceCell<$ty> =
::tokio::sync::OnceCell::const_new();
#[doc = concat!("Initialize the global ", stringify!($name), " store.")]
pub async fn init_global() -> ::anyhow::Result<()> {
let root = $crate::config::CONFIG.global_storage_root();
$crate::turso::register_global_store(
&$name,
stringify!($name),
|| $constructor(&root),
)
.await
}
#[must_use]
#[doc = concat!(
"Get a reference to the global ",
stringify!($name),
" store.\n\n# Panics\n\nPanics if the store has not been initialized.",
)]
pub fn store() -> &'static $ty {
$name.get().expect($expect)
}
};
}
#[must_use]
pub fn sanitize_fts_query(query: &str) -> String {
query
.chars()
.map(|c| {
if c.is_alphanumeric() || c.is_whitespace() {
c
} else {
' '
}
})
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
#[must_use]
pub fn sql_in_placeholders(count: usize) -> String {
vec!["?"; count].join(", ")
}
#[derive(Clone, Debug)]
pub struct Connection {
conn: Arc<tokio::sync::Mutex<turso::Connection>>,
has_dangling_tx: Arc<AtomicBool>,
}
impl Connection {
pub async fn open(path: &Path) -> anyhow::Result<Self> {
let path_str = path
.to_str()
.with_context(|| format!("database path must be UTF-8: {}", path.display()))?;
let opts = experimental_database_opts();
let db = Builder::new_local(path_str)
.experimental_index_method(opts.enable_index_method)
.experimental_multiprocess_wal(opts.enable_multiprocess_wal)
.build()
.await
.context("failed to open local database")?;
let conn = db.connect()?;
conn.busy_timeout(Duration::from_mins(1))?;
Ok(Self {
conn: Arc::new(tokio::sync::Mutex::new(conn)),
has_dangling_tx: Arc::new(AtomicBool::new(false)),
})
}
async fn maybe_rollback_dangling_tx(&self) -> turso::Result<()> {
let conn = self.conn.lock().await;
if self.has_dangling_tx.swap(false, Ordering::SeqCst) {
conn.execute("ROLLBACK".to_string(), ()).await?;
}
Ok(())
}
pub async fn execute(
&self,
sql: &str,
params: impl IntoParams + Send + 'static,
) -> turso::Result<u64> {
self.maybe_rollback_dangling_tx().await?;
let conn = self.conn.lock().await;
conn.execute(sql.to_string(), params).await
}
pub(crate) async fn execute_batch(&self, sql: &str) -> turso::Result<()> {
self.maybe_rollback_dangling_tx().await?;
let conn = self.conn.lock().await;
conn.execute_batch(sql.to_string()).await
}
pub async fn begin_tx(&self) -> turso::Result<TxGuard<'_>> {
self.maybe_rollback_dangling_tx().await?;
let conn = self.conn.lock().await;
conn.execute("BEGIN".to_string(), ()).await?;
Ok(TxGuard {
conn,
has_dangling_tx: Some(self.has_dangling_tx.clone()),
})
}
pub async fn query(
&self,
sql: &str,
params: impl IntoParams + Send + 'static,
) -> turso::Result<Vec<Row>> {
let conn = self.conn.lock().await;
let mut rows = conn.query(sql, params).await?;
let mut result = Vec::new();
while let Some(row) = rows.next().await? {
result.push(row);
}
Ok(result)
}
pub async fn query_map<T, E>(
&self,
sql: &str,
params: impl IntoParams + Send + 'static,
map: impl FnMut(&Row) -> std::result::Result<T, E> + Send + 'static,
) -> turso::Result<Vec<turso::Result<T>>>
where
T: Send + 'static,
E: std::fmt::Display + Send + Sync + 'static,
{
let rows = self.query(sql, params).await?;
let mut map = map;
Ok(rows
.iter()
.map(|row| map(row).map_err(|e| turso::Error::Error(e.to_string())))
.collect())
}
pub async fn query_row<T, E>(
&self,
sql: &str,
params: impl IntoParams + Send + 'static,
map: impl FnOnce(&Row) -> std::result::Result<T, E> + Send + 'static,
) -> turso::Result<T>
where
E: std::fmt::Display + Send + Sync + 'static,
{
let conn = self.conn.lock().await;
let mut rows = conn.query(sql, params).await?;
let row = rows
.next()
.await?
.ok_or(turso::Error::QueryReturnedNoRows)?;
map(&row).map_err(|e| turso::Error::Error(e.to_string()))
}
pub async fn checkpoint(&self) -> anyhow::Result<()> {
self.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")
.await
.context("Failed to checkpoint WAL")?;
Ok(())
}
}
pub struct TxGuard<'a> {
conn: tokio::sync::MutexGuard<'a, turso::Connection>,
has_dangling_tx: Option<Arc<AtomicBool>>,
}
impl TxGuard<'_> {
pub async fn execute(
&self,
sql: &str,
params: impl IntoParams + Send + 'static,
) -> turso::Result<u64> {
self.conn.execute(sql.to_string(), params).await
}
pub async fn query_row<T, E>(
&self,
sql: &str,
params: impl IntoParams + Send + 'static,
map: impl FnOnce(&Row) -> std::result::Result<T, E> + Send + 'static,
) -> turso::Result<T>
where
E: std::fmt::Display + Send + Sync + 'static,
{
let mut rows = self.conn.query(sql, params).await?;
let row = rows
.next()
.await?
.ok_or(turso::Error::QueryReturnedNoRows)?;
map(&row).map_err(|e| turso::Error::Error(e.to_string()))
}
pub async fn query(
&self,
sql: &str,
params: impl IntoParams + Send + 'static,
) -> turso::Result<Vec<Row>> {
let mut rows = self.conn.query(sql, params).await?;
let mut result = Vec::new();
while let Some(row) = rows.next().await? {
result.push(row);
}
Ok(result)
}
pub async fn commit(mut self) -> turso::Result<()> {
self.conn.execute("COMMIT".to_string(), ()).await?;
self.has_dangling_tx = None;
Ok(())
}
pub async fn rollback(mut self) -> turso::Result<()> {
self.conn.execute("ROLLBACK".to_string(), ()).await?;
self.has_dangling_tx = None;
Ok(())
}
}
impl Drop for TxGuard<'_> {
fn drop(&mut self) {
if let Some(flag) = &self.has_dangling_tx {
flag.store(true, Ordering::SeqCst);
}
}
}
pub fn row_text(row: &Row, idx: usize) -> anyhow::Result<String> {
match row.get_value(idx)? {
Value::Text(s) => Ok(s),
Value::Null => Ok(String::new()),
other => anyhow::bail!("expected text column {idx}, got {other:?}"),
}
}
pub fn row_text_opt(row: &Row, idx: usize) -> anyhow::Result<Option<String>> {
match row.get_value(idx)? {
Value::Text(s) => Ok(Some(s)),
Value::Null => Ok(None),
other => anyhow::bail!("expected text or null in column {idx}, got {other:?}"),
}
}
pub fn row_bool_opt(row: &Row, idx: usize) -> anyhow::Result<Option<bool>> {
match row.get_value(idx)? {
Value::Integer(i) => Ok(Some(i != 0)),
Value::Null => Ok(None),
other => anyhow::bail!("expected integer or null in column {idx}, got {other:?}"),
}
}
pub async fn ensure_fts_index(
conn: &Connection,
index_name: &str,
tokenizer: &str,
ddl: &str,
) -> anyhow::Result<()> {
let existing_sql: Option<String> = match conn
.query_row(
"SELECT sql FROM sqlite_master WHERE type='index' AND name=?1 LIMIT 1",
params![index_name],
|row| match row.get_value(0)? {
Value::Text(s) => Ok::<_, ::turso::Error>(s),
_ => Ok::<_, ::turso::Error>(String::new()),
},
)
.await
{
Ok(sql) if !sql.is_empty() => Some(sql),
Err(turso::Error::QueryReturnedNoRows) => None,
Err(e) => return Err(e.into()),
_ => None,
};
let needs_rebuild = existing_sql
.as_deref()
.is_none_or(|sql| !sql.to_lowercase().contains(&tokenizer.to_lowercase()));
if needs_rebuild {
conn.execute(&format!("DROP INDEX IF EXISTS {index_name}"), ())
.await?;
conn.execute(ddl, ()).await?;
}
Ok(())
}
pub async fn open_with_schema(db_path: &Path, schema: &str) -> anyhow::Result<Connection> {
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
let conn = Connection::open(db_path)
.await
.with_context(|| format!("Failed to open database: {}", db_path.display()))?;
conn.execute_batch(schema)
.await
.context(format!("Failed to run schema {schema}"))?;
Ok(conn)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn experimental_features_are_consistent() {
let opts = experimental_database_opts();
for feature in EXPERIMENTAL_FEATURES {
match *feature {
"index_method" => assert!(
opts.enable_index_method,
"index_method should be enabled per EXPERIMENTAL_FEATURES"
),
"multiprocess_wal" => assert!(
opts.enable_multiprocess_wal,
"multiprocess_wal should be enabled per EXPERIMENTAL_FEATURES"
),
other => panic!("unknown experimental feature: {other}"),
}
}
assert!(
!opts.enable_views,
"views is not an active experimental feature"
);
assert!(
!opts.enable_custom_types,
"custom_types is not an active experimental feature"
);
assert!(
!opts.enable_encryption,
"encryption is not an active experimental feature"
);
assert!(
!opts.enable_autovacuum,
"autovacuum is not an active experimental feature"
);
assert!(
!opts.enable_vacuum,
"vacuum is not an active experimental feature"
);
assert!(
!opts.enable_attach,
"attach is not an active experimental feature"
);
assert!(
!opts.enable_generated_columns,
"generated_columns is not an active experimental feature"
);
assert!(
!opts.enable_without_rowid,
"without_rowid is not an active experimental feature"
);
assert!(
!opts.unsafe_testing,
"unsafe_testing is not an active experimental feature"
);
}
#[test]
fn builder_mapping_matches_experimental_features() {
let opts = experimental_database_opts();
let mut mapped: Vec<&str> = Vec::new();
if opts.enable_index_method {
mapped.push("index_method");
}
if opts.enable_multiprocess_wal {
mapped.push("multiprocess_wal");
}
mapped.sort_unstable();
let mut expected: Vec<&str> = EXPERIMENTAL_FEATURES.to_vec();
expected.sort_unstable();
assert_eq!(
mapped, expected,
"Connection::open builder mapping enables features that differ from \
EXPERIMENTAL_FEATURES.\n\
If you added a feature: add the experimental_*() guard above AND \
add it to Connection::open.\n\
If you removed a feature: remove it from both places.\n\
See EXPERIMENTAL_FEATURES docs for naming asymmetries."
);
}
#[test]
fn test_sanitize_fts_query_keeps_alphanumeric() {
assert_eq!(sanitize_fts_query("hello world"), "hello world");
}
#[test]
fn test_sanitize_fts_query_strips_special_chars() {
assert_eq!(sanitize_fts_query("`Hello ${name}`"), "Hello name");
}
#[test]
fn test_sanitize_fts_query_preserves_word_boundaries() {
assert_eq!(
sanitize_fts_query("contact user@example.com now"),
"contact user example com now"
);
}
#[test]
fn test_sanitize_fts_query_handles_punctuation() {
assert_eq!(
sanitize_fts_query("hello, world! How's it going?"),
"hello world How s it going"
);
}
#[test]
fn test_sanitize_fts_query_empty_result() {
assert_eq!(sanitize_fts_query("!@#$%"), "");
}
#[test]
fn parse_utc_timestamp_valid_zulu() {
let ts = parse_utc_timestamp("2024-01-15T10:30:00Z").unwrap();
assert_eq!(ts.to_rfc3339(), "2024-01-15T10:30:00+00:00");
}
#[test]
fn parse_utc_timestamp_valid_offset() {
let ts = parse_utc_timestamp("2024-06-15T14:30:00+05:00").unwrap();
assert_eq!(ts.to_rfc3339(), "2024-06-15T09:30:00+00:00");
}
#[test]
fn parse_utc_timestamp_negative_offset() {
let ts = parse_utc_timestamp("2024-12-25T20:00:00-08:00").unwrap();
assert_eq!(ts.to_rfc3339(), "2024-12-26T04:00:00+00:00");
}
#[test]
fn parse_utc_timestamp_invalid_returns_err() {
assert!(parse_utc_timestamp("garbage").is_err());
assert!(parse_utc_timestamp("").is_err());
assert!(parse_utc_timestamp("2024-01-15").is_err());
}
}