first commit
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-29 15:24:59 +01:00
commit bc5fbd6ba9
180 files changed
+63750

No files matched your search

+231
View File
@@ -0,0 +1,231 @@
use std::fs;
use std::path::Path;
use rusqlite::Connection;
/// Owned database context for short blocking jobs. Never hold SQLite on a Tokio worker.
#[derive(Clone)]
pub(crate) struct UserDb {
data_dir: String,
user_hash: String,
}
impl UserDb {
pub(crate) fn new(data_dir: &str, user_hash: &str) -> Self {
Self {
data_dir: data_dir.into(),
user_hash: user_hash.into(),
}
}
pub(crate) async fn run<T: Send + 'static>(
&self,
work: impl FnOnce(&Connection) -> Result<T, String> + Send + 'static,
) -> Result<T, String> {
let context = self.clone();
tokio::task::spawn_blocking(move || {
let conn = open_user_db(&context.data_dir, &context.user_hash)?;
work(&conn)
})
.await
.map_err(|e| format!("Database task failed: {e}"))?
}
}
/// All migration scripts in order. Each entry is (version, sql).
const MIGRATIONS: &[(u32, &str)] = &[
(1, include_str!("../../migrations/V001__initial_schema.sql")),
(2, include_str!("../../migrations/V002__folders_and_messages.sql")),
(3, include_str!("../../migrations/V003__add_date_epoch.sql")),
(4, include_str!("../../migrations/V004__folder_messages_updated_at.sql")),
(5, include_str!("../../migrations/V005__drafts_and_attachments.sql")),
(6, include_str!("../../migrations/V006__cache_attachments_and_headers.sql")),
(7, include_str!("../../migrations/V007__contacts.sql")),
(8, include_str!("../../migrations/V008__identities.sql")),
(9, include_str!("../../migrations/V009__notification_preferences.sql")),
(10, include_str!("../../migrations/V010__contact_groups.sql")),
(11, include_str!("../../migrations/V011__message_reaction.sql")),
(12, include_str!("../../migrations/V012__tags.sql")),
(13, include_str!("../../migrations/V013__display_preferences.sql")),
(14, include_str!("../../migrations/V014__compose_format.sql")),
(15, include_str!("../../migrations/V015__deep_index.sql")),
(16, include_str!("../../migrations/V016__calendar.sql")),
(17, include_str!("../../migrations/V017__add_email_theme.sql")),
(18, include_str!("../../migrations/V018__animation_mode.sql")),
(19, include_str!("../../migrations/V019__known_addresses.sql")),
(20, include_str!("../../migrations/V020__appearance_personalization.sql")),
(21, include_str!("../../migrations/V021__special_folders.sql")),
(22, include_str!("../../migrations/V022__identity_options.sql")),
(23, include_str!("../../migrations/V023__saved_searches.sql")),
(24, include_str!("../../migrations/V024__notification_dnd.sql")),
(25, include_str!("../../migrations/V025__custom_theme_colors.sql")),
(26, include_str!("../../migrations/V026__mail_preferences.sql")),
(27, include_str!("../../migrations/V027__contact_profile.sql")),
(28, include_str!("../../migrations/V028__contact_multi_values.sql")),
(29, include_str!("../../migrations/V029__draft_send_options.sql")),
(30, include_str!("../../migrations/V030__folder_list_preferences.sql")),
(31, include_str!("../../migrations/V031__compose_preferences.sql")),
(32, include_str!("../../migrations/V032__advanced_appearance.sql")),
(33, include_str!("../../migrations/V033__draft_compose_format.sql")),
(34, include_str!("../../migrations/V034__imap_keyword_sync.sql")),
(35, include_str!("../../migrations/V035__compose_behavior_preferences.sql")),
(36, include_str!("../../migrations/V036__remote_draft_tracking.sql")),
(37, include_str!("../../migrations/V037__openpgp_keyring.sql")),
(38, include_str!("../../migrations/V038__identity_openpgp.sql")),
(39, include_str!("../../migrations/V039__draft_openpgp_options.sql")),
(40, include_str!("../../migrations/V040__draft_openpgp_attach_key.sql")),
(41, include_str!("../../migrations/V041__draft_openpgp_recipient_keys.sql")),
];
/// Run any pending migrations based on SQLite's `user_version` PRAGMA.
///
/// If refinery has previously run migrations (indicated by the presence of
/// `refinery_schema_history`), we sync `user_version` to match the highest
/// refinery version before checking for new migrations. This avoids re-running
/// migrations that refinery already applied.
fn run_migrations(conn: &Connection) -> Result<(), String> {
let mut current: u32 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
.map_err(|e| format!("Failed to read user_version: {e}"))?;
// Sync with refinery's migration state so we never re-apply migrations
// that refinery already applied (refinery updates its own history table
// but doesn't touch the user_version pragma).
let refinery_max: Option<u32> = conn
.query_row(
"SELECT MAX(version) FROM refinery_schema_history",
[],
|row| row.get(0),
)
.unwrap_or(None);
if let Some(v) = refinery_max
&& v > current
{
current = v;
conn.execute_batch(&format!("PRAGMA user_version = {current};"))
.map_err(|e| format!("Failed to sync user_version from refinery: {e}"))?;
}
for &(version, sql) in MIGRATIONS {
if version > current {
conn.execute_batch(sql)
.map_err(|e| format!("Migration V{version:03} failed: {e}"))?;
conn.execute_batch(&format!("PRAGMA user_version = {version};"))
.map_err(|e| format!("Failed to set user_version to {version}: {e}"))?;
}
}
Ok(())
}
/// Opens the per-user SQLite database at `{data_dir}/{user_hash}/db.sqlite`.
///
/// Creates the directory tree if it doesn't exist, enables WAL journal mode
/// and foreign key enforcement, then runs any pending migrations.
pub fn open_user_db(data_dir: &str, user_hash: &str) -> Result<Connection, String> {
let dir = Path::new(data_dir).join(user_hash);
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create db dir: {e}"))?;
let db_path = dir.join("db.sqlite");
let conn =
Connection::open(&db_path).map_err(|e| format!("Failed to open SQLite: {e}"))?;
// Enable WAL mode for better concurrent read performance.
conn.execute_batch("PRAGMA journal_mode=WAL;")
.map_err(|e| format!("Failed to set WAL mode: {e}"))?;
// Enable foreign key constraint enforcement.
conn.execute_batch("PRAGMA foreign_keys=ON;")
.map_err(|e| format!("Failed to enable foreign keys: {e}"))?;
// Run any pending schema migrations.
run_migrations(&conn)?;
Ok(conn)
}
/// Opens an in-memory SQLite database with all migration scripts applied.
/// Used exclusively by tests so every test starts with a clean, fully-migrated
/// schema without touching the filesystem.
#[cfg(test)]
pub fn open_test_db() -> Connection {
let conn = Connection::open_in_memory().expect("Failed to open in-memory SQLite");
conn.execute_batch("PRAGMA foreign_keys=ON;")
.expect("Failed to enable foreign keys");
run_migrations(&conn).expect("Test migrations failed");
conn
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_open_test_db_has_tables() {
let conn = open_test_db();
// Verify the three expected tables exist.
let tables: Vec<String> = conn
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.unwrap()
.query_map([], |row| row.get(0))
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert!(tables.contains(&"user_meta".to_string()));
assert!(tables.contains(&"folders".to_string()));
assert!(tables.contains(&"messages".to_string()));
assert!(tables.contains(&"drafts".to_string()));
assert!(tables.contains(&"draft_attachments".to_string()));
}
#[test]
fn test_open_user_db_creates_file() {
let tmp = tempfile::tempdir().unwrap();
let data_dir = tmp.path().to_str().unwrap();
let conn = open_user_db(data_dir, "abc123").unwrap();
// The file should exist on disk.
let db_file = tmp.path().join("abc123").join("db.sqlite");
assert!(db_file.exists());
// Foreign keys should be enabled.
let fk: i32 = conn
.query_row("PRAGMA foreign_keys", [], |row| row.get(0))
.unwrap();
assert_eq!(fk, 1);
drop(conn);
}
}
#[cfg(test)]
mod async_tests {
use super::*;
#[tokio::test(flavor = "current_thread")]
async fn database_jobs_leave_runtime_free() {
let directory = tempfile::tempdir().unwrap();
let database = UserDb::new(directory.path().to_str().unwrap(), "test");
let (started, ready) = tokio::sync::oneshot::channel();
let (release, wait) = std::sync::mpsc::channel();
let job = tokio::spawn(async move {
database.run(move |_| {
started.send(()).unwrap();
wait.recv_timeout(std::time::Duration::from_secs(2))
.map_err(|e| format!("runtime could not release database job: {e}"))?;
Ok(())
}).await
});
ready.await.unwrap();
// On a current-thread runtime, an inline DB job prevents this send.
release.send(()).unwrap();
job.await.unwrap().unwrap();
}
}