use anyhow::Context;
use chrono::{DateTime, Utc};
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::sync::OnceCell;
use tracing::warn;
use turso::Builder;
pub use turso::{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"];
pub(crate) fn iter_checkpoint_stores()
-> impl Iterator<Item = (&'static str, Option<&'static crate::turso::Connection>)> {
[
("board", crate::board::BOARD.get().map(|s| &s.conn)),
(
"chat_history",
crate::chat_history::CHAT_HISTORY.get().map(|s| &s.conn),
),
(
"config",
crate::config_db::CONFIG_STORE.get().map(|s| &s.conn),
),
("logs", crate::logs::LOG_STORE.get().map(|s| &s.conn)),
("sessions", crate::session::SESSIONS.get().map(|s| &s.conn)),
("stats", crate::stats::STATS_STORE.get().map(|s| &s.conn)),
("users", crate::users::USER_STORE.get().map(|s| &s.conn)),
(
"workspaces",
crate::workspace::WORKSPACES.get().map(|s| &s.conn),
),
]
.into_iter()
}
pub(crate) fn store_names() -> Vec<&'static str> {
iter_checkpoint_stores().map(|(name, _)| name).collect()
}
pub async fn init_all_stores() -> anyhow::Result<()> {
tokio::try_join!(
crate::session::init_global(),
crate::workspace::init_global(),
crate::users::init_global(),
crate::board::init_global(),
crate::stats::init_global(),
crate::chat_history::init_global(),
crate::config_db::init_global(),
)?;
Ok(())
}
#[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,
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)
}
};
}
static TANTIVY_SPECIAL: &[char] = &[
'+', '^', '~', ':', '{', '}', '"', '\'', '`', '[', ']', '(', ')', '\\', '*', '-', ];
#[must_use]
pub fn sanitize_fts_query(query: &str) -> String {
let sanitized: String = query
.chars()
.map(|c| {
if c.is_whitespace() || TANTIVY_SPECIAL.contains(&c) {
' '
} else {
c
}
})
.collect();
sanitized
.split_whitespace()
.map(|word| word.trim_start_matches('/'))
.filter(|word| !word.is_empty())
.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>,
}
fn map_rows<T, E>(
rows: &[Row],
mut map: impl FnMut(&Row) -> std::result::Result<T, E>,
) -> Vec<turso::Result<T>>
where
E: std::fmt::Display,
{
rows.iter()
.map(|row| map(row).map_err(|e| turso::Error::Error(e.to_string())))
.collect()
}
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 lock_and_cleanup(&self) -> tokio::sync::MutexGuard<'_, turso::Connection> {
let conn = self.conn.lock().await;
if self.has_dangling_tx.swap(false, Ordering::SeqCst) {
let _ = conn.execute("ROLLBACK", ()).await;
}
conn
}
pub async fn execute(
&self,
sql: &str,
params: impl IntoParams + Send + 'static,
) -> turso::Result<u64> {
let conn = self.lock_and_cleanup().await;
conn.execute(sql, params).await
}
pub(crate) async fn execute_batch(&self, sql: &str) -> turso::Result<()> {
let conn = self.lock_and_cleanup().await;
conn.execute_batch(sql).await
}
pub async fn begin_tx(&self) -> turso::Result<TxGuard<'_>> {
let conn = self.lock_and_cleanup().await;
conn.execute("BEGIN", ()).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.lock_and_cleanup().await;
Self::query_impl(&conn, sql, params).await
}
async fn query_impl(
conn: &turso::Connection,
sql: &str,
params: impl IntoParams + Send + 'static,
) -> turso::Result<Vec<Row>> {
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?;
Ok(map_rows(&rows, map))
}
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.lock_and_cleanup().await;
Self::query_row_impl(&conn, sql, params, map).await
}
pub async fn query_optional<T, E>(
&self,
sql: &str,
params: impl IntoParams + Send + 'static,
map: impl FnOnce(&Row) -> std::result::Result<T, E> + Send + 'static,
) -> anyhow::Result<Option<T>>
where
E: std::fmt::Display + Send + Sync + 'static,
{
match self.query_row(sql, params, map).await {
Ok(val) => Ok(Some(val)),
Err(::turso::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
async fn query_row_impl<T, E>(
conn: &turso::Connection,
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 = 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.query("PRAGMA wal_checkpoint(TRUNCATE);", ())
.await
.context("Failed to checkpoint WAL")?;
Ok(())
}
pub async fn quick_check(&self) -> anyhow::Result<()> {
let rows = self
.query("PRAGMA quick_check;", ())
.await
.context("Failed to execute PRAGMA quick_check")?;
if let Some(row) = rows.first() {
match row.get_value(0)? {
Value::Text(s) if s == "ok" => {}
Value::Text(s) => anyhow::bail!("Database integrity check failed: {s}"),
_ => anyhow::bail!("Unexpected result from PRAGMA quick_check"),
}
}
Ok(())
}
pub async fn integrity_check(&self) -> anyhow::Result<Vec<String>> {
let rows = self
.query("PRAGMA integrity_check;", ())
.await
.context("Failed to execute PRAGMA integrity_check")?;
let mut problems: Vec<String> = Vec::new();
for row in &rows {
match row.get_value(0)? {
Value::Text(s) if s == "ok" => { }
Value::Text(s) => problems.push(s),
_ => anyhow::bail!("Unexpected result from PRAGMA integrity_check"),
}
}
Ok(problems)
}
}
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, 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,
{
Connection::query_row_impl(&self.conn, sql, params, map).await
}
pub async fn query(
&self,
sql: &str,
params: impl IntoParams + Send + 'static,
) -> turso::Result<Vec<Row>> {
Connection::query_impl(&self.conn, sql, params).await
}
pub async fn commit(mut self) -> turso::Result<()> {
self.conn.execute("COMMIT", ()).await?;
self.has_dangling_tx = None;
Ok(())
}
pub async fn rollback(mut self) -> turso::Result<()> {
self.conn.execute("ROLLBACK", ()).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 async fn ensure_fts_index(
conn: &Connection,
index_name: &str,
tokenizer: &str,
ddl: &str,
) -> anyhow::Result<()> {
let existing_sql: Option<String> = conn
.query_optional(
"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?
.filter(|s| !s.is_empty());
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("PRAGMA foreign_keys = ON;", ())
.await
.context("Failed to enable foreign key enforcement")?;
conn.execute_batch(schema)
.await
.context(format!("Failed to run schema {schema}"))?;
Ok(conn)
}
pub(crate) async fn open_store(
root: &Path,
name: &str,
schema: &str,
) -> anyhow::Result<Connection> {
let db_path = root.join("db").join(format!("{name}.db"));
open_with_schema(&db_path, schema).await
}
pub(crate) async fn with_tx(
conn: &Connection,
ticket_id: &str,
action_label: &str,
work: impl AsyncFnOnce(&TxGuard<'_>) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
let tx = conn
.begin_tx()
.await
.map_err(|e| {
warn!(
ticket = %ticket_id,
error = %e,
"Failed to begin transaction for {action_label}",
);
e
})
.with_context(|| format!("Failed to begin transaction for {action_label}"))?;
if let Err(e) = work(&tx).await {
if let Err(rollback_err) = tx.rollback().await {
warn!(
ticket = %ticket_id,
error = %rollback_err,
"Transaction rollback also failed for {action_label}",
);
}
warn!(
ticket = %ticket_id,
error = %e,
"{action_label}: transaction rolled back",
);
return Err(e.context(format!("{action_label}: transaction rolled back")));
}
tx.commit()
.await
.map_err(|e| {
warn!(
ticket = %ticket_id,
error = %e,
"Failed to commit transaction for {action_label}",
);
e
})
.with_context(|| format!("Failed to commit transaction for {action_label}"))?;
Ok(())
}
#[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() {
let cases = [
("hello world", "hello world"),
("+-~", ""),
("`Hello ${name}`", "Hello $ name"),
(
"contact user@example.com now",
"contact user@example.com now",
),
(
"hello, world! How's it going?",
"hello, world! How s it going?",
),
("!@#$%", "!@#$%"),
("my_function", "my_function"),
("#381", "#381"),
("v1.2.3", "v1.2.3"),
("feature/x", "feature/x"),
("/something", "something"),
("-hello", "hello"),
("hello-world", "hello world"),
("+term", "term"),
("don't", "don t"),
];
for (input, expected) in cases {
assert_eq!(sanitize_fts_query(input), expected, "input: {input:?}");
}
}
#[test]
fn test_parse_utc_timestamp() {
let valid_cases = [
("2024-01-15T10:30:00Z", "2024-01-15T10:30:00+00:00"),
("2024-06-15T14:30:00+05:00", "2024-06-15T09:30:00+00:00"),
("2024-12-25T20:00:00-08:00", "2024-12-26T04:00:00+00:00"),
];
for (input, expected) in valid_cases {
let ts = parse_utc_timestamp(input)
.unwrap_or_else(|e| panic!("parse_utc_timestamp({input:?}) failed: {e}"));
assert_eq!(ts.to_rfc3339(), expected, "input: {input:?}");
}
for invalid in ["garbage", "", "2024-01-15"] {
assert!(
parse_utc_timestamp(invalid).is_err(),
"expected error for: {invalid:?}",
);
}
}
#[tokio::test]
async fn test_quick_check_passes_on_healthy_db() {
let tmp = tempfile::TempDir::new().expect("temp dir for test");
let conn = Connection::open(tmp.path().join("test.db").as_path())
.await
.expect("open test database");
conn.quick_check()
.await
.expect("quick_check should pass on a healthy empty database");
}
#[tokio::test]
async fn test_integrity_check_passes_on_healthy_db() {
let tmp = tempfile::TempDir::new().expect("temp dir for test");
let conn = Connection::open(tmp.path().join("test.db").as_path())
.await
.expect("open test database");
conn.execute(
"CREATE TABLE IF NOT EXISTS _test (id INTEGER PRIMARY KEY, val TEXT NOT NULL)",
(),
)
.await
.expect("create test table");
conn.execute("INSERT INTO _test (id, val) VALUES (1, 'hello')", ())
.await
.expect("insert test row");
let problems = conn
.integrity_check()
.await
.expect("integrity_check should pass on a healthy database");
assert!(
problems.is_empty(),
"expected no integrity problems, got: {problems:?}",
);
}
}