use anyhow::Context;
use chrono::{DateTime, Utc};
use futures_util::FutureExt;
use std::fs::File;
use std::panic::AssertUnwindSafe;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::sync::OnceCell;
use tracing::{info, warn};
use turso::Builder;
pub(crate) use turso::{IntoParams, Row, Value, params};
use crate::util::UnwrapPoison;
#[cfg(unix)]
pub(crate) fn pread_at<const N: usize>(file: &File, offset: u64) -> Option<[u8; N]> {
use std::os::unix::fs::FileExt;
let mut buf = [0u8; N];
file.read_exact_at(&mut buf, offset).ok()?;
Some(buf)
}
#[cfg(windows)]
pub(crate) fn pread_at<const N: usize>(file: &File, offset: u64) -> Option<[u8; N]> {
use std::os::windows::fs::FileExt;
let mut buf = [0u8; N];
file.seek_read(&mut buf, offset).ok()?;
Some(buf)
}
pub(crate) fn pread<const N: usize>(file: &File) -> Option<[u8; N]> {
pread_at(file, 0)
}
#[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", "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)),
("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<()> {
let mut set = tokio::task::JoinSet::new();
set.spawn(crate::session::init_global());
set.spawn(crate::workspace::init_global());
set.spawn(crate::users::init_global());
set.spawn(crate::board::init_global());
set.spawn(crate::chat_history::init_global());
set.spawn(crate::config_db::init_global());
let mut first_error: Option<anyhow::Error> = None;
while let Some(result) = set.join_next().await {
let outcome = match result {
Ok(Ok(())) => None,
Ok(Err(e)) => Some(e),
Err(join_err) => {
let message = join_err.try_into_panic().map_or_else(
|_| "store init task failed to join".to_string(),
|p| crate::util::panic_message(&*p),
);
Some(anyhow::anyhow!("store init task panicked: {message}"))
}
};
if let Some(e) = outcome
&& first_error.is_none()
{
first_error = Some(e);
}
}
match first_error {
Some(e) => Err(e),
None => Ok(()),
}
}
#[must_use]
pub(crate) fn experimental_database_opts() -> turso::core::DatabaseOpts {
turso::core::DatabaseOpts::new()
.with_multiprocess_wal(true)
.with_index_method(true)
}
#[must_use]
pub(crate) fn family_database_opts() -> turso::core::DatabaseOpts {
experimental_database_opts().with_multiprocess_wal(false)
}
pub(crate) 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])*
$vis:vis static $name:ident: $ty:ty,
constructor = $constructor:expr,
expect = $expect:expr,
) => {
$(#[$attr])*
$vis static $name: ::tokio::sync::OnceCell<$ty> =
::tokio::sync::OnceCell::const_new();
#[doc = concat!("Initialize the global ", stringify!($name), " store.")]
$vis 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.",
)]
$vis fn store() -> &'static $ty {
$name.get().expect($expect)
}
};
}
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(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SidecarIdentity {
Matches,
Absent,
Deleted,
Replaced,
}
#[derive(Debug)]
pub(crate) struct PersistentSidecarFd {
path: std::path::PathBuf,
file: std::sync::OnceLock<File>,
open_lock: std::sync::Mutex<()>,
}
impl PersistentSidecarFd {
fn open(db_path: &Path, suffix: &str) -> Self {
let path = std::path::PathBuf::from(format!("{}{suffix}", db_path.display()));
let file = std::sync::OnceLock::new();
if let Ok(f) = std::fs::File::open(&path) {
let _ = file.set(f);
}
Self {
path,
file,
open_lock: std::sync::Mutex::new(()),
}
}
fn ensure_open(&self) {
if self.file.get().is_some() {
return;
}
let _guard = self.open_lock.lock().unwrap_poison();
if self.file.get().is_some() {
return; }
if let Ok(f) = std::fs::File::open(&self.path) {
let _ = self.file.set(f); }
}
pub(crate) fn file(&self) -> Option<&File> {
self.file.get()
}
#[must_use]
pub(crate) fn identity(&self) -> SidecarIdentity {
self.ensure_open();
let Some(file) = self.file.get() else {
return SidecarIdentity::Absent;
};
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let Ok(fd_meta) = file.metadata() else {
return SidecarIdentity::Deleted;
};
let Ok(path_meta) = std::fs::metadata(&self.path) else {
return SidecarIdentity::Deleted; };
if fd_meta.dev() == path_meta.dev() && fd_meta.ino() == path_meta.ino() {
return SidecarIdentity::Matches;
}
SidecarIdentity::Replaced
}
#[cfg(not(unix))]
{
SidecarIdentity::Matches
}
}
}
pub(crate) type StoreFds<'a> = crate::wal_guard::StoreFds<'a>;
#[derive(Clone, Debug)]
pub(crate) struct Connection {
conn: Arc<tokio::sync::Mutex<turso::Connection>>,
has_dangling_tx: Arc<AtomicBool>,
tshm_fd: Arc<PersistentSidecarFd>,
wal_fd: Arc<PersistentSidecarFd>,
}
impl Connection {
#[must_use]
pub(crate) fn store_fds(&self) -> StoreFds<'_> {
self.tshm_fd.ensure_open();
self.wal_fd.ensure_open();
StoreFds {
tshm: self.tshm_fd.file(),
wal: self.wal_fd.file(),
}
}
#[must_use]
pub(crate) fn check_coordination_identity(&self) -> Option<SidecarIdentity> {
let mut worst = None;
for fd in [&self.tshm_fd, &self.wal_fd] {
match fd.identity() {
SidecarIdentity::Matches | SidecarIdentity::Absent => {}
SidecarIdentity::Deleted => {
if worst != Some(SidecarIdentity::Replaced) {
worst = Some(SidecarIdentity::Deleted);
}
}
SidecarIdentity::Replaced => worst = Some(SidecarIdentity::Replaced),
}
}
worst
}
}
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_%'";
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()));
let _ = std::fs::remove_file(format!("{}-tshm", 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()
}
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))?;
conn.execute("PRAGMA temp_store = MEMORY;", ())
.await
.context("failed to set in-memory temp storage (PRAGMA temp_store = MEMORY)")?;
let tshm_fd = Arc::new(PersistentSidecarFd::open(path, "-tshm"));
let wal_fd = Arc::new(PersistentSidecarFd::open(path, "-wal"));
Ok(Self {
conn: Arc::new(tokio::sync::Mutex::new(conn)),
has_dangling_tx: Arc::new(AtomicBool::new(false)),
tshm_fd,
wal_fd,
})
}
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_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 checkpoint(&self) -> anyhow::Result<CheckpointOutcome> {
self.run_checkpoint(CheckpointMode::Truncate).await
}
pub 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 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(())
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct Migration {
pub(crate) id: &'static str,
pub(crate) sql: &'static str,
pub(crate) guard: Option<(&'static str, &'static str)>,
}
pub(crate) async fn column_exists(
conn: &Connection,
table: &str,
column: &str,
) -> anyhow::Result<bool> {
let rows = conn
.query(&format!("PRAGMA table_info({table})"), ())
.await
.context("Failed to read table schema (PRAGMA table_info)")?;
Ok(rows
.iter()
.any(|row| row.get::<String>(1).ok().as_deref() == Some(column)))
}
pub(crate) async fn run_pending_migrations(
conn: &Connection,
store_name: &str,
migrations: &[Migration],
) -> anyhow::Result<()> {
conn.execute(
"CREATE TABLE IF NOT EXISTS schema_migrations (\
id TEXT PRIMARY KEY,\
applied_at TEXT NOT NULL\
)",
(),
)
.await
.context("Failed to create schema_migrations tracking table")?;
let applied: std::collections::HashSet<String> = conn
.query("SELECT id FROM schema_migrations", ())
.await
.context("Failed to read applied migrations")?
.into_iter()
.filter_map(|row| row.get::<String>(0).ok())
.collect();
for migration in migrations {
if applied.contains(migration.id) {
continue;
}
let guard_holds = match migration.guard {
Some((table, column)) => {
column_exists(conn, table, column).await.with_context(|| {
format!(
"Migration '{}' guard check failed on {table}.{column}",
migration.id
)
})?
}
None => false,
};
if guard_holds {
conn.execute(
"INSERT INTO schema_migrations (id, applied_at) VALUES (?1, ?2)",
params![migration.id, now()],
)
.await
.with_context(|| format!("Failed to record migration '{}' as applied", migration.id))?;
} else {
let tx = conn.begin_tx().await.with_context(|| {
format!("Migration '{}': failed to begin transaction", migration.id)
})?;
tx.execute_batch(migration.sql)
.await
.with_context(|| format!("Migration '{}' failed", migration.id))?;
tx.execute(
"INSERT INTO schema_migrations (id, applied_at) VALUES (?1, ?2)",
params![migration.id, now()],
)
.await
.with_context(|| format!("Failed to record migration '{}' as applied", migration.id))?;
tx.commit()
.await
.with_context(|| format!("Migration '{}': failed to commit", migration.id))?;
}
tracing::info!(
store = store_name,
migration = migration.id,
skipped_sql = guard_holds,
"Applied database migration",
);
}
Ok(())
}
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")?;
conn.execute_batch(schema)
.await
.context(format!("Failed to run schema {schema}"))?;
Ok(conn)
}
#[must_use]
pub(crate) fn 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 {
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_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::wal_guard::take_boot_diagnosis(&db_path) else {
return open_with_schema(&db_path, schema).await;
};
match diagnosis {
crate::wal_guard::BootDiagnosis::BlockedCoordination => {
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) => {
retry_after_coordination_panic(&db_path, name, schema, &panic_err(&*payload))
.await
}
}
}
crate::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
}
}
}
healable @ (crate::wal_guard::BootDiagnosis::StaleTail
| crate::wal_guard::BootDiagnosis::DurableB) => {
let healed = AssertUnwindSafe(heal_checkpoint_sequence(&db_path, name, healable))
.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::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)
)))),
}
}
}
}
fn panic_err(payload: &(dyn std::any::Any + Send)) -> anyhow::Error {
anyhow::anyhow!(
"store open panicked: {}",
crate::util::panic_message(payload)
)
}
async fn retry_after_coordination_panic(
db_path: &Path,
name: &str,
schema: &str,
reason: &anyhow::Error,
) -> anyhow::Result<Connection> {
crate::boot::boot_diagnostic(format!(
"store '{name}' open panicked ({reason}) — quarantining coordination \
sidecars and retrying on the intact main DB",
));
let _ = quarantine_coordination_sidecars(db_path);
let retry = AssertUnwindSafe(open_with_schema(db_path, schema))
.catch_unwind()
.await;
match retry {
Ok(Ok(conn)) => Ok(conn),
Ok(Err(e)) => {
if is_actionable_signal(&e) {
return Err(e);
}
recreate_after_failed_heal(db_path, name, schema, &e).await
}
Err(p) => recreate_after_failed_heal(db_path, name, schema, &panic_err(&*p)).await,
}
}
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 status = crate::wal_guard::inspect_store_at(db_path, conn.store_fds());
if let Some(h) = status.tshm
&& u64::from(h.frame_index_len) != h.max_frame
{
crate::boot::boot_diagnostic(format!(
"store '{name}' WAL frame index (len={}) does not match max_frame ({}) — \
TRUNCATE-checkpoint first in the single-writer window",
h.frame_index_len, h.max_frame,
));
match conn.checkpoint().await {
Ok(o) if o.is_complete() => {}
Ok(o) => {
crate::boot::boot_diagnostic(format!(
"store '{name}' pre-rebuild TRUNCATE checkpoint incomplete \
(busy={}, {} of {} frames) — recreate justified",
o.busy, o.checkpointed_frames, o.log_frames,
));
return Ok((RepairOutcome::Unreadable, conn));
}
Err(e) if is_actionable_signal(&e) => return Err(e),
Err(e) => {
crate::boot::boot_diagnostic(format!(
"store '{name}' pre-rebuild TRUNCATE checkpoint failed: {e} — \
recreate justified",
));
return Ok((RepairOutcome::Unreadable, conn));
}
}
}
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::wal_guard::BootDiagnosis,
) -> anyhow::Result<()> {
let sidecars = store_sidecars(db_path);
let durable_b = matches!(diagnosis, crate::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?;
return Ok(());
}
if matches!(diagnosis, crate::wal_guard::BootDiagnosis::StaleTail) {
if std::fs::metadata(&sidecars.wal).is_ok_and(|m| m.len() < 32) {
return Ok(());
}
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_coordination_sidecars(db_path: &Path) -> bool {
let sidecars = store_sidecars(db_path);
quarantine_family(
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::*;
#[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"
);
let fam = family_database_opts();
assert!(
fam.enable_index_method,
"family reads need index_method (stores are created with it)"
);
assert!(
!fam.enable_multiprocess_wal,
"family reads must disable multiprocess_wal (snapshot semantics)"
);
}
#[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::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::debug::FamilyKind::Quarantine);
}
let snap = pre_reindex_snapshot_path(&db_path);
let meta = crate::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::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");
}
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 raw_builder_usage_is_confined_to_persistence_and_debug() {
const PATTERNS: [&str; 2] = ["turso::Builder", "Builder::new_local"];
const ALLOWED: [&str; 2] = ["src/turso.rs", "src/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::turso::Connection (turso.rs);\n\
the debug CLI (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/turso.rs", "impl Connection"),
("src/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::wal_guard::set_boot_diagnosis(&db_path, crate::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)"
);
}
}