//! SQLite persistence for Blocker (CLAUDE.md section 3, `rusqlite` bundled). //! //! # Why this is a separate crate from `blocker-core` //! //! Section 2 requires the engine to be a pure function with no I/O, and this //! project's `tests/purity.rs` enforces that mechanically: it reads the resolved //! dependency graph and fails if `blocker-core` has any dependency at all. That //! check is only meaningful while storage lives elsewhere. //! //! Both crates occupy the same layer of the section 2 diagram — storage is shared //! by every platform and contains no platform code — so this is a refinement that //! makes the purity guarantee mechanically checkable, not an architectural //! departure. //! //! # Fail open (section 1.5.4) //! //! A corrupt database must never leave a device unusable. [`Store::open`] moves a //! damaged file aside and starts clean rather than returning an error the caller //! might respond to by disabling enforcement. The damaged file is kept for //! diagnosis; blocking is then re-applied from defaults on the next start. //! //! Losing a schedule list is a far smaller harm than a phone that cannot make //! calls, and section 1.5.4 makes that trade explicit. use std::path::{Path, PathBuf}; use blocker_core::engine::{BootId, CivilTime, Monotonic}; use rusqlite::{Connection, OpenFlags}; use crate::codec::DecodeError; /// Current schema version, applied with `PRAGMA user_version`. pub const SCHEMA_VERSION: i64 = 1; /// Everything that can go wrong opening or preparing a store. /// /// Notably **absent**: a corruption variant that is fatal. Corruption is /// handled inside [`Store::open`] by starting clean, because section 1.5.4 /// requires failing open rather than refusing to run. #[derive(Debug)] pub enum StoreError { /// SQLite could not be initialised at all — for example the bundled library /// could not be loaded. Fatal, and genuinely exceptional. Init(rusqlite::Error), /// The directory holding the database could not be created. Io(std::io::Error), /// Stored bytes could not be decoded into the model. Decode(DecodeError), } impl std::fmt::Display for StoreError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Init(e) => write!(f, "could not initialise storage: {e}"), Self::Io(e) => write!(f, "storage directory unusable: {e}"), Self::Decode(e) => write!(f, "stored data could not be decoded: {e}"), } } } impl std::error::Error for StoreError {} /// A handle to the Blocker database. /// /// Cheap to clone conceptually but deliberately not `Clone`: the daemon is the /// only writer, and an accidental second connection would mean two processes /// fighting over lock state. #[derive(Debug)] pub struct Store { conn: Connection, path: PathBuf, /// Set when the database was found corrupt and moved aside, so the caller can /// log it. Section 1.5.4 wants this recorded, not silently swallowed. recovered_from_corruption: bool, } impl Store { /// Open, creating if absent, and ensure the schema is current. /// /// Never returns an error for corruption: a damaged database is renamed /// aside and a fresh one created. See the module docs. pub fn open(path: &Path) -> Result { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(StoreError::Io)?; } let mut recovered = false; // First attempt: open normally and verify it is actually usable. A // corrupt file often opens fine and only fails on first query, so the // probe below is what actually detects corruption. let mut attempt = Self::try_open(path)?; if let Err(probe) = attempt.conn.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0)) { recovered = true; Self::quarantine(path)?; attempt = Self::try_open(path)?; // Keep the probe result meaningful: if this one also fails, the // problem is not the file. if let Err(second) = attempt.conn.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0)) { return Err(StoreError::Init(second)); } let _ = probe; } attempt.migrate()?; attempt.recovered_from_corruption = recovered; Ok(attempt) } fn try_open(path: &Path) -> Result { let conn = Connection::open_with_flags( path, OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE | OpenFlags::SQLITE_OPEN_NO_MUTEX, ) .map_err(StoreError::Init)?; // WAL keeps a reader from blocking the daemon's writes, which matters // because the UI reads while the daemon writes. It also means a crash // cannot corrupt the main file, which is part of failing open. // Errors here are not fatal: a filesystem without WAL still works, just // with less concurrency. let _ = conn.pragma_update(None, "journal_mode", "WAL"); let _ = conn.pragma_update(None, "foreign_keys", "ON"); Ok(Self { conn, path: path.to_path_buf(), recovered_from_corruption: false }) } /// Move a damaged database aside, keeping it for diagnosis. fn quarantine(path: &Path) -> Result<(), StoreError> { let backup = path.with_extension("corrupt"); std::fs::rename(path, &backup).map_err(StoreError::Io)?; Ok(()) } /// Whether this store was recovered from corruption on open. pub fn recovered_from_corruption(&self) -> bool { self.recovered_from_corruption } fn migrate(&mut self) -> Result<(), StoreError> { let current: i64 = self .conn .query_row("PRAGMA user_version", [], |r| r.get(0)) .map_err(StoreError::Init)?; if current > SCHEMA_VERSION { // Forward-only by design (section 1.5.3: downgrading risks losing // lock state, which the safety rules do not permit). A newer file is // left alone rather than destroyed. return Err(StoreError::Init(rusqlite::Error::InvalidQuery)); } if current == SCHEMA_VERSION { return Ok(()); } self.conn.execute_batch(SCHEMA).map_err(StoreError::Init)?; self.conn.pragma_update(None, "user_version", SCHEMA_VERSION).map_err(StoreError::Init)?; Ok(()) } /// Load every schedule, with its targets and conditions. /// /// Two queries rather than a join: a schedule's children are needed together /// to reassemble the model, and SQLite's JSON functions are not used here so /// the relational shape stays portable. Schedules without children still /// appear, with empty vectors — `validation` is what rejects those, not the /// database. pub fn load_schedules(&self) -> Result, StoreError> { let headers = { let mut stmt = self .conn .prepare( "SELECT id, name, icon, mode, paused FROM schedule ORDER BY created_at, id", ) .map_err(StoreError::Init)?; let rows = stmt .query_map([], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, Option>(2)?, row.get::<_, String>(3)?, row.get::<_, bool>(4)?, )) }) .map_err(StoreError::Init)?; let mut out = Vec::new(); for row in rows { out.push(row.map_err(StoreError::Init)?); } out }; let mut schedules = Vec::with_capacity(headers.len()); for (id, name, icon, mode, paused) in headers { let targets = self.load_targets(&id)?; let conditions = self.load_conditions(&id)?; schedules.push( codec::decode_schedule( &id, &name, icon.as_deref(), &mode, &targets, &conditions, paused, ) .map_err(StoreError::Decode)?, ); } Ok(schedules) } fn load_targets(&self, schedule_id: &str) -> Result, StoreError> { let mut stmt = self .conn .prepare( "SELECT ordinal, kind, value FROM schedule_target WHERE schedule_id = ?1 ORDER BY ordinal", ) .map_err(StoreError::Init)?; let rows = stmt .query_map([schedule_id], |row| { Ok(codec::TargetRow { ordinal: row.get(0)?, kind: row.get(1)?, value: row.get(2)? }) }) .map_err(StoreError::Init)?; let mut out = Vec::new(); for row in rows { out.push(row.map_err(StoreError::Init)?); } Ok(out) } fn load_conditions(&self, schedule_id: &str) -> Result, StoreError> { let mut stmt = self .conn .prepare( "SELECT ordinal, kind, days, start_min, end_min, limit_value, window, ssid, latitude, longitude, radius_m, outside FROM schedule_condition WHERE schedule_id = ?1 ORDER BY ordinal", ) .map_err(StoreError::Init)?; let rows = stmt .query_map([schedule_id], |row| { Ok(codec::ConditionRow { ordinal: row.get(0)?, kind: row.get(1)?, days: row.get(2)?, start_min: row.get(3)?, end_min: row.get(4)?, limit_value: row.get(5)?, window: row.get(6)?, ssid: row.get(7)?, latitude: row.get(8)?, longitude: row.get(9)?, radius_m: row.get(10)?, outside: row.get(11)?, }) }) .map_err(StoreError::Init)?; let mut out = Vec::new(); for row in rows { out.push(row.map_err(StoreError::Init)?); } Ok(out) } /// Persist the full schedule set, replacing what was there. /// /// Written as a single transaction so a crash cannot leave a half-applied /// schedule list — which on a blocking tool would mean some targets blocked /// and others not, with no way for the user to tell which. pub fn save_schedules( &mut self, schedules: &[blocker_core::model::Schedule], ) -> Result<(), StoreError> { let tx = self.conn.transaction().map_err(StoreError::Init)?; // Children cascade on delete, so clearing the parents is enough and // avoids a stale row outliving its schedule. tx.execute("DELETE FROM schedule", []).map_err(StoreError::Init)?; { let mut insert_target = tx .prepare( "INSERT INTO schedule_target (schedule_id, ordinal, kind, value) VALUES (?1, ?2, ?3, ?4)", ) .map_err(StoreError::Init)?; let mut insert_condition = tx .prepare( "INSERT INTO schedule_condition (schedule_id, ordinal, kind, days, start_min, end_min, limit_value, window, ssid, latitude, longitude, radius_m, outside) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", ) .map_err(StoreError::Init)?; for (index, s) in schedules.iter().enumerate() { tx.execute( "INSERT INTO schedule (id, name, icon, mode, paused, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", rusqlite::params![ s.id, s.name, s.icon, codec::mode_to_str(s.mode), i64::from(s.paused), i64::try_from(index).unwrap_or(i64::MAX), ], ) .map_err(StoreError::Init)?; for row in codec::encode_targets(&s.targets) { insert_target .execute(rusqlite::params![s.id, row.ordinal, row.kind, row.value]) .map_err(StoreError::Init)?; } for row in codec::encode_conditions(&s.conditions) { insert_condition .execute(rusqlite::params![ s.id, row.ordinal, row.kind, row.days, row.start_min, row.end_min, row.limit_value, row.window, row.ssid, row.latitude, row.longitude, row.radius_m, row.outside, ]) .map_err(StoreError::Init)?; } } } tx.commit().map_err(StoreError::Init) } /// Persist lock state, including the monotonic deadline a pending disable /// request is waiting on (section 1.5.2.2). pub fn save_lock( &mut self, state: &blocker_core::lock::LockState, boot_id: BootId, last_monotonic_ms: u64, ) -> Result<(), StoreError> { let pending = state.pending.as_ref(); self.conn .execute( "INSERT INTO lock_config (id, level, schedules_locked, blocks_uninstall, keyholder_code, request_ready_at, request_window_closes_at, boot_id, last_monotonic_ms) VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) ON CONFLICT(id) DO UPDATE SET level = excluded.level, schedules_locked = excluded.schedules_locked, blocks_uninstall = excluded.blocks_uninstall, keyholder_code = excluded.keyholder_code, request_ready_at = excluded.request_ready_at, request_window_closes_at = excluded.request_window_closes_at, boot_id = excluded.boot_id, last_monotonic_ms = excluded.last_monotonic_ms", rusqlite::params![ codec::level_to_str(state.level), i64::from(state.schedules_locked), i64::from(state.blocks_uninstall()), state.keyholder_code, pending.map(|p| i64::try_from(p.ready_at.0).unwrap_or(i64::MAX)), pending.map(|p| i64::try_from(p.window_closes_at.0).unwrap_or(i64::MAX)), i64::try_from(boot_id.0).unwrap_or(i64::MAX), i64::try_from(last_monotonic_ms).unwrap_or(i64::MAX), ], ) .map(|_| ()) .map_err(StoreError::Init) } /// The monotonic deadline and boot id of a pending disable request. /// /// Used on resume to distinguish "the daemon restarted" from "the machine /// rebooted" (section 1.5.3), so a lock is resumed conservatively rather /// than assumed to have continued. pub fn load_pending_request(&self) -> Result, StoreError> { let mut stmt = self .conn .prepare("SELECT request_ready_at, boot_id FROM lock_config WHERE id = 1") .map_err(StoreError::Init)?; let mut rows = stmt.query([]).map_err(StoreError::Init)?; match rows.next().map_err(StoreError::Init)? { None => Ok(None), Some(row) => { let ready: Option = row.get(0).map_err(StoreError::Init)?; let boot: i64 = row.get(1).map_err(StoreError::Init)?; Ok(ready.map(|r| { ( Monotonic(u64::try_from(r.max(0)).unwrap_or(0)), BootId(u64::try_from(boot.max(0)).unwrap_or(0)), ) })) } } } /// Record one attempt to disable (section 1.5.2.5). Local only. pub fn record_attempt( &mut self, at: Monotonic, boot_id: BootId, day_index: i64, ) -> Result<(), StoreError> { self.conn .execute( "INSERT INTO disable_attempt (at_monotonic, boot_id, day_index) VALUES (?1, ?2, ?3)", rusqlite::params![ i64::try_from(at.0).unwrap_or(i64::MAX), i64::try_from(boot_id.0).unwrap_or(i64::MAX), day_index, ], ) .map(|_| ()) .map_err(StoreError::Init)?; Ok(()) } /// How many disable attempts were made on a given local day. /// /// The block screen shows this as plain text — "You tried to turn this off 4 /// times today" — never as a shaming counter (section 1.5.2.5). pub fn attempt_count(&self, day_index: i64) -> Result { let count: i64 = self .conn .query_row( "SELECT COUNT(*) FROM disable_attempt WHERE day_index = ?1", [day_index], |r| r.get(0), ) .map_err(StoreError::Init)?; Ok(u32::try_from(count.max(0)).unwrap_or(u32::MAX)) } /// Add foreground minutes to a daily bucket (section 1.2). pub fn add_daily_usage( &mut self, day_index: i64, target: &str, minutes: u32, ) -> Result<(), StoreError> { self.conn .execute( "INSERT INTO usage_daily (day_index, target, minutes) VALUES (?1, ?2, ?3) ON CONFLICT(day_index, target) DO UPDATE SET minutes = minutes + excluded.minutes", rusqlite::params![day_index, target, i64::from(minutes)], ) .map(|_| ()) .map_err(StoreError::Init)?; Ok(()) } /// Add a launch to a daily bucket (section 1.2). pub fn add_daily_launch(&mut self, day_index: i64, target: &str) -> Result<(), StoreError> { self.conn .execute( "INSERT INTO usage_daily (day_index, target, minutes, launches) VALUES (?1, ?2, 0, 1) ON CONFLICT(day_index, target) DO UPDATE SET launches = launches + 1", rusqlite::params![day_index, target], ) .map(|_| ()) .map_err(StoreError::Init)?; Ok(()) } /// Read a daily usage bucket. pub fn load_daily_usage(&self, day_index: i64) -> Result { let mut stmt = self .conn .prepare("SELECT target, minutes, launches FROM usage_daily WHERE day_index = ?1") .map_err(StoreError::Init)?; let mut rows = stmt.query([day_index]).map_err(StoreError::Init)?; let mut out = UsageRow::default(); while let Some(row) = rows.next().map_err(StoreError::Init)? { let target: String = row.get(0).map_err(StoreError::Init)?; let minutes: i64 = row.get(1).map_err(StoreError::Init)?; let launches: i64 = row.get(2).map_err(StoreError::Init)?; out.minutes.insert(target.clone(), u32::try_from(minutes.max(0)).unwrap_or(u32::MAX)); out.launches.insert(target, u32::try_from(launches.max(0)).unwrap_or(u32::MAX)); } Ok(out) } /// Local day index for a civil time, for bucketing usage. pub fn day_index(now: CivilTime) -> i64 { now.epoch_minutes.div_euclid(24 * 60) } /// Path this store was opened from. pub fn path(&self) -> &Path { &self.path } /// Run a maintenance step: drop usage rows older than `keep_days`. /// /// Pruning is deliberate rather than unbounded — an app-blocker's usage /// history is genuinely useful for statistics (section 1.6) but a table /// that grows forever is a slow disk leak on a machine with 9.6 GB free. pub fn prune_usage(&mut self, keep_days: i64) -> Result { // Relative to the NEWEST day, not the oldest. // // Anchoring on MIN(day_index) is the obvious version and it never prunes // anything: the oldest row is by definition the one being tested, so the // cutoff sits before it and the delete matches zero rows. The table then // grows forever, which on a machine with 9.6 GB free is exactly the slow // leak this is meant to prevent. Caught by old_usage_is_pruned. let newest: i64 = self .conn .query_row("SELECT COALESCE(MAX(day_index), 0) FROM usage_daily", [], |r| { r.get::<_, i64>(0) }) .map_err(StoreError::Init)?; // +1 so exactly `keep_days` rows survive: with cutoff = newest - keep_days // and a strict `<`, the boundary day is kept as well, giving keep_days + 1. let cutoff = newest - keep_days + 1; let n = self .conn .execute("DELETE FROM usage_daily WHERE day_index < ?1", [cutoff]) .map_err(StoreError::Init)?; Ok(n as u64) } } /// One bucket of accumulated usage. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct UsageRow { /// Foreground minutes per target. pub minutes: std::collections::BTreeMap, /// Launch counts per target. pub launches: std::collections::BTreeMap, } /// Schema, version 1. /// /// Forward-only migrations. `user_version` gates them; downgrading would risk /// losing lock state, which section 1.5.3 forbids and section 1.5.4 makes a /// safety property. /// /// The normalised shape of `schedule_target` and `schedule_condition` is /// deliberate over storing JSON blobs: usage and target rows are queried by /// target, and an index on a text column that happens to contain JSON is not a /// plan. Encoding is handled in [`crate::codec`]. pub const SCHEMA: &str = r#" CREATE TABLE IF NOT EXISTS schedule ( id TEXT PRIMARY KEY, name TEXT NOT NULL, icon TEXT, mode TEXT NOT NULL CHECK (mode IN ('Blocklist', 'Allowlist')), paused INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS schedule_target ( schedule_id TEXT NOT NULL REFERENCES schedule(id) ON DELETE CASCADE, ordinal INTEGER NOT NULL, kind TEXT NOT NULL CHECK (kind IN ('app', 'website', 'keyword')), value TEXT NOT NULL, PRIMARY KEY (schedule_id, ordinal) ); CREATE TABLE IF NOT EXISTS schedule_condition ( schedule_id TEXT NOT NULL REFERENCES schedule(id) ON DELETE CASCADE, ordinal INTEGER NOT NULL, kind TEXT NOT NULL, days TEXT, -- comma-separated, Monday = 0 start_min INTEGER, end_min INTEGER, limit_value INTEGER, window TEXT, ssid TEXT, latitude REAL, longitude REAL, radius_m INTEGER, outside INTEGER, PRIMARY KEY (schedule_id, ordinal) ); -- Section 1.5.3: lock state lives in the database, not in an editable plaintext -- config file, so editing a file is not enough to defeat a lock. CREATE TABLE IF NOT EXISTS lock_config ( id INTEGER PRIMARY KEY CHECK (id = 1), level TEXT NOT NULL CHECK (level IN ('Normal', 'Strict', 'Stubborn')), schedules_locked INTEGER NOT NULL, blocks_uninstall INTEGER NOT NULL, keyholder_code TEXT, request_ready_at INTEGER, request_window_closes_at INTEGER, boot_id INTEGER NOT NULL, last_monotonic_ms INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS disable_attempt ( id INTEGER PRIMARY KEY AUTOINCREMENT, at_monotonic INTEGER NOT NULL, boot_id INTEGER NOT NULL, day_index INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS usage_daily ( day_index INTEGER NOT NULL, target TEXT NOT NULL, minutes INTEGER NOT NULL DEFAULT 0, launches INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (day_index, target) ); CREATE TABLE IF NOT EXISTS usage_hourly ( hour_index INTEGER NOT NULL, target TEXT NOT NULL, minutes INTEGER NOT NULL DEFAULT 0, launches INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (hour_index, target) ); CREATE INDEX IF NOT EXISTS usage_daily_target ON usage_daily (target); CREATE INDEX IF NOT EXISTS usage_hourly_target ON usage_hourly (target); CREATE INDEX IF NOT EXISTS disable_attempt_day ON disable_attempt (day_index); "#; pub mod codec; #[cfg(test)] mod tests;