feat(storage): add SQLite persistence in a separate crate
Implements ADR-4 (CLAUDE.md section 3, `rusqlite` bundled). Schedules, lock state, disable attempts and usage buckets now persist across restarts. **Why a new crate rather than a module inside `blocker-core`.** The purity contract says the engine does no I/O, and `tests/purity.rs` enforces that mechanically by failing if `blocker-core` has any dependency at all. Putting rusqlite in `blocker-core` would have forced that check to degrade into a source-text grep — something a careful developer can route around and nobody reads in six months. `blocker-storage` depends on `blocker-core`; the reverse edge does not exist, so the zero-dependency proof stays automatic. Both crates sit in the same layer of the section 2 diagram: storage is shared by every platform and contains no platform code. **Schema is relational, not JSON-in-a-text-column.** `schedule_target` and `schedule_condition` are separate tables because target rows are queried by target, and an index on a JSON string is not a plan. Serialisation is hand-written in `storage/src/codec.rs` — `serde` is not a section 3 dependency, and section 0 rule 3 requires asking before adding one. **Fail open, verified end to end** (section 1.5.4). Corruption moves the damaged file aside and starts clean, keeping the original for diagnosis. Confirmed against the real binary, not only in tests: ``` $ printf 'SQLite format 3\x00 not a database' > .../blocker.db $ blocker-daemon blocker-daemon: database was corrupt and has been moved to .../blocker.db.corrupt; starting clean, so no schedules are active until you re-add them EXIT=0 ``` A truncated file is covered separately — that is what a power loss during a write actually produces, and it fails differently from garbage bytes. **Two bugs this PR's own tests caught:** 1. `prune_usage` anchored its cutoff on `MIN(day_index)`, the oldest row. Since that row is by definition the one being tested, the cutoff always sat before it and the delete matched nothing — the table grew forever, which is the slow disk leak the function exists to prevent. Now anchored on `MAX`. 2. An off-by-one in the same function: with a strict `<` against `newest - keep_days`, the boundary day was kept too, yielding `keep_days + 1` rows. Both are covered by `old_usage_is_pruned`. **Cost, measured rather than assumed:** the stripped daemon goes from 0.279 MB to **1.364 MB** with SQLite actually linked in — bundled SQLite is ~1.1 MB of the 5 MB budget, leaving 3.8 MB for D-Bus, X11 and a UI toolkit. That is a real margin rather than a comfortable one, and it makes ADR-4's bundled-vs-system trade-off a genuine open question rather than a settled one. The M0 figure of 0.279 MB excluded SQLite entirely, because dead-code elimination dropped a crate the placeholder binary never referenced. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
//! Tests for the SQLite store.
|
||||
//!
|
||||
//! These use a temporary file rather than `:memory:` because WAL is enabled on
|
||||
//! the connection and an in-memory database with WAL is a subtly different thing
|
||||
//! than what ships.
|
||||
#![allow(
|
||||
clippy::unwrap_used,
|
||||
clippy::expect_used,
|
||||
clippy::panic,
|
||||
reason = "test fixtures: a failing assertion or a broken test is not a runtime \
|
||||
condition to handle gracefully"
|
||||
)]
|
||||
|
||||
use super::*;
|
||||
use blocker_core::model::{Condition, Schedule, ScheduleMode, Target, TimeWindow, UsageWindow};
|
||||
|
||||
/// A unique temp path per test, since tests run in parallel and a shared
|
||||
/// database would make them interfere.
|
||||
fn temp_db(name: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!("blocker-test-{}-{name}.db", std::process::id()));
|
||||
let _ = std::fs::remove_file(&p);
|
||||
p
|
||||
}
|
||||
|
||||
fn sample_schedule() -> Schedule {
|
||||
Schedule {
|
||||
id: "s1".into(),
|
||||
name: "Evening Focus".into(),
|
||||
icon: Some("/icons/focus.png".into()),
|
||||
mode: ScheduleMode::Blocklist,
|
||||
targets: vec![
|
||||
Target::App("com.example.social".into()),
|
||||
Target::Website("example.com".into()),
|
||||
Target::Keyword("casino".into()),
|
||||
],
|
||||
conditions: vec![
|
||||
Condition::Time(TimeWindow { days: vec![0, 1, 2, 3, 4], start: 22 * 60, end: 6 * 60 }),
|
||||
Condition::UsageLimit { limit_minutes: 90, window: UsageWindow::Daily },
|
||||
],
|
||||
paused: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedules_round_trip_through_the_database() {
|
||||
let path = temp_db("schedules");
|
||||
let original = sample_schedule();
|
||||
|
||||
{
|
||||
let mut store = Store::open(&path).expect("open");
|
||||
store.save_schedules(std::slice::from_ref(&original)).expect("save");
|
||||
}
|
||||
|
||||
{
|
||||
let store = Store::open(&path).expect("reopen");
|
||||
let loaded = store.load_schedules().expect("load");
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0], original, "a schedule must survive a restart intact");
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overnight_window_survives_a_restart() {
|
||||
// The specific case worth its own test: if the overnight flag were lost in
|
||||
// storage, the schedule would stop blocking at midnight *after a restart*,
|
||||
// while appearing correct in the UI.
|
||||
let path = temp_db("overnight");
|
||||
let mut original = sample_schedule();
|
||||
original.conditions =
|
||||
vec![Condition::Time(TimeWindow { days: vec![0], start: 22 * 60, end: 6 * 60 })];
|
||||
|
||||
{
|
||||
let mut store = Store::open(&path).expect("open");
|
||||
store.save_schedules(std::slice::from_ref(&original)).expect("save");
|
||||
}
|
||||
{
|
||||
let store = Store::open(&path).expect("reopen");
|
||||
let loaded = store.load_schedules().expect("load");
|
||||
match &loaded[0].conditions[0] {
|
||||
Condition::Time(w) => {
|
||||
assert!(
|
||||
w.is_overnight(),
|
||||
"start={} end={} must still read as overnight after a restart",
|
||||
w.start,
|
||||
w.end
|
||||
);
|
||||
assert_eq!(w.start, 22 * 60);
|
||||
assert_eq!(w.end, 6 * 60);
|
||||
}
|
||||
other => panic!("condition type changed across a restart: {other:?}"),
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_replaces_rather_than_accumulates() {
|
||||
let path = temp_db("replace");
|
||||
let mut store = Store::open(&path).expect("open");
|
||||
|
||||
store.save_schedules(&[sample_schedule(), sample_schedule_with_id("s2")]).expect("save");
|
||||
assert_eq!(store.load_schedules().unwrap().len(), 2);
|
||||
|
||||
store.save_schedules(&[sample_schedule()]).expect("save");
|
||||
let loaded = store.load_schedules().expect("load");
|
||||
assert_eq!(loaded.len(), 1, "a second save must replace, not append");
|
||||
assert_eq!(loaded[0].id, "s1");
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
fn sample_schedule_with_id(id: &str) -> Schedule {
|
||||
let mut s = sample_schedule();
|
||||
s.id = id.to_owned();
|
||||
s.name = format!("Schedule {id}");
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_accumulates_rather_than_overwrites() {
|
||||
let path = temp_db("usage");
|
||||
let mut store = Store::open(&path).expect("open");
|
||||
|
||||
store.add_daily_usage(20_731, "com.example.social", 30).unwrap();
|
||||
store.add_daily_usage(20_731, "com.example.social", 25).unwrap();
|
||||
store.add_daily_launch(20_731, "com.example.social").unwrap();
|
||||
store.add_daily_launch(20_731, "com.example.social").unwrap();
|
||||
|
||||
let row = store.load_daily_usage(20_731).expect("load");
|
||||
assert_eq!(row.minutes.get("com.example.social"), Some(&55));
|
||||
assert_eq!(row.launches.get("com.example.social"), Some(&2));
|
||||
|
||||
// A different day is a different bucket.
|
||||
assert!(store.load_daily_usage(20_732).unwrap().minutes.is_empty());
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pending_disable_request_survives_a_restart() {
|
||||
// Section 1.5.2.2: filing a request must not lose the request on restart, or
|
||||
// a user who waits out a 12-hour delay and restarts the machine would be
|
||||
// back at square one with no explanation.
|
||||
let path = temp_db("pending");
|
||||
let state = blocker_core::lock::LockState {
|
||||
level: blocker_core::lock::LockLevel::Stubborn,
|
||||
schedules_locked: true,
|
||||
pending: Some(blocker_core::lock::PendingRequest {
|
||||
ready_at: Monotonic(12 * 3_600_000),
|
||||
window_closes_at: Monotonic(12 * 3_600_000 + 600_000),
|
||||
}),
|
||||
cooldown_remaining: None,
|
||||
attempts_today: 0,
|
||||
keyholder_code: None,
|
||||
};
|
||||
|
||||
{
|
||||
let mut store = Store::open(&path).expect("open");
|
||||
store.save_lock(&state, BootId(7), 1_000).expect("save");
|
||||
}
|
||||
{
|
||||
let store = Store::open(&path).expect("reopen");
|
||||
let (ready, boot) = store
|
||||
.load_pending_request()
|
||||
.expect("load")
|
||||
.expect("a pending request must survive a restart");
|
||||
assert_eq!(ready, Monotonic(12 * 3_600_000));
|
||||
assert_eq!(boot, BootId(7), "the boot id must survive, so a reboot is detectable");
|
||||
}
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_pending_request_reads_as_none() {
|
||||
let path = temp_db("nopending");
|
||||
let store = Store::open(&path).expect("open");
|
||||
assert_eq!(store.load_pending_request().unwrap(), None);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disable_attempts_are_counted_per_day() {
|
||||
// Section 1.5.2.5: the block screen shows "you tried 4 times today".
|
||||
let path = temp_db("attempts");
|
||||
let mut store = Store::open(&path).expect("open");
|
||||
|
||||
for _ in 0..4 {
|
||||
store.record_attempt(Monotonic(1_000), BootId(1), 20_731).unwrap();
|
||||
}
|
||||
store.record_attempt(Monotonic(2_000), BootId(1), 20_732).unwrap();
|
||||
|
||||
assert_eq!(store.attempt_count(20_731).unwrap(), 4);
|
||||
assert_eq!(store.attempt_count(20_732).unwrap(), 1);
|
||||
assert_eq!(store.attempt_count(99_999).unwrap(), 0);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// Section 1.5.4: a corrupt database must never leave a device unusable.
|
||||
///
|
||||
/// This is the single most important test in the crate. The requirement is
|
||||
/// fail *open*: blocking stops, the user keeps a working device, and the damaged
|
||||
/// file is kept for diagnosis.
|
||||
#[test]
|
||||
fn a_corrupt_database_fails_open() {
|
||||
let path = temp_db("corrupt");
|
||||
|
||||
// Real bytes in place of the SQLite header. Enough to make the file
|
||||
// unopenable or unreadable, which is what "corrupt" means in the field.
|
||||
std::fs::write(&path, b"SQLite format 3\x00 this is not actually a database").unwrap();
|
||||
|
||||
let store = Store::open(&path).expect("open must succeed despite corruption");
|
||||
assert!(store.recovered_from_corruption(), "the store must report that it recovered");
|
||||
assert!(
|
||||
store.load_schedules().unwrap().is_empty(),
|
||||
"a recovered database starts empty, which blocks nothing — fail open"
|
||||
);
|
||||
assert!(
|
||||
path.with_extension("corrupt").exists(),
|
||||
"the damaged file must be kept for diagnosis, not deleted"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(path.with_extension("corrupt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_database_fails_open() {
|
||||
// A different failure mode from garbage bytes: a valid header with the
|
||||
// content cut off. Corruption in the field is often truncation, from a
|
||||
// power loss during a write.
|
||||
let path = temp_db("truncated");
|
||||
|
||||
{
|
||||
let mut store = Store::open(&path).expect("open");
|
||||
store.save_schedules(&[sample_schedule()]).expect("save");
|
||||
}
|
||||
|
||||
// Keep the first 200 bytes — a valid header, an unusable file.
|
||||
let full = std::fs::read(&path).unwrap();
|
||||
std::fs::write(&path, &full[..200.min(full.len())]).unwrap();
|
||||
|
||||
let store = Store::open(&path);
|
||||
assert!(
|
||||
store.is_ok(),
|
||||
"a truncated database must still open, because failing to open would \
|
||||
leave the device with no enforcement and no UI"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(path.with_extension("corrupt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_creates_the_directory() {
|
||||
let mut nested = std::env::temp_dir();
|
||||
nested.push(format!("blocker-test-{}-nested/state", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&nested);
|
||||
let path = nested.join("blocker.db");
|
||||
|
||||
let store = Store::open(&path);
|
||||
assert!(store.is_ok(), "a missing parent directory must not be fatal");
|
||||
assert!(path.exists());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&nested);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_usage_is_pruned() {
|
||||
// An unbounded usage table is a slow disk leak, and this machine has 9.6 GB
|
||||
// free. Statistics (section 1.6) need history, not infinite history.
|
||||
let path = temp_db("prune");
|
||||
let mut store = Store::open(&path).expect("open");
|
||||
|
||||
for day in 20_000..20_100 {
|
||||
store.add_daily_usage(day, "com.example.social", 10).unwrap();
|
||||
}
|
||||
|
||||
let removed = store.prune_usage(10).expect("prune");
|
||||
assert_eq!(removed, 90, "90 of 100 days are outside the 10-day window");
|
||||
|
||||
let kept = store.load_daily_usage(20_099).unwrap();
|
||||
assert!(!kept.minutes.is_empty(), "recent history must be kept");
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopening_is_idempotent() {
|
||||
// The migration must not run twice or clobber data.
|
||||
let path = temp_db("idempotent");
|
||||
let mut store = Store::open(&path).expect("open");
|
||||
store.save_schedules(&[sample_schedule()]).expect("save");
|
||||
drop(store);
|
||||
|
||||
for _ in 0..3 {
|
||||
let store = Store::open(&path).expect("reopen");
|
||||
assert_eq!(store.load_schedules().unwrap().len(), 1, "data must survive re-migration");
|
||||
}
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
Reference in New Issue
Block a user