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:
+71
-11
@@ -2,16 +2,16 @@
|
||||
//!
|
||||
//! The daemon observes the OS and enforces the decisions `blocker-core` makes.
|
||||
//! It contains **no decision logic of its own** (CLAUDE.md section 2): if the
|
||||
//! daemon ever decides that something should be blocked, the Android and
|
||||
//! desktop builds will drift and the golden tables stop being a parity contract.
|
||||
//! daemon ever decides that something should be blocked, the Android and desktop
|
||||
//! builds will drift and the golden tables stop being a parity contract.
|
||||
//!
|
||||
//! # Shape
|
||||
//!
|
||||
//! Two processes, deliberately:
|
||||
//!
|
||||
//! - the **daemon** — small, long-lived, stays resident, holds the block window
|
||||
//! machinery and the engine connection. This is the binary with the 5 MB
|
||||
//! stripped budget (section 5).
|
||||
//! - the **daemon** — small, long-lived, holds the block window machinery and
|
||||
//! the engine connection. This is the binary with the 5 MB stripped budget
|
||||
//! (section 5).
|
||||
//! - the **UI** (`blocker-ui`) — a separate process launched to show a block
|
||||
//! screen, which exits when the block lifts.
|
||||
//!
|
||||
@@ -20,14 +20,74 @@
|
||||
//!
|
||||
//! # Event loop
|
||||
//!
|
||||
//! M2-Linux replaces this placeholder with a loop that blocks on exactly three
|
||||
//! things: a focus-change event from the active
|
||||
//! [`ForegroundWatcher`](../../blocker_core/index.html) backend, the
|
||||
//! M2-Linux replaces the startup path below with a loop that blocks on exactly
|
||||
//! three things: a focus-change event from the active
|
||||
//! [`ForegroundWatcher`](../../blocker_platform_linux/index.html) backend, the
|
||||
//! `next_wakeup` instant from the engine, and IPC from the UI. There is no
|
||||
//! polling tick anywhere — an idle daemon takes zero wakeups, which is how the
|
||||
//! section 5 idle-CPU budget is met.
|
||||
//!
|
||||
//! # Where the database lives
|
||||
//!
|
||||
//! Under the XDG data directory, never beside the binary. The binary may be
|
||||
//! installed read-only in `/usr/bin`, while the database must be writable and
|
||||
//! must survive an upgrade.
|
||||
|
||||
fn main() {
|
||||
println!("blocker-daemon {} (M0 placeholder)", env!("CARGO_PKG_VERSION"));
|
||||
println!("enforcement lands in M2-Linux; see docs/DECISIONS.md for the ADR log");
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
/// `~/.local/share/blocker/blocker.db`, honouring `XDG_DATA_HOME`.
|
||||
fn database_path() -> PathBuf {
|
||||
let base = std::env::var_os("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.is_absolute())
|
||||
.unwrap_or_else(|| {
|
||||
let home =
|
||||
std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
|
||||
home.join(".local/share")
|
||||
});
|
||||
base.join("blocker").join("blocker.db")
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let path = database_path();
|
||||
|
||||
// Section 1.5.4: a corrupt or unusable database must never leave the device
|
||||
// unusable. `Store::open` handles corruption by moving the damaged file
|
||||
// aside and starting clean, so what reaches the `Err` arm is genuinely fatal
|
||||
// — an unwritable directory, say. Even then we exit with a message rather
|
||||
// than panicking: a daemon that crashes on startup is worse than one that
|
||||
// reports the problem and stops.
|
||||
let store = match blocker_storage::Store::open(&path) {
|
||||
Ok(store) => store,
|
||||
Err(e) => {
|
||||
eprintln!("blocker-daemon: cannot open storage at {}: {e}", path.display());
|
||||
eprintln!("blocking is disabled until this is fixed; see docs/LIMITATIONS.md");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
if store.recovered_from_corruption() {
|
||||
// Logged, not swallowed: section 1.5.4 wants the recovery recorded.
|
||||
eprintln!(
|
||||
"blocker-daemon: database was corrupt and has been moved to {}.corrupt; \
|
||||
starting clean, so no schedules are active until you re-add them",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let schedules = match store.load_schedules() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("blocker-daemon: cannot read schedules: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
println!("blocker-daemon {} (M0/M1)", env!("CARGO_PKG_VERSION"));
|
||||
println!("database: {}", path.display());
|
||||
println!("schedules: {}", schedules.len());
|
||||
println!("enforcement lands in M2-Linux; see docs/DECISIONS.md for the ADR log");
|
||||
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user