use anyhow::Context;
use chrono::{DateTime, Utc};
use futures_util::FutureExt;
use std::future::Future;
use std::panic::AssertUnwindSafe;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tracing::{info, warn};
use turso::Builder;
pub(crate) use turso::{IntoParams, Row, Value, params};
pub(crate) mod cdc;
pub mod checkpoint;
pub mod debug;
pub mod ipc;
pub(crate) mod migrations;
pub mod wal_guard;
#[must_use]
pub fn now() -> String {
Utc::now().to_rfc3339()
}
pub(crate) fn parse_utc_timestamp(s: &str) -> Result<DateTime<Utc>, chrono::ParseError> {
DateTime::parse_from_rfc3339(s).map(|dt| dt.with_timezone(&Utc))
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "Referenced only by assertion tests; kept for documentation"
)
)]
pub(crate) const EXPERIMENTAL_FEATURES: &[&str] = &["index_method"];
pub(crate) const CONSOLIDATED_DB_NAME: &str = "core";
pub(crate) const DOMAIN_STORE_NAMES: &[&str] = &[
"board",
"sessions",
"workspaces",
"users",
"config",
"chat_history",
"alarms",
];
pub(crate) const LOG_DB_NAME: &str = "logs";
pub(crate) static DOMAIN_CONN: tokio::sync::OnceCell<Connection> =
tokio::sync::OnceCell::const_new();
pub(crate) fn iter_checkpoint_stores()
-> impl Iterator<Item = (&'static str, Option<&'static crate::db::Connection>)> {
[
(CONSOLIDATED_DB_NAME, DOMAIN_CONN.get()),
(LOG_DB_NAME, crate::logs::LOG_STORE.get().map(|s| &s.conn)),
]
.into_iter()
}
pub(crate) fn store_names() -> Vec<&'static str> {
let mut names: Vec<&'static str> = DOMAIN_STORE_NAMES.to_vec();
names.push(LOG_DB_NAME);
names
}
pub(crate) fn debug_db_names() -> Vec<&'static str> {
let mut names = store_names();
names.push(CONSOLIDATED_DB_NAME);
names
}
pub async fn init_all_stores() -> anyhow::Result<()> {
if DOMAIN_CONN.get().is_some() {
return Ok(());
}
let root = crate::config::CONFIG.global_storage_root();
let conn = open_consolidated_store(&root).await?;
let board = crate::pipeline::board::BoardStore { conn: conn.clone() };
board.after_open().await?;
let users = crate::users::UserStore { conn: conn.clone() };
users.ensure_admin_user().await?;
let session = crate::session::SessionStore { conn: conn.clone() };
let workspace = crate::workspace::WorkspaceStore { conn: conn.clone() };
let config = crate::config_db::ConfigStore { conn: conn.clone() };
let chat_history = crate::channels::chat_history::ChatHistoryStore { conn: conn.clone() };
let alarms = crate::alarms::AlarmStore { conn: conn.clone() };
let _ = DOMAIN_CONN.set(conn.clone());
crate::db::cdc::spawn_drainer(conn.clone());
crate::pipeline::board::BOARD
.set(board)
.map_err(|_| anyhow::anyhow!("BOARD already initialized"))?;
crate::session::SESSIONS
.set(session)
.map_err(|_| anyhow::anyhow!("SESSIONS already initialized"))?;
crate::workspace::WORKSPACES
.set(workspace)
.map_err(|_| anyhow::anyhow!("WORKSPACES already initialized"))?;
crate::users::USER_STORE
.set(users)
.map_err(|_| anyhow::anyhow!("USER_STORE already initialized"))?;
crate::config_db::CONFIG_STORE
.set(config)
.map_err(|_| anyhow::anyhow!("CONFIG_STORE already initialized"))?;
crate::channels::chat_history::CHAT_HISTORY
.set(chat_history)
.map_err(|_| anyhow::anyhow!("CHAT_HISTORY already initialized"))?;
crate::alarms::ALARMS
.set(alarms)
.map_err(|_| anyhow::anyhow!("ALARMS already initialized"))?;
Ok(())
}
#[must_use]
pub(crate) fn experimental_database_opts() -> turso::core::DatabaseOpts {
turso::core::DatabaseOpts::new().with_index_method(true)
}
static TANTIVY_SPECIAL: &[char] = &[
'+', '^', '~', ':', '{', '}', '"', '\'', '`', '[', ']', '(', ')', '\\', '*', '-', ];
#[must_use]
pub(crate) fn sanitize_fts_query(query: &str) -> String {
query
.split(|c: char| c.is_whitespace() || TANTIVY_SPECIAL.contains(&c))
.map(|word| word.trim_start_matches('/'))
.filter(|word| !word.is_empty())
.collect::<Vec<_>>()
.join(" ")
}
#[must_use]
pub(crate) fn sql_in_placeholders(count: usize) -> String {
vec!["?"; count].join(", ")
}
#[derive(Clone, Debug)]
pub(crate) struct Connection {
conn: Arc<tokio::sync::Mutex<turso::Connection>>,
has_dangling_tx: Arc<AtomicBool>,
}
pub(crate) struct ReadonlyRows {
pub columns: Vec<String>,
pub rows: Vec<Vec<Value>>,
pub truncated: bool,
}
const KNOWN_FTS_DIR_COUNT_FALSE_POSITIVE: &str =
"wrong # of entries in index __turso_internal_fts_dir_";
const FTS_INTERNAL_INDEX_PREFIX: &str = "__turso_internal_fts_dir_";
pub(crate) const USER_OBJECT_FILTER: &str = "name NOT LIKE 'sqlite_%' AND name NOT LIKE '__turso_internal_%' \
AND name NOT IN ('turso_cdc', 'turso_cdc_version')";
fn remove_rebuild_temp(temp: &Path) {
let _ = std::fs::remove_file(temp);
let _ = std::fs::remove_file(format!("{}-wal", temp.display()));
let _ = std::fs::remove_file(format!("{}-shm", temp.display()));
}
struct TempCleanup<'a>(&'a Path);
impl Drop for TempCleanup<'_> {
fn drop(&mut self) {
remove_rebuild_temp(self.0);
}
}
#[must_use]
pub(crate) fn known_fts_dir_false_positive(message: &str) -> bool {
message.contains(FTS_INTERNAL_INDEX_PREFIX)
}
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()
}
async fn run_readonly_query(
conn: &turso::Connection,
sql: &str,
params: impl IntoParams + Send + 'static,
row_limit: usize,
) -> turso::Result<ReadonlyRows> {
let mut rows = conn.query(sql, params).await?;
let columns = rows.column_names();
let col_count = columns.len();
let mut result_rows = Vec::new();
let mut count = 0usize;
let mut truncated = false;
while let Some(row) = rows.next().await? {
if count >= row_limit {
truncated = true;
break;
}
let mut values = Vec::with_capacity(col_count);
for idx in 0..col_count {
values.push(row.get_value(idx)?);
}
result_rows.push(values);
count += 1;
}
Ok(ReadonlyRows {
columns,
rows: result_rows,
truncated,
})
}
const OPEN_LOCK_RETRY_ATTEMPTS: usize = 10;
const OPEN_LOCK_RETRY_DELAY: Duration = Duration::from_millis(500);
fn is_open_time_lock_error(err: &turso::Error) -> bool {
matches!(
err,
turso::Error::Error(msg)
if msg.contains("Locking error:") && msg.contains("File is locked by another process")
)
}
async fn with_open_lock_retry<T, F, Fut>(
attempts: usize,
delay: Duration,
mut attempt: F,
) -> Result<T, turso::Error>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, turso::Error>>,
{
let mut remaining = attempts;
loop {
match attempt().await {
Ok(value) => return Ok(value),
Err(err) if is_open_time_lock_error(&err) => {
warn!(
attempt = attempts - remaining + 1,
total_attempts = attempts + 1,
error = ?err,
"database open blocked by another process (open-time WAL lock)"
);
if remaining == 0 {
return Err(err);
}
tokio::time::sleep(delay).await;
remaining -= 1;
}
Err(err) => return Err(err),
}
}
}
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 = with_open_lock_retry(OPEN_LOCK_RETRY_ATTEMPTS, OPEN_LOCK_RETRY_DELAY, || async {
Builder::new_local(path_str)
.experimental_index_method(opts.enable_index_method)
.build()
.await
})
.await
.context("failed to open local database")?;
let conn = db.connect()?;
conn.busy_timeout(Duration::from_mins(1))?;
conn.execute("PRAGMA temp_store = MEMORY;", ())
.await
.context("failed to set in-memory temp storage (PRAGMA temp_store = MEMORY)")?;
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_cached(
&self,
sql: &str,
params: impl IntoParams + Send + 'static,
) -> turso::Result<u64> {
let conn = self.lock_and_cleanup().await;
let mut stmt = conn.prepare_cached(sql).await?;
stmt.execute(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
}
pub(crate) async fn query_readonly(
&self,
sql: &str,
params: impl IntoParams + Send + 'static,
row_limit: usize,
) -> turso::Result<ReadonlyRows> {
let conn = self.lock_and_cleanup().await;
conn.execute("PRAGMA query_only = 1;", ()).await?;
let result = AssertUnwindSafe(run_readonly_query(&conn, sql, params, row_limit))
.catch_unwind()
.await;
let (reset, tx_reset) = match AssertUnwindSafe(async {
let reset = conn.execute("PRAGMA query_only = 0;", ()).await;
let tx_reset = match conn.is_autocommit() {
Ok(true) => Ok(()),
_ => conn.execute("ROLLBACK;", ()).await.map(|_| ()),
};
(reset, tx_reset)
})
.catch_unwind()
.await
{
Ok(ok) => ok,
Err(payload) => {
self.has_dangling_tx.store(true, Ordering::SeqCst);
return Err(turso::Error::Error(format!(
"the database engine panicked while resetting the read-only query state: {}",
crate::util::panic_message(&*payload)
)));
}
};
if let Err(e) = &reset {
tracing::error!(
error = %e,
"query_readonly: failed to reset PRAGMA query_only — daemon writes may \
be left disabled on the shared connection"
);
}
if let Err(e) = &tx_reset {
self.has_dangling_tx.store(true, Ordering::SeqCst);
tracing::error!(
error = %e,
"query_readonly: failed to roll back a leaked transaction — daemon writes may \
be left disabled on the shared connection"
);
}
match result {
Ok(Ok(rows)) => {
reset?;
tx_reset?;
Ok(rows)
}
Ok(Err(e)) => {
reset?;
tx_reset?;
Err(e)
}
Err(payload) => {
reset?;
tx_reset?;
Err(turso::Error::Error(format!(
"the database engine panicked while executing the query: {}",
crate::util::panic_message(&*payload)
)))
}
}
}
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_map_strict<T, E>(
&self,
sql: &str,
params: impl IntoParams + Send + 'static,
map: impl FnMut(&Row) -> std::result::Result<T, E> + Send + 'static,
) -> anyhow::Result<Vec<T>>
where
T: Send + 'static,
E: std::fmt::Display + Send + Sync + 'static,
{
let rows = self.query_map(sql, params, map).await?;
rows.into_iter()
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Into::into)
}
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 query_cached(
&self,
sql: &str,
params: impl IntoParams + Send + 'static,
) -> turso::Result<Vec<Row>> {
let conn = self.lock_and_cleanup().await;
Self::query_cached_impl(&conn, sql, params).await
}
async fn query_cached_impl(
conn: &turso::Connection,
sql: &str,
params: impl IntoParams + Send + 'static,
) -> turso::Result<Vec<Row>> {
let mut stmt = conn.prepare_cached(sql).await?;
let mut rows = stmt.query(params).await?;
let mut result = Vec::new();
while let Some(row) = rows.next().await? {
result.push(row);
}
Ok(result)
}
pub async fn query_map_strict_cached<T, E>(
&self,
sql: &str,
params: impl IntoParams + Send + 'static,
map: impl FnMut(&Row) -> std::result::Result<T, E> + Send + 'static,
) -> anyhow::Result<Vec<T>>
where
T: Send + 'static,
E: std::fmt::Display + Send + Sync + 'static,
{
let rows = self.query_cached(sql, params).await?;
map_rows(&rows, map)
.into_iter()
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Into::into)
}
async fn query_row_cached<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;
let mut stmt = conn.prepare_cached(sql).await?;
let mut rows = stmt.query(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_optional_cached<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_cached(sql, params, map).await {
Ok(val) => Ok(Some(val)),
Err(::turso::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
pub(crate) async fn checkpoint(&self) -> anyhow::Result<CheckpointOutcome> {
self.run_checkpoint(CheckpointMode::Truncate).await
}
pub(crate) async fn checkpoint_passive(&self) -> anyhow::Result<CheckpointOutcome> {
self.run_checkpoint(CheckpointMode::Passive).await
}
async fn run_checkpoint(&self, mode: CheckpointMode) -> anyhow::Result<CheckpointOutcome> {
let rows = self
.query(&format!("PRAGMA wal_checkpoint({});", mode.label()), ())
.await
.context("Failed to checkpoint WAL")?;
let row = rows
.first()
.context("PRAGMA wal_checkpoint returned no result row")?;
Ok(CheckpointOutcome {
busy: match row.get_value(0)? {
Value::Integer(n) => n != 0,
_ => anyhow::bail!("Unexpected result from PRAGMA wal_checkpoint"),
},
log_frames: int_column(row, 1)?,
checkpointed_frames: int_column(row, 2)?,
})
}
pub async fn quick_check(&self) -> anyhow::Result<()> {
if let Some(problem) = self.quick_check_problems().await?.into_iter().next() {
anyhow::bail!("Database integrity check failed: {problem}");
}
Ok(())
}
pub(crate) async fn quick_check_problems(&self) -> anyhow::Result<Vec<String>> {
let rows = self
.query("PRAGMA quick_check;", ())
.await
.context("Failed to execute PRAGMA quick_check")?;
scan_integrity_rows(&rows)
}
}
fn scan_integrity_rows(rows: &[Row]) -> anyhow::Result<Vec<String>> {
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) if s.contains(KNOWN_FTS_DIR_COUNT_FALSE_POSITIVE) => {}
Value::Text(s) => problems.push(s),
_ => anyhow::bail!("Unexpected result from PRAGMA quick_check"),
}
}
Ok(problems)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct CheckpointOutcome {
pub busy: bool,
pub log_frames: i64,
pub checkpointed_frames: i64,
}
impl CheckpointOutcome {
#[must_use]
pub fn is_complete(&self) -> bool {
!self.busy && self.log_frames <= self.checkpointed_frames
}
}
fn int_column(row: &Row, idx: usize) -> anyhow::Result<i64> {
match row.get_value(idx)? {
Value::Integer(n) => Ok(n),
_ => anyhow::bail!("Unexpected result from PRAGMA wal_checkpoint"),
}
}
pub(crate) 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 execute_batch(&self, sql: &str) -> turso::Result<()> {
self.conn.execute_batch(sql).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(crate) 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(crate) async fn table_exists(conn: &Connection, table: &str) -> anyhow::Result<bool> {
let rows = conn
.query(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?1",
params![table],
)
.await
.context("Failed to read table schema (sqlite_master)")?;
Ok(!rows.is_empty())
}
pub(crate) 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")?;
execute_schema_ddl(&conn, schema).await?;
Ok(conn)
}
async fn execute_schema_ddl(conn: &Connection, sql: &str) -> anyhow::Result<()> {
if sql.trim().is_empty() {
return Ok(());
}
conn.execute_batch(sql)
.await
.context("Failed to run schema DDL")?;
Ok(())
}
#[must_use]
pub(crate) fn store_db_path(root: &Path, name: &str) -> std::path::PathBuf {
let stem = if name == LOG_DB_NAME {
LOG_DB_NAME
} else {
CONSOLIDATED_DB_NAME
};
root.join("db").join(format!("{stem}.db"))
}
pub(crate) fn legacy_store_db_path(root: &Path, name: &str) -> std::path::PathBuf {
root.join("db").join(format!("{name}.db"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StoreSidecars {
pub wal: std::path::PathBuf,
pub shm: std::path::PathBuf,
pub tshm: std::path::PathBuf,
}
#[must_use]
pub(crate) fn store_sidecars(db_path: &Path) -> StoreSidecars {
let base = db_path.display().to_string();
StoreSidecars {
wal: std::path::PathBuf::from(format!("{base}-wal")),
shm: std::path::PathBuf::from(format!("{base}-shm")),
tshm: std::path::PathBuf::from(format!("{base}-tshm")),
}
}
const RESOURCE_SIGNAL_KEYWORDS: [&str; 4] = [
"no space left on device",
"too many open files",
"out of memory",
"permission denied",
];
fn has_resource_signal(lower: &str) -> bool {
RESOURCE_SIGNAL_KEYWORDS.iter().any(|k| lower.contains(k))
}
pub(crate) fn is_corruption_class(e: &anyhow::Error) -> bool {
if is_schema_mismatch(e) {
return false;
}
let msg = format!("{e:#}");
if msg.contains("Database integrity check failed") {
return true;
}
let lower = msg.to_lowercase();
!(has_resource_signal(&lower)
|| lower.contains("busy")
|| lower.contains("locked")
|| lower.contains("i/o error")
|| lower.contains("no such file"))
}
fn is_schema_mismatch(e: &anyhow::Error) -> bool {
const DDL_SHAPE: [&str; 8] = [
"no such column",
"no such table",
"no such index",
"has no column",
"duplicate column",
"already exists",
"syntax error",
"parse error",
];
let lower = format!("{e:#}").to_lowercase();
DDL_SHAPE.iter().any(|k| lower.contains(k))
}
fn is_actionable_signal(e: &anyhow::Error) -> bool {
has_resource_signal(&format!("{e:#}").to_lowercase())
}
#[derive(Debug)]
pub(crate) struct RecreateFailed(pub anyhow::Error);
impl std::fmt::Display for RecreateFailed {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "fresh store open failed after quarantine: {}", self.0)
}
}
impl std::error::Error for RecreateFailed {}
pub(crate) async fn open_store(
root: &Path,
name: &str,
schema: &str,
) -> anyhow::Result<Connection> {
let db_path = store_db_path(root, name);
let Some(diagnosis) = crate::db::wal_guard::take_boot_diagnosis(&db_path) else {
return open_with_schema(&db_path, schema).await;
};
match diagnosis {
crate::db::wal_guard::BootDiagnosis::Healthy => {
let result = AssertUnwindSafe(open_and_repair(&db_path, name, schema))
.catch_unwind()
.await;
match result {
Ok(Ok(conn)) => Ok(conn),
Ok(Err(e)) => Err(e),
Err(payload) => {
recreate_after_failed_heal(&db_path, name, schema, &panic_err(&*payload)).await
}
}
}
crate::db::wal_guard::BootDiagnosis::DurableB => {
let healed = AssertUnwindSafe(heal_checkpoint_sequence(
&db_path,
name,
crate::db::wal_guard::BootDiagnosis::DurableB,
))
.catch_unwind()
.await;
match healed {
Ok(Ok(())) => {}
Ok(Err(e)) => {
if is_actionable_signal(&e) {
return Err(e);
}
return recreate_after_failed_heal(&db_path, name, schema, &e).await;
}
Err(payload) => {
return recreate_after_failed_heal(
&db_path,
name,
schema,
&panic_err(&*payload),
)
.await;
}
}
let result = AssertUnwindSafe(open_and_repair(&db_path, name, schema))
.catch_unwind()
.await;
match result {
Ok(Ok(conn)) => Ok(conn),
Ok(Err(e)) => Err(e),
Err(payload) => Err(anyhow::anyhow!(
"post-heal open of store '{name}' panicked: {}",
crate::util::panic_message(&*payload)
)),
}
}
crate::db::wal_guard::BootDiagnosis::Structural => {
crate::boot::boot_diagnostic(format!(
"store '{name}' structurally damaged (pre-flight diagnosis) — \
quarantining and recreating",
));
let _ = quarantine_store_artifacts(&db_path);
let result = AssertUnwindSafe(open_with_schema(&db_path, schema))
.catch_unwind()
.await;
match result {
Ok(Ok(conn)) => Ok(conn),
Ok(Err(e)) => Err(anyhow::anyhow!(RecreateFailed(e))),
Err(payload) => Err(anyhow::anyhow!(RecreateFailed(anyhow::anyhow!(
"recreated store '{name}' open panicked: {}",
crate::util::panic_message(&*payload)
)))),
}
}
}
}
pub(crate) async fn open_consolidated_store(root: &Path) -> anyhow::Result<Connection> {
let conn = open_store(root, CONSOLIDATED_DB_NAME, "").await?;
migrations::run_migrations(&conn, migrations::TargetDb::Core, root).await?;
cdc::enable_capture(&conn).await?;
Ok(conn)
}
pub(crate) async fn import_legacy_stores(conn: &Connection, root: &Path) -> anyhow::Result<()> {
conn.execute("PRAGMA foreign_keys = OFF;", ())
.await
.context("Failed to disable FK enforcement during consolidation import")?;
let result = import_legacy_stores_inner(conn, root).await;
let reenabled = conn
.execute("PRAGMA foreign_keys = ON;", ())
.await
.context("Failed to re-enable FK enforcement after consolidation import");
match (result, reenabled) {
(Ok(()), Ok(_)) => Ok(()),
(Err(e), _) | (Ok(()), Err(e)) => Err(e),
}
}
async fn import_legacy_stores_inner(conn: &Connection, root: &Path) -> anyhow::Result<()> {
for name in DOMAIN_STORE_NAMES {
let legacy_path = legacy_store_db_path(root, name);
if !legacy_path.exists() {
continue;
}
if let Err(e) = import_one_legacy_store(conn, name, &legacy_path).await {
return Err(e).with_context(|| format!("consolidation import failed for '{name}'"));
}
}
Ok(())
}
async fn import_one_legacy_store(
conn: &Connection,
store_name: &str,
legacy_path: &Path,
) -> anyhow::Result<()> {
use std::sync::Arc;
let io: Arc<dyn turso::core::IO> = Arc::new(turso::core::PlatformIO::new()?);
let path_str = legacy_path
.to_str()
.with_context(|| format!("legacy store path must be UTF-8: {}", legacy_path.display()))?;
let db = turso::core::Database::open_file_with_flags(
io.clone(),
path_str,
turso::core::OpenFlags::ReadOnly | turso::core::OpenFlags::NoLock,
experimental_database_opts(),
None,
)
.with_context(|| format!("failed to open legacy store '{store_name}' read-only"))?;
let src = db
.connect()
.with_context(|| format!("failed to connect to legacy store '{store_name}'"))?;
let src_tables = read_sync_rows(
&io,
&src,
&format!("SELECT name FROM sqlite_master WHERE type='table' AND {USER_OBJECT_FILTER}"),
1,
)?;
for table_row in src_tables {
let Some(src_table) = table_row.first().and_then(|v| match v {
Value::Text(s) => Some(s.as_str()),
_ => None,
}) else {
continue;
};
let Some(target_table) = consolidate_table_name(src_table) else {
continue;
};
if !table_exists(conn, target_table).await? {
continue;
}
copy_table(conn, &io, &src, src_table, target_table).await?;
}
copy_sqlite_sequence(conn, &io, &src).await?;
Ok(())
}
#[allow(clippy::match_same_arms)]
fn consolidate_table_name(src: &str) -> Option<&'static str> {
match src {
"tickets" => Some("tickets"),
"ticket_comments" => Some("ticket_comments"),
"ticket_counters" => Some("ticket_counters"),
"sessions" => Some("sessions"),
"session_metadata" => Some("session_metadata"),
"jobs" => Some("jobs"),
"agents" => Some("agents"),
"pending_jobs" => Some("pending_jobs"),
"research_jobs" => Some("research_jobs"),
"workspaces" => Some("workspaces"),
"workspace_contexts" => Some("workspace_contexts"),
"editor_tabs" => Some("editor_tabs"),
"users" => Some("users"),
"user_channels" => Some("user_channels"),
"user_roles" => None,
"config_kv" => Some("config_kv"),
"config_model_routing" => Some("config_model_routing"),
"chat_history" => Some("chat_history"),
_ => None,
}
}
const IMPORT_READ_BATCH: usize = 10_000;
async fn copy_table(
conn: &Connection,
io: &std::sync::Arc<dyn turso::core::IO>,
src: &std::sync::Arc<turso::core::Connection>,
src_table: &str,
target_table: &str,
) -> anyhow::Result<()> {
let src_cols = column_names_sync(io, src, src_table)?;
let target_cols = column_names_async(conn, target_table).await?;
let common: Vec<String> = target_cols
.iter()
.filter(|c| src_cols.contains(*c))
.cloned()
.collect();
if common.is_empty() {
return Ok(());
}
let col_list = common.join(", ");
let placeholders = vec!["?"; common.len()].join(", ");
let insert_sql = format!("INSERT INTO {target_table} ({col_list}) VALUES ({placeholders})");
let tx = conn
.begin_tx()
.await
.with_context(|| format!("failed to begin import for '{target_table}'"))?;
tx.execute(&format!("DELETE FROM {target_table}"), ())
.await
.with_context(|| format!("failed to clear target table '{target_table}'"))?;
let mut offset = 0usize;
loop {
let batch_select = format!(
"SELECT {col_list} FROM {src_table} ORDER BY rowid LIMIT {IMPORT_READ_BATCH} OFFSET {offset}"
);
let rows = read_sync_rows(io, src, &batch_select, common.len())?;
if rows.is_empty() {
break;
}
for vals in rows {
tx.execute(&insert_sql, vals).await.with_context(|| {
format!("failed to insert row into '{target_table}' during import")
})?;
}
offset += IMPORT_READ_BATCH;
}
tx.commit()
.await
.with_context(|| format!("failed to commit import for '{target_table}'"))?;
Ok(())
}
fn read_sync_rows(
io: &std::sync::Arc<dyn turso::core::IO>,
src: &std::sync::Arc<turso::core::Connection>,
sql: &str,
ncols: usize,
) -> anyhow::Result<Vec<Vec<Value>>> {
use turso::core::StepResult;
let Some(mut stmt) = src
.query(sql)
.with_context(|| format!("failed to prepare read: {sql}"))?
else {
return Ok(Vec::new());
};
let mut out = Vec::new();
loop {
match stmt.step() {
Ok(StepResult::Row) => {
let row = stmt.row().context("row missing after StepResult::Row")?;
let mut vals = Vec::with_capacity(ncols);
for idx in 0..ncols {
vals.push(row_to_value(row.get_value(idx)));
}
out.push(vals);
}
Ok(StepResult::Done) => break,
Ok(StepResult::IO | StepResult::Yield) => {
io.step().context("I/O step during read-only import")?;
}
Ok(StepResult::Interrupt) => {
return Err(anyhow::anyhow!("read-only import query interrupted"));
}
Ok(StepResult::Busy) => {
return Err(anyhow::anyhow!(
"read-only import query busy; try again later"
));
}
Err(e) => return Err(anyhow::anyhow!("read-only import query failed: {e}")),
}
}
Ok(out)
}
fn column_names_sync(
io: &std::sync::Arc<dyn turso::core::IO>,
src: &std::sync::Arc<turso::core::Connection>,
table: &str,
) -> anyhow::Result<Vec<String>> {
let rows = read_sync_rows(io, src, &format!("PRAGMA table_info({table})"), 2)?;
Ok(rows
.iter()
.filter_map(|r| match r.get(1) {
Some(Value::Text(s)) => Some(s.as_str().to_string()),
_ => None,
})
.collect())
}
async fn column_names_async(conn: &Connection, table: &str) -> anyhow::Result<Vec<String>> {
let rows = conn
.query(&format!("PRAGMA table_info({table})"), ())
.await
.with_context(|| format!("failed to read columns of target '{table}'"))?;
Ok(rows
.iter()
.filter_map(|r| r.get::<String>(1).ok())
.collect())
}
fn row_to_value(v: &turso::core::Value) -> Value {
match v {
turso::core::Value::Null => Value::Null,
turso::core::Value::Numeric(turso::core::Numeric::Integer(i)) => Value::Integer(*i),
turso::core::Value::Numeric(turso::core::Numeric::Float(f)) => {
let f64_val: f64 = (*f).into();
Value::Real(f64_val)
}
turso::core::Value::Text(t) => Value::Text(t.as_str().to_string()),
turso::core::Value::Blob(b) => Value::Blob(b.clone()),
}
}
async fn copy_sqlite_sequence(
conn: &Connection,
io: &std::sync::Arc<dyn turso::core::IO>,
src: &std::sync::Arc<turso::core::Connection>,
) -> anyhow::Result<()> {
let Ok(rows) = read_sync_rows(io, src, "SELECT name, seq FROM sqlite_sequence", 2) else {
return Ok(());
};
for row in rows {
let (Some(name), Some(seq)) = (
row.first().and_then(|v| match v {
Value::Text(s) => Some(s.as_str().to_string()),
_ => None,
}),
row.get(1).and_then(|v| match v {
Value::Integer(i) => Some(*i),
_ => None,
}),
) else {
continue;
};
if !table_exists(conn, &name).await? {
continue;
}
conn.execute(
"DELETE FROM sqlite_sequence WHERE name = ?1",
params![name.clone()],
)
.await
.with_context(|| format!("failed to clear AUTOINCREMENT watermark for '{name}'"))?;
conn.execute(
"INSERT INTO sqlite_sequence (name, seq) VALUES (?1, ?2)",
params![name.clone(), seq],
)
.await
.with_context(|| format!("failed to preserve AUTOINCREMENT watermark for '{name}'"))?;
}
Ok(())
}
fn panic_err(payload: &(dyn std::any::Any + Send)) -> anyhow::Error {
anyhow::anyhow!(
"store open panicked: {}",
crate::util::panic_message(payload)
)
}
enum RepairOutcome {
NoRepair,
Repaired,
Migrated,
Unreadable,
}
async fn open_and_repair(db_path: &Path, name: &str, schema: &str) -> anyhow::Result<Connection> {
let conn = match open_with_schema(db_path, schema).await {
Ok(conn) => conn,
Err(e) if is_corruption_class(&e) => {
return recreate_after_failed_heal(db_path, name, schema, &e).await;
}
Err(e) => return Err(e),
};
match repair_btree_index_if_desynced(conn, db_path, name, schema).await {
Ok((RepairOutcome::Unreadable, conn)) => {
drop(conn); recreate_after_failed_heal(
db_path,
name,
schema,
&anyhow::anyhow!("class-B unreadable table — recreate justified"),
)
.await
}
Ok((_, conn)) => Ok(conn),
Err(e) => Err(e),
}
}
const BAKED_OVERFLOW_ALIASING_READ: &str = "short read on page 167772160";
enum RepairTarget {
Unreadable,
OverflowAliasing,
Index(String),
Unknown,
}
fn classify_repair_target(problems: &[String]) -> RepairTarget {
if problems.iter().any(|p| p.contains("Invalid page type")) {
RepairTarget::Unreadable
} else if problems.iter().any(|p| {
p.contains("referenced multiple times") || p.contains(BAKED_OVERFLOW_ALIASING_READ)
}) {
RepairTarget::OverflowAliasing
} else if problems.iter().any(|p| p.contains("short read")) {
RepairTarget::Unreadable
} else {
match problems.iter().find_map(|p| desynced_index_name(p)) {
Some(index) => RepairTarget::Index(index.to_string()),
None => RepairTarget::Unknown,
}
}
}
#[must_use]
pub(crate) fn pre_reindex_snapshot_path(db_path: &Path) -> std::path::PathBuf {
std::path::PathBuf::from(format!(
"{}.pre-reindex-{}",
db_path.display(),
family_stamp()
))
}
async fn repair_btree_index_if_desynced(
conn: Connection,
db_path: &Path,
name: &str,
schema: &str,
) -> anyhow::Result<(RepairOutcome, Connection)> {
let problems = match conn.quick_check_problems().await {
Ok(p) if p.is_empty() => return Ok((RepairOutcome::NoRepair, conn)),
Ok(p) => p,
Err(e) => {
if is_actionable_signal(&e) {
return Err(e);
}
crate::boot::boot_diagnostic(format!(
"store '{name}' quick_check could not run: {e} — unreadable table, \
recreate justified",
));
return Ok((RepairOutcome::Unreadable, conn));
}
};
let index = match classify_repair_target(&problems) {
RepairTarget::Unreadable => {
crate::boot::boot_diagnostic(format!(
"store '{name}' quick_check cannot scan a table ({}) — unreadable table, \
recreate justified",
problems.join("; "),
));
return Ok((RepairOutcome::Unreadable, conn));
}
RepairTarget::OverflowAliasing => {
crate::boot::boot_diagnostic(format!(
"store '{name}' quick_check reports overflow-aliasing ({}) — rebuilding \
the store data-preservingly in a fresh file",
problems.join("; "),
));
return migrate_overflow_aliased_store(conn, db_path, name, schema).await;
}
RepairTarget::Unknown => {
crate::boot::boot_diagnostic(format!(
"store '{name}' quick_check flagged an unknown condition: {}",
problems.join("; "),
));
return Ok((RepairOutcome::NoRepair, conn));
}
RepairTarget::Index(index) => {
if known_fts_dir_false_positive(&index) {
crate::boot::boot_diagnostic(format!(
"store '{name}' quick_check flagged the known FTS false positive — \
not repairing",
));
return Ok((RepairOutcome::NoRepair, conn));
}
index
}
};
let snap = pre_reindex_snapshot_path(db_path);
let sidecars = store_sidecars(db_path);
for (src, suffix) in [(db_path, ""), (&sidecars.wal, "-wal")] {
if let Err(e) = std::fs::copy(
src,
std::path::PathBuf::from(format!("{}{suffix}", snap.display())),
) {
warn!(
error = %e,
from = %src.display(),
"Failed to copy pre-reindex snapshot",
);
}
}
let quoted = index.replace('"', "\"\"");
if conn
.execute_batch(&format!("REINDEX \"{quoted}\";"))
.await
.is_ok()
&& conn.quick_check().await.is_ok()
{
info!(
db = %name,
index = %index,
"class-B btree index desync repaired in place (REINDEX)",
);
return Ok((RepairOutcome::Repaired, conn));
}
crate::boot::boot_diagnostic(format!(
"store '{name}' REINDEX of '{index}' did not clear the quick_check desync — \
falling back to DROP+CREATE",
));
Ok((
drop_create_index_fallback(&conn, name, &index, "ed).await,
conn,
))
}
#[expect(clippy::too_many_lines)] async fn migrate_overflow_aliased_store(
conn: Connection,
db_path: &Path,
name: &str,
schema: &str,
) -> anyhow::Result<(RepairOutcome, Connection)> {
let mut counts: Vec<(String, i64)> = Vec::new();
let tables = match conn
.query(
&format!(
"SELECT name FROM sqlite_master WHERE type='table' \
AND {USER_OBJECT_FILTER} ORDER BY rowid"
),
(),
)
.await
{
Ok(t) => t,
Err(e) => {
let err = anyhow::anyhow!(e);
if is_actionable_signal(&err) {
return Err(err);
}
crate::boot::boot_diagnostic(format!(
"store '{name}' cannot enumerate tables for the rebuild: {err} — left \
for operator review",
));
return Ok((RepairOutcome::NoRepair, conn));
}
};
for t in &tables {
let tbl = match t.get::<String>(0) {
Ok(tbl) => tbl,
Err(e) => {
crate::boot::boot_diagnostic(format!(
"store '{name}' table name is not text ({e}) — rebuild aborted; \
left for operator review",
));
return Ok((RepairOutcome::NoRepair, conn));
}
};
let quoted = tbl.replace('"', "\"\"");
match conn
.query_row(&format!("SELECT COUNT(*) FROM \"{quoted}\""), (), |r| {
r.get::<i64>(0)
})
.await
{
Ok(count) => counts.push((tbl, count)),
Err(e) => {
crate::boot::boot_diagnostic(format!(
"store '{name}' table '{tbl}' is unreadable ({e}) — the data cannot \
be preserved by the rebuild; recreate justified",
));
return Ok((RepairOutcome::Unreadable, conn));
}
}
}
let temp =
std::path::PathBuf::from(format!("{}.rebuild-{}", db_path.display(), family_stamp()));
let _temp_guard = TempCleanup(&temp);
let fresh = match Connection::open(&temp).await {
Ok(f) => f,
Err(e) if is_actionable_signal(&e) => return Err(e),
Err(e) => {
crate::boot::boot_diagnostic(format!(
"store '{name}' rebuild store open failed: {e} — left for operator review",
));
return Ok((RepairOutcome::NoRepair, conn));
}
};
let migrated = migrate_schema_and_data(&conn, &fresh, &counts).await;
let outcome = match migrated {
Ok(()) => match fresh.quick_check().await {
Ok(()) => match fresh.checkpoint().await {
Ok(o) if o.is_complete() => Ok(()),
Ok(o) => Err(MigrateFailure::Finding(format!(
"rebuilt store checkpoint incomplete (busy={}, {} of {} frames)",
o.busy, o.checkpointed_frames, o.log_frames,
))),
Err(e) => Err(classify_migrate(e)),
},
Err(e) => Err(classify_migrate(e)),
},
Err(f) => Err(f),
};
drop(fresh); if let Err(failure) = outcome {
return match failure {
MigrateFailure::Actionable(e) => Err(e),
MigrateFailure::Finding(msg) => {
crate::boot::boot_diagnostic(format!(
"store '{name}' rebuild aborted: {msg} — original store preserved \
for operator review (no data changed)",
));
Ok((RepairOutcome::NoRepair, conn))
}
};
}
drop(conn); if !quarantine_store_artifacts(db_path) {
crate::boot::boot_diagnostic(format!(
"store '{name}' original family could not be fully quarantined — the \
rebuild swap would clobber it without a forensic record; rebuild aborted, \
migrated data discarded",
));
return if db_path.exists() {
crate::boot::boot_diagnostic(format!(
"store '{name}' original family remains in place — left for operator \
review",
));
open_with_schema(db_path, schema)
.await
.map(|reopened| (RepairOutcome::NoRepair, reopened))
} else {
crate::boot::boot_diagnostic(format!(
"store '{name}' original family is in the quarantine and the store \
path is empty — boot aborted; recover from the quarantine and retry",
));
Err(anyhow::anyhow!(
"store '{name}' rebuild aborted: partial quarantine with the main file \
already moved — the store path was not rebuilt"
))
};
}
let sidecars = store_sidecars(db_path);
let temp_sidecars = store_sidecars(&temp);
if let Err(e) = std::fs::rename(&temp, db_path) {
return Err(anyhow::anyhow!(e).context(format!(
"store '{name}' rebuild swap main-file rename failed — migrated temp \
family discarded; original family quarantined for recovery"
)));
}
for (src, dst) in [
(&temp_sidecars.wal, &sidecars.wal),
(&temp_sidecars.shm, &sidecars.shm),
(&temp_sidecars.tshm, &sidecars.tshm),
] {
if src.exists()
&& let Err(e) = std::fs::rename(src, dst)
{
warn!(
error = %e,
from = %src.display(),
to = %dst.display(),
"rebuild swap sidecar rename failed",
);
}
}
let reopened = match open_with_schema(db_path, schema).await {
Ok(reopened) => reopened,
Err(e) if is_actionable_signal(&e) => return Err(e),
Err(e) => {
crate::boot::boot_diagnostic(format!(
"store '{name}' rebuilt store reopen failed: {e} — original family is \
quarantined for recovery",
));
return Err(e);
}
};
let mut verified = true;
let mut finding_reported = false;
match reopened.quick_check().await {
Ok(()) => {}
Err(e) if is_actionable_signal(&e) => {
warn!(error = %e, db = %name, "post-swap verification quick_check hit a resource signal");
verified = false;
}
Err(e) => {
crate::boot::boot_diagnostic(format!(
"store '{name}' rebuilt store failed post-swap quick_check ({e}) — \
original family is quarantined for recovery",
));
verified = false;
finding_reported = true;
}
}
for (tbl, expected) in &counts {
let quoted = tbl.replace('"', "\"\"");
match reopened
.query_row(&format!("SELECT COUNT(*) FROM \"{quoted}\""), (), |r| {
r.get::<i64>(0)
})
.await
{
Err(e) => {
let err = anyhow::anyhow!(e);
if is_actionable_signal(&err) {
verified = false;
warn!(
error = %err,
db = %name,
table = %tbl,
"post-swap counts verification query hit a resource signal",
);
} else {
crate::boot::boot_diagnostic(format!(
"store '{name}' post-swap verification counts query for table \
'{tbl}' failed ({err}) — verification incomplete; original \
family is quarantined for recovery",
));
verified = false;
finding_reported = true;
}
}
Ok(c) if c != *expected => {
crate::boot::boot_diagnostic(format!(
"store '{name}' post-swap verification failed for table '{tbl}' \
(expected {expected} rows, found {c}) — the swap lost data; \
original family is quarantined for recovery",
));
verified = false;
finding_reported = true;
}
Ok(_) => {}
}
}
if verified {
info!(db = %name, "overflow-aliasing repaired: store rebuilt data-preservingly");
Ok((RepairOutcome::Migrated, reopened))
} else {
if !finding_reported {
warn!(db = %name, "post-swap verification incomplete (transient read errors) — store is in place; re-verified on the next boot");
}
Ok((RepairOutcome::NoRepair, reopened))
}
}
enum MigrateFailure {
Actionable(anyhow::Error),
Finding(String),
}
fn classify_migrate<E: Into<anyhow::Error>>(e: E) -> MigrateFailure {
let err = e.into();
if is_actionable_signal(&err) {
MigrateFailure::Actionable(err)
} else {
MigrateFailure::Finding(format!("{err:#}"))
}
}
fn row_insert_sql(table_ref: &str, row: &Row) -> Result<(String, Vec<Value>), MigrateFailure> {
let ncols = row.column_count();
let mut vals = Vec::with_capacity(ncols);
for c in 0..ncols {
vals.push(row.get_value(c).map_err(classify_migrate)?);
}
Ok((
format!(
"INSERT INTO \"{table_ref}\" VALUES ({})",
sql_in_placeholders(ncols)
),
vals,
))
}
async fn migrate_schema_and_data(
old: &Connection,
fresh: &Connection,
counts: &[(String, i64)],
) -> Result<(), MigrateFailure> {
let ddl_rows = old
.query(
&format!(
"SELECT sql FROM sqlite_master \
WHERE sql IS NOT NULL \
AND {USER_OBJECT_FILTER} \
ORDER BY rowid"
),
(),
)
.await
.map_err(classify_migrate)?;
for row in &ddl_rows {
let ddl = row
.get::<String>(0)
.map_err(|e| MigrateFailure::Finding(format!("DDL replay row is not text ({e})")))?;
fresh.execute(&ddl, ()).await.map_err(classify_migrate)?;
}
let has_sequence: i64 = old
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='sqlite_sequence'",
(),
|r| r.get::<i64>(0),
)
.await
.map_err(classify_migrate)?;
if has_sequence > 0 {
let seq_rows = old
.query("SELECT * FROM sqlite_sequence", ())
.await
.map_err(classify_migrate)?;
for row in &seq_rows {
let (sql, vals) = row_insert_sql("sqlite_sequence", row)?;
fresh.execute(&sql, vals).await.map_err(classify_migrate)?;
}
}
for (tbl, expected) in counts {
let quoted = tbl.replace('"', "\"\"");
let rows = old
.query(&format!("SELECT * FROM \"{quoted}\""), ())
.await
.map_err(classify_migrate)?;
let tx = fresh.begin_tx().await.map_err(classify_migrate)?;
let mut copied = 0i64;
for row in &rows {
let (sql, vals) = row_insert_sql("ed, row)?;
tx.execute(&sql, vals).await.map_err(classify_migrate)?;
copied += 1;
}
tx.commit().await.map_err(classify_migrate)?;
if copied != *expected {
return Err(MigrateFailure::Finding(format!(
"table '{tbl}' copy count {copied} != pre-migration {expected}",
)));
}
}
Ok(())
}
async fn drop_create_index_fallback(
conn: &Connection,
name: &str,
index: &str,
quoted: &str,
) -> RepairOutcome {
let ddl = conn
.query_optional(
"SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?",
(index.to_string(),),
|row| row.get::<String>(0),
)
.await
.ok()
.flatten();
let Some(ddl) = ddl else {
crate::boot::boot_diagnostic(format!(
"store '{name}' index '{index}' DDL not found in sqlite_master — cannot \
DROP+CREATE; left for operator review",
));
return RepairOutcome::NoRepair;
};
let outcome = async {
let tx = conn.begin_tx().await?;
tx.execute_batch(&format!("DROP INDEX \"{quoted}\"; {ddl};"))
.await?;
tx.commit().await
}
.await;
match outcome {
Ok(()) if conn.quick_check().await.is_ok() => {
info!(
db = %name,
index = %index,
"class-B btree index desync repaired in place (DROP+CREATE)",
);
RepairOutcome::Repaired
}
Ok(()) => {
crate::boot::boot_diagnostic(format!(
"store '{name}' DROP+CREATE of '{index}' post-rebuild quick_check \
not clean — left for operator review",
));
RepairOutcome::NoRepair
}
Err(repair_err) => {
crate::boot::boot_diagnostic(format!(
"store '{name}' DROP+CREATE of '{index}' failed: {repair_err} — DROP \
rolled back on the next connection op, original index preserved; left \
for operator review",
));
RepairOutcome::NoRepair
}
}
}
fn desynced_index_name(msg: &str) -> Option<&str> {
const PREFIX: &str = "wrong # of entries in index ";
let rest = msg.strip_prefix(PREFIX)?;
let end = rest.find(['\n', ';', '\r']).unwrap_or(rest.len());
let name = &rest[..end];
(!name.is_empty()).then_some(name)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CheckpointMode {
Passive,
Truncate,
}
impl CheckpointMode {
fn label(self) -> &'static str {
match self {
Self::Passive => "PASSIVE",
Self::Truncate => "TRUNCATE",
}
}
}
async fn heal_checkpoint_sequence(
db_path: &Path,
name: &str,
diagnosis: crate::db::wal_guard::BootDiagnosis,
) -> anyhow::Result<()> {
let sidecars = store_sidecars(db_path);
let durable_b = matches!(diagnosis, crate::db::wal_guard::BootDiagnosis::DurableB)
|| (std::fs::metadata(db_path).is_ok_and(|m| m.len() == 0)
&& std::fs::metadata(&sidecars.wal).is_ok_and(|m| m.len() > 0));
if durable_b {
heal_checkpoint(db_path, CheckpointMode::Passive, name).await?;
heal_checkpoint(db_path, CheckpointMode::Truncate, name).await?;
}
Ok(())
}
async fn recreate_after_failed_heal(
db_path: &Path,
name: &str,
schema: &str,
reason: &anyhow::Error,
) -> anyhow::Result<Connection> {
crate::boot::boot_diagnostic(format!(
"store '{name}' heal failed: {reason} — quarantining artifact family and \
recreating a fresh store",
));
let _ = quarantine_store_artifacts(db_path);
match open_with_schema(db_path, schema).await {
Ok(conn) => Ok(conn),
Err(e) => Err(anyhow::anyhow!(RecreateFailed(e))),
}
}
const HEAL_CHECKPOINT_RETRIES: usize = 3;
async fn heal_checkpoint(db_path: &Path, mode: CheckpointMode, name: &str) -> anyhow::Result<()> {
let conn = Connection::open(db_path)
.await
.with_context(|| format!("heal connection open failed for {name}"))?;
let mut attempts_left = HEAL_CHECKPOINT_RETRIES;
loop {
attempts_left -= 1;
let outcome = conn.run_checkpoint(mode).await;
match outcome {
Ok(o) if o.is_complete() => return Ok(()),
Ok(o) if attempts_left > 0 => {
warn!(
db = %name,
busy = o.busy,
checkpointed = o.checkpointed_frames,
log = o.log_frames,
"heal checkpoint incomplete — retrying",
);
tokio::time::sleep(Duration::from_secs(1)).await;
}
Ok(o) => anyhow::bail!(
"heal checkpoint {} incomplete after {HEAL_CHECKPOINT_RETRIES} attempts \
(busy={}, checkpointed={}/{})",
mode.label(),
o.busy,
o.checkpointed_frames,
o.log_frames,
),
Err(e) if attempts_left > 0 => {
warn!(db = %name, error = %e, "heal checkpoint failed — retrying");
tokio::time::sleep(Duration::from_secs(1)).await;
}
Err(e) => {
return Err(e).with_context(|| {
format!("heal checkpoint {} failed for {name}", mode.label())
});
}
}
}
}
#[must_use]
pub(crate) fn quarantine_store_artifacts(db_path: &Path) -> bool {
let sidecars = store_sidecars(db_path);
quarantine_family(
db_path,
&[
(db_path, ""),
(&sidecars.wal, "-wal"),
(&sidecars.shm, "-shm"),
(&sidecars.tshm, "-tshm"),
],
)
}
#[must_use]
fn quarantine_family(db_path: &Path, sources: &[(&Path, &str)]) -> bool {
static QUARANTINE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let stamp = family_stamp();
let seq = QUARANTINE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let base = if seq == 0 {
format!(
"{}.quarantine-{stamp}",
db_path.file_name().unwrap_or_default().to_string_lossy()
)
} else {
format!(
"{}.quarantine-{stamp}-{seq}",
db_path.file_name().unwrap_or_default().to_string_lossy()
)
};
let mut complete = true;
for (src, suffix) in sources {
if !src.exists() {
continue;
}
let dst = db_path.with_file_name(format!("{base}{suffix}"));
if let Err(e) = std::fs::rename(src, &dst) {
warn!(
error = %e,
from = %src.display(),
to = %dst.display(),
"Failed to quarantine store artifact",
);
complete = false;
}
}
complete
}
pub(crate) async fn with_tx(
conn: &Connection,
ticket_id: &str,
action_label: &str,
work: impl AsyncFnOnce(&TxGuard<'_>) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
with_tx_outcome(conn, ticket_id, action_label, async |tx| {
work(tx).await?;
Ok(true)
})
.await
.map(|_| ())
}
pub(crate) async fn with_tx_outcome(
conn: &Connection,
ticket_id: &str,
action_label: &str,
work: impl AsyncFnOnce(&TxGuard<'_>) -> anyhow::Result<bool>,
) -> anyhow::Result<bool> {
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}"))?;
match work(&tx).await {
Ok(true) => {
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(true)
}
Ok(false) => {
tx.rollback()
.await
.map_err(|e| {
warn!(
ticket = %ticket_id,
error = %e,
"Transaction rollback also failed for {action_label}",
);
e
})
.with_context(|| format!("Failed to roll back transaction for {action_label}"))?;
Ok(false)
}
Err(e) => {
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",
);
Err(e.context(format!("{action_label}: transaction rolled back")))
}
}
}
#[must_use]
fn family_stamp() -> String {
format!(
"{}-{}",
Utc::now().format("%Y%m%dT%H%M%SZ"),
std::process::id()
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicUsize;
#[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"
),
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"
);
let fam = experimental_database_opts();
assert!(
fam.enable_index_method,
"family reads need index_method (stores are created with it)"
);
}
#[test]
fn schema_mismatch_is_not_corruption() {
for msg in [
"no such column: ticket_id",
"no such table: jobs",
"no such index: idx_jobs_phase_ticket",
"table jobs has no column named ticket_id",
"duplicate column name: ticket_id",
"index idx_jobs_phase_ticket already exists",
"syntax error",
"parse error at line 1",
] {
let e = anyhow::anyhow!("{msg}");
assert!(is_schema_mismatch(&e), "mismatch: {msg}");
assert!(
!is_corruption_class(&e),
"schema-mismatch must not quarantine: {msg}"
);
let wrapped = e.context("Failed to run schema DDL");
assert!(
!is_corruption_class(&wrapped),
"wrapped schema-mismatch must not quarantine: {msg}"
);
}
let page = anyhow::anyhow!("Invalid page type");
assert!(!is_schema_mismatch(&page));
assert!(
is_corruption_class(&page),
"page-level damage stays corruption-class"
);
}
#[tokio::test]
async fn schema_mismatch_does_not_quarantine_existing_store() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let db_path = store_db_path(root, "board");
std::fs::create_dir_all(db_path.parent().unwrap()).unwrap();
{
let _conn = open_with_schema(
&db_path,
"CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY);",
)
.await
.unwrap();
}
crate::db::wal_guard::set_boot_diagnosis(
&db_path,
crate::db::wal_guard::BootDiagnosis::Healthy,
);
let err = open_store(
root,
"board",
"CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY);\
CREATE INDEX IF NOT EXISTS idx_t_missing ON t(missing_col);",
)
.await
.expect_err("schema mismatch must fail boot, not succeed");
assert!(
is_schema_mismatch(&err) || format!("{err:#}").to_lowercase().contains("no such"),
"expected a DDL-shape error, got: {err:#}"
);
let quarantined = std::fs::read_dir(db_path.parent().unwrap())
.unwrap()
.filter_map(std::result::Result::ok)
.any(|e| e.file_name().to_string_lossy().contains("quarantine-"));
assert!(
!quarantined,
"readable store must not be quarantined on schema mismatch"
);
assert!(
db_path.exists(),
"original main file must still be at the live path"
);
}
#[test]
fn family_writer_names_parse_via_debug_parser() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("board.db");
let wal = dir.path().join("board.db-wal");
let tshm = dir.path().join("board.db-tshm");
for f in [&db_path, &wal, &tshm] {
std::fs::write(f, b"x").unwrap();
}
assert!(
quarantine_family(
&db_path,
&[(&db_path, ""), (&wal, "-wal"), (&tshm, "-tshm")],
),
"quarantine writer must move every existing source"
);
let moved: Vec<String> = std::fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.filter(|n| n.contains("board.db.quarantine-"))
.collect();
assert_eq!(moved.len(), 3, "db + wal + tshm moved: {moved:?}");
for name in moved {
let base = ["-wal", "-tshm"]
.iter()
.find_map(|s| name.strip_suffix(s))
.unwrap_or(&name);
let meta = crate::db::debug::parse_family_name(base).unwrap_or_else(|| {
panic!("quarantine writer name must parse as a family id: {name}")
});
assert_eq!(meta.store, "board");
assert_eq!(meta.kind, crate::db::debug::FamilyKind::Quarantine);
}
let snap = pre_reindex_snapshot_path(&db_path);
let meta = crate::db::debug::parse_family_name(snap.file_name().unwrap().to_str().unwrap())
.expect("pre-reindex snapshot name must parse as a family id");
assert_eq!(meta.kind, crate::db::debug::FamilyKind::PreReindex);
}
#[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");
}
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 raw_builder_usage_is_confined_to_persistence_and_debug() {
const PATTERNS: [&str; 2] = ["turso::Builder", "Builder::new_local"];
const ALLOWED: [&str; 2] = ["src/db/mod.rs", "src/db/debug.rs"];
fn collect_rs_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
for entry in std::fs::read_dir(dir).expect("read src directory") {
let path = entry.expect("read directory entry").path();
if path.is_dir() {
collect_rs_files(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let mut files = Vec::new();
collect_rs_files(&manifest_dir.join("src"), &mut files);
let mut violations: Vec<(String, &'static str)> = Vec::new();
for file in files {
let rel = file
.strip_prefix(manifest_dir)
.expect("source files live under the manifest dir")
.to_string_lossy()
.to_string();
if ALLOWED.contains(&rel.as_str()) {
continue;
}
let content = std::fs::read_to_string(&file).expect("read source file");
let code_only: String = content
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n");
for pattern in PATTERNS {
if code_only.contains(pattern) {
violations.push((rel.clone(), pattern));
}
}
}
assert!(
violations.is_empty(),
"raw turso::Builder usage outside the persistence module and debug CLI.\n\
All database access must go through crate::db::Connection (db/mod.rs);\n\
the debug CLI (db/debug.rs) is the only documented exception.\n\
Violations: {violations:#?}"
);
}
#[test]
fn in_memory_temp_store_applied_on_both_opening_paths() {
const PRAGMA: &str = "PRAGMA temp_store = MEMORY";
const EXPECTED: [(&str, &str); 2] = [
("src/db/mod.rs", "impl Connection"),
("src/db/debug.rs", "fn connect_readonly"),
];
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let mut violations: Vec<String> = Vec::new();
for (file, carrier) in EXPECTED {
let path = manifest_dir.join(file);
let content =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {file}: {e}"));
let code_only: String = content
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n");
if !code_only.contains(PRAGMA) {
violations.push(format!(
"{file} ({carrier}) no longer applies the in-memory temp-store \
setting (missing `{PRAGMA}` in code).\n\
The missing-temp-root guarantee requires EVERY turso connection \
the application opens — service factory AND debug CLI — to run \
with in-memory temp storage."
));
}
}
assert!(
violations.is_empty(),
"in-memory temp-store regression guard failed:\n{}",
violations.join("\n\n")
);
}
#[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_checkpoint_reports_complete_outcome() {
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");
for mode in ["checkpoint", "checkpoint_passive"] {
let outcome = match mode {
"checkpoint" => conn.checkpoint().await,
_ => conn.checkpoint_passive().await,
}
.expect("checkpoint should succeed on a healthy database");
assert!(
outcome.is_complete(),
"{mode} outcome must be complete: {outcome:?}"
);
}
}
#[test]
fn checkpoint_outcome_completeness_predicate() {
let complete = CheckpointOutcome {
busy: false,
log_frames: 0,
checkpointed_frames: 0,
};
assert!(complete.is_complete());
let busy = CheckpointOutcome {
busy: true,
log_frames: 0,
checkpointed_frames: 0,
};
assert!(!busy.is_complete(), "busy outcome must be incomplete");
let partial = CheckpointOutcome {
busy: false,
log_frames: 12,
checkpointed_frames: 5,
};
assert!(
!partial.is_complete(),
"uncheckpointed WAL frames must be incomplete"
);
}
#[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");
}
#[test]
fn known_fts_dir_false_positive_classification() {
let fp = "wrong # of entries in index __turso_internal_fts_dir_idx_tickets_title_fts_key";
assert!(fp.contains(KNOWN_FTS_DIR_COUNT_FALSE_POSITIVE));
let genuine_missing =
"row 5 missing from index __turso_internal_fts_dir_idx_tickets_title_fts_key";
let genuine_unique =
"non-unique entry in index __turso_internal_fts_dir_idx_tickets_title_fts_key";
assert!(!genuine_missing.contains(KNOWN_FTS_DIR_COUNT_FALSE_POSITIVE));
assert!(!genuine_unique.contains(KNOWN_FTS_DIR_COUNT_FALSE_POSITIVE));
}
#[tokio::test]
async fn open_store_recreates_structural_store() {
let tmp = tempfile::TempDir::new().expect("temp dir for test");
let root = tmp.path();
let db_path = store_db_path(root, "board");
std::fs::create_dir_all(db_path.parent().unwrap()).unwrap();
std::fs::write(&db_path, [0u8; 64]).unwrap();
std::fs::write(format!("{}-tshm", db_path.display()), [0u8; 32]).unwrap();
crate::db::wal_guard::set_boot_diagnosis(
&db_path,
crate::db::wal_guard::BootDiagnosis::Structural,
);
let conn = open_store(root, "board", "CREATE TABLE IF NOT EXISTS t (id INTEGER);")
.await
.expect("structural store must be recreated, not fail boot");
let rows: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='t'",
(),
|r| r.get::<i64>(0),
)
.await
.expect("schema applied on the recreated store");
assert_eq!(rows, 1, "recreated store must carry the schema");
let quarantined = std::fs::read_dir(db_path.parent().unwrap())
.unwrap()
.filter_map(std::result::Result::ok)
.any(|e| e.file_name().to_string_lossy().contains("quarantine-"));
assert!(
quarantined,
"artifact family must be quarantined (forensic copy)"
);
}
#[test]
fn desynced_index_name_parses_quick_check_shapes() {
assert_eq!(
desynced_index_name("wrong # of entries in index idx_tickets_phase"),
Some("idx_tickets_phase")
);
assert_eq!(
desynced_index_name("wrong # of entries in index a; row 5 missing"),
Some("a")
);
assert!(
desynced_index_name(
"wrong # of entries in index __turso_internal_fts_dir_idx_tickets_title_fts_key"
)
.is_some()
);
assert!(desynced_index_name("Page referenced multiple times: page 167772160").is_none());
assert!(desynced_index_name("short read on page 12").is_none());
assert!(desynced_index_name("row 5 missing from index idx_x").is_none());
assert!(desynced_index_name("ok").is_none());
}
#[test]
fn overflow_aliasing_vetoes_reindex_across_rows() {
assert!(matches!(
classify_repair_target(&[
"wrong # of entries in index idx_phase".to_string(),
"Page referenced multiple times: page 167772160".to_string(),
]),
RepairTarget::OverflowAliasing
));
assert!(matches!(
classify_repair_target(&[
"*** in database main ***\nPage 871 referenced multiple times \
(references=[3, 3], page_category=Normal)"
.to_string(),
"wrong # of entries in index idx_t_v".to_string(),
]),
RepairTarget::OverflowAliasing
));
assert!(matches!(
classify_repair_target(&[
"Page referenced multiple times: page 5".to_string(),
"wrong # of entries in index idx_phase".to_string(),
]),
RepairTarget::OverflowAliasing
));
assert!(matches!(
classify_repair_target(&["short read on page 167772160".to_string()]),
RepairTarget::OverflowAliasing
));
assert!(matches!(
classify_repair_target(&[
"wrong # of entries in index idx_phase".to_string(),
"short read on page 12".to_string(),
]),
RepairTarget::Unreadable
));
assert!(matches!(
classify_repair_target(&["short read on page 12".to_string()]),
RepairTarget::Unreadable
));
assert!(matches!(
classify_repair_target(&["wrong # of entries in index idx_phase".to_string()]),
RepairTarget::Index(name) if name == "idx_phase"
));
assert!(matches!(
classify_repair_target(&["row 5 missing from index idx_x".to_string()]),
RepairTarget::Unknown
));
}
#[tokio::test]
async fn transactional_ddl_rollback_preserves_dropped_index() {
let tmp = tempfile::TempDir::new().unwrap();
let conn = open_with_schema(
&tmp.path().join("t.db"),
"CREATE TABLE t (a TEXT, b TEXT); \
INSERT INTO t VALUES ('1', 'x'), ('1', 'y'); \
CREATE UNIQUE INDEX u ON t(b);",
)
.await
.expect("open test store");
let tx = conn.begin_tx().await.expect("begin tx");
let err = tx
.execute_batch("DROP INDEX u; CREATE UNIQUE INDEX u2 ON t(a);")
.await
.expect_err("CREATE UNIQUE on duplicated data must fail");
assert!(
format!("{err}").to_lowercase().contains("unique"),
"expected a constraint violation, got: {err}"
);
tx.rollback().await.expect("rollback");
{
let tx = conn.begin_tx().await.expect("begin tx");
let err = tx
.execute_batch("DROP INDEX u; CREATE UNIQUE INDEX u2 ON t(a);")
.await
.expect_err("CREATE UNIQUE on duplicated data must fail");
assert!(
format!("{err}").to_lowercase().contains("unique"),
"expected a constraint violation, got: {err}"
);
}
let names: Vec<String> = conn
.query_map_strict(
"SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('u', 'u2')",
(),
|row| row.get::<String>(0),
)
.await
.expect("query sqlite_master");
assert_eq!(names, vec!["u".to_string()], "original index must survive");
conn.quick_check()
.await
.expect("store must be consistent after the rollback");
let n: i64 = conn
.query_row("SELECT COUNT(*) FROM t INDEXED BY u", (), |row| {
row.get::<i64>(0)
})
.await
.expect("INDEXED BY must resolve");
assert_eq!(n, 2);
}
fn synthesize_overflow_aliasing(db_path: &Path) -> u32 {
let file = std::fs::read(db_path).unwrap();
let page_size = u16::from_be_bytes([file[16], file[17]]) as usize;
let local = ((page_size - 12) * 32 / 255) - 23;
let find = |file: &[u8], prefix: &[u8], rowid_serial: u8| -> (usize, u32) {
for i in 0..file.len().saturating_sub(prefix.len() + 12) {
if &file[i..i + prefix.len()] == prefix
&& file[i - 4..i] == [0x04, 0x9f, 0x3b, rowid_serial]
{
let ptr =
u32::from_be_bytes(file[i + local - 4..i + local].try_into().unwrap());
return (i, ptr);
}
}
panic!(
"index cell for {:?} not found",
String::from_utf8_lossy(prefix)
);
};
let (_, shared) = find(&file, b"v04000-", 0x02); let (patch_at, _) = find(&file, b"v00000-", 0x09); let mut file = file;
file[patch_at + local - 4..patch_at + local].copy_from_slice(&shared.to_be_bytes());
std::fs::write(db_path, &file).unwrap();
shared
}
async fn build_aliasing_candidate(
db_path: &Path,
schema: &str,
extra_row: Option<(&str, i64)>,
) {
{
let conn = open_with_schema(db_path, schema).await.unwrap();
for i in 0..5000i32 {
let mut big = format!("v{i:05}-");
big.push_str(&"q".repeat(2000));
conn.execute(
"INSERT INTO t (v, n) VALUES (?1, 1);",
turso::params![big.clone()],
)
.await
.unwrap();
}
if let Some((v, n)) = extra_row {
conn.execute("PRAGMA ignore_check_constraints = ON;", ())
.await
.unwrap();
conn.execute(
"INSERT INTO t (v, n) VALUES (?1, ?2);",
turso::params![v, n],
)
.await
.unwrap();
conn.execute("PRAGMA ignore_check_constraints = OFF;", ())
.await
.unwrap();
}
drop(conn);
}
{
let conn = Connection::open(db_path).await.unwrap();
conn.checkpoint().await.unwrap();
drop(conn);
}
}
#[ignore = "performs real overflow-page DB surgery on a multi-MB fixture (~4 s); runs only when explicitly invoked"]
#[tokio::test]
async fn overflow_aliasing_rebuild_preserves_fts_store() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let db_path = store_db_path(root, "board");
std::fs::create_dir_all(db_path.parent().unwrap()).unwrap();
let schema = "CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, v TEXT NOT NULL, \
n INTEGER NOT NULL DEFAULT 1); \
CREATE INDEX IF NOT EXISTS idx_t_v ON t(v); \
CREATE TABLE IF NOT EXISTS ft (title TEXT NOT NULL); \
CREATE INDEX IF NOT EXISTS idx_ft_fts ON ft USING fts (title) \
WITH (tokenizer = 'ngram');";
build_aliasing_candidate(&db_path, schema, None).await;
{
let conn = open_with_schema(&db_path, schema).await.unwrap();
for i in 0..5 {
conn.execute(
"INSERT INTO ft (title) VALUES (?1);",
turso::params![format!("ticket title {i}")],
)
.await
.unwrap();
}
drop(conn);
}
{
let conn = Connection::open(&db_path).await.unwrap();
conn.checkpoint().await.unwrap();
drop(conn);
}
let shared = synthesize_overflow_aliasing(&db_path);
assert!(shared > 0, "surgery must reference a real overflow page");
let conn = open_and_repair(&db_path, "board", schema)
.await
.expect("overflow-aliasing repair must succeed on an FTS store");
conn.quick_check()
.await
.expect("rebuilt store must pass quick_check");
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM t", (), |r| r.get::<i64>(0))
.await
.unwrap();
assert_eq!(count, 5000, "all rows must survive the rebuild");
let idx: i64 = conn
.query_row("SELECT COUNT(*) FROM t INDEXED BY idx_t_v", (), |r| {
r.get::<i64>(0)
})
.await
.unwrap();
assert_eq!(idx, 5000, "rebuilt index must be valid and complete");
let ft: i64 = conn
.query_row("SELECT COUNT(*) FROM ft", (), |r| r.get::<i64>(0))
.await
.unwrap();
assert_eq!(ft, 5, "FTS table data must survive the rebuild");
let matched: i64 = conn
.query_row(
"SELECT COUNT(*) FROM ft WHERE title MATCH 'title'",
(),
|r| r.get::<i64>(0),
)
.await
.unwrap();
assert_eq!(matched, 5, "rebuilt FTS index must answer MATCH queries");
let quarantined = std::fs::read_dir(db_path.parent().unwrap())
.unwrap()
.filter_map(std::result::Result::ok)
.any(|e| e.file_name().to_string_lossy().contains("quarantine-"));
assert!(
quarantined,
"original family must be quarantined (forensic record)"
);
conn.execute("INSERT INTO t (v, n) VALUES ('post-rebuild', 1);", ())
.await
.unwrap();
conn.quick_check()
.await
.expect("rebuilt store must stay clean after a write");
}
#[ignore = "performs real overflow-page DB surgery on a multi-MB fixture (~4 s); runs only when explicitly invoked"]
#[tokio::test]
async fn overflow_aliasing_rebuild_constraint_finding_aborts() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let db_path = store_db_path(root, "board");
std::fs::create_dir_all(db_path.parent().unwrap()).unwrap();
let schema = "CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, v TEXT NOT NULL, \
n INTEGER NOT NULL CHECK (n < 50)); \
CREATE INDEX IF NOT EXISTS idx_t_v ON t(v);";
build_aliasing_candidate(&db_path, schema, Some(("v09999x", 99))).await;
synthesize_overflow_aliasing(&db_path);
let conn = open_and_repair(&db_path, "board", schema)
.await
.expect("aborted rebuild must still open the store (report-only)");
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM t", (), |r| r.get::<i64>(0))
.await
.unwrap();
assert_eq!(
count, 5001,
"original data must be preserved, not recreated"
);
let quarantined = std::fs::read_dir(db_path.parent().unwrap())
.unwrap()
.filter_map(std::result::Result::ok)
.any(|e| e.file_name().to_string_lossy().contains("quarantine-"));
assert!(
!quarantined,
"a constraint finding must not quarantine/recreate the store"
);
}
#[ignore = "performs real overflow-page DB surgery on a multi-MB fixture (~4 s); runs only when explicitly invoked"]
#[tokio::test]
async fn overflow_aliasing_rebuild_preserves_autoincrement_store() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let db_path = store_db_path(root, "board");
std::fs::create_dir_all(db_path.parent().unwrap()).unwrap();
let schema = "CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY AUTOINCREMENT, \
v TEXT NOT NULL, n INTEGER NOT NULL DEFAULT 1); \
CREATE INDEX IF NOT EXISTS idx_t_v ON t(v);";
build_aliasing_candidate(&db_path, schema, None).await;
let shared = synthesize_overflow_aliasing(&db_path);
assert!(shared > 0, "surgery must reference a real overflow page");
{
let conn = Connection::open(&db_path).await.unwrap();
conn.execute("DELETE FROM t WHERE id > 4000", ())
.await
.unwrap();
drop(conn);
}
let conn = open_and_repair(&db_path, "board", schema)
.await
.expect("overflow-aliasing repair must succeed on an AUTOINCREMENT store");
conn.quick_check()
.await
.expect("rebuilt store must pass quick_check");
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM t", (), |r| r.get::<i64>(0))
.await
.unwrap();
assert_eq!(count, 4000, "all surviving rows must be preserved");
let idx: i64 = conn
.query_row("SELECT COUNT(*) FROM t INDEXED BY idx_t_v", (), |r| {
r.get::<i64>(0)
})
.await
.unwrap();
assert_eq!(idx, 4000, "rebuilt index must be valid and complete");
conn.execute("INSERT INTO t (v, n) VALUES ('auto-next', 1);", ())
.await
.unwrap();
let max_id: i64 = conn
.query_row("SELECT MAX(id) FROM t", (), |r| r.get::<i64>(0))
.await
.unwrap();
assert_eq!(
max_id, 5001,
"AUTOINCREMENT must advance past the old watermark, never re-issue ids"
);
let seq: i64 = conn
.query_row(
"SELECT seq FROM sqlite_sequence WHERE name = 't'",
(),
|r| r.get::<i64>(0),
)
.await
.unwrap();
assert_eq!(
seq, 5001,
"sqlite_sequence watermark must be carried over and advanced"
);
conn.quick_check()
.await
.expect("rebuilt store must stay clean after a write");
let quarantined = std::fs::read_dir(db_path.parent().unwrap())
.unwrap()
.filter_map(std::result::Result::ok)
.any(|e| e.file_name().to_string_lossy().contains("quarantine-"));
assert!(
quarantined,
"original family must be quarantined (forensic record)"
);
}
#[test]
fn open_time_lock_error_predicate() {
let lock = |msg: &str| turso::Error::Error(msg.to_string());
assert!(is_open_time_lock_error(&lock(
"Locking error: Failed locking file '/x/logs.db-wal'. File is locked by another process"
)));
assert!(is_open_time_lock_error(&lock(
"Locking error: Failed locking shared WAL coordination file. File is locked by another process"
)));
assert!(!is_open_time_lock_error(&turso::Error::Busy(
"database is locked".into()
)));
assert!(!is_open_time_lock_error(&lock(
"Runtime error: database table is locked"
)));
assert!(!is_open_time_lock_error(&lock(
"Database file is corrupted"
)));
}
#[tokio::test]
async fn open_lock_retry_succeeds_after_transient_lock() {
let calls = AtomicUsize::new(0);
let result = with_open_lock_retry(3, Duration::from_millis(1), || {
let n = calls.fetch_add(1, Ordering::SeqCst);
async move {
if n < 2 {
Err(turso::Error::Error(
"Locking error: Failed locking file '/x/logs.db-wal'. File is locked by another process".to_string(),
))
} else {
Ok("opened")
}
}
})
.await;
assert!(
result.is_ok(),
"open must succeed after the transient lock clears"
);
assert_eq!(
calls.load(Ordering::SeqCst),
3,
"closure must be called twice before success"
);
}
#[tokio::test]
async fn open_lock_retry_exhausts_and_preserves_error() {
let calls = AtomicUsize::new(0);
let err = with_open_lock_retry(2, Duration::from_millis(1), || {
calls.fetch_add(1, Ordering::SeqCst);
async {
Err::<(), turso::Error>(turso::Error::Error(
"Locking error: Failed locking file '/x/logs.db-wal'. File is locked by another process".to_string(),
))
}
})
.await
.expect_err("a persistent open-time lock must eventually surface");
assert_eq!(
calls.load(Ordering::SeqCst),
3,
"max total tries must be attempts + 1"
);
assert!(
is_open_time_lock_error(&err),
"the original lock error must be preserved"
);
}
#[tokio::test]
async fn open_lock_no_retry_on_non_lock_error() {
let calls = AtomicUsize::new(0);
let err = with_open_lock_retry(2, Duration::from_millis(1), || {
calls.fetch_add(1, Ordering::SeqCst);
async { Err::<(), turso::Error>(turso::Error::Error("Disk I/O error".to_string())) }
})
.await
.expect_err("a non-lock open failure must not be retried");
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"non-lock errors must not trigger retries"
);
assert!(
matches!(&err, turso::Error::Error(msg) if msg == "Disk I/O error"),
"the original non-lock error must be returned"
);
}
}