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:
2026-10-01 17:14:16 +03:00
parent af8b28f6c6
commit 9a5c529a8e
9 changed files with 1721 additions and 12 deletions
+347
View File
@@ -0,0 +1,347 @@
//! Encoding between model types and their database representation.
//!
//! Split out from the SQL so the tricky part — turning a `Condition` into
//! nullable columns and back without silently losing a field — is testable
//! without a database, and so a decode failure has somewhere precise to point.
//!
//! # Why this is hand-written rather than serde
//!
//! `serde` is not a CLAUDE.md section 3 dependency, and section 0 rule 3 requires
//! stopping to ask before adding one. The encoding is a few hundred lines of
//! mechanical match arms, which is a real cost — and it is the cost of not adding
//! a dependency without permission. If serde is approved later, this module is the
//! only thing that changes.
//!
//! # Decoding never panics
//!
//! Every decode failure returns [`DecodeError`]. A database written by a newer
//! version, or corrupted by hand, must produce a diagnosable error rather than a
//! panic: this is enforcement code, and section 1.5.4 requires failing open.
use blocker_core::model::{
Condition, DayOfWeek, MinuteOfDay, Schedule, ScheduleMode, Target, TimeWindow, UsageWindow,
};
/// Why stored bytes could not become a model value.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DecodeError {
/// A `mode` column held something other than the two known values.
UnknownMode(String),
/// A `level` column held something other than the three known values.
UnknownLevel(String),
/// A `kind` column held something unrecognised.
UnknownKind(String),
/// A target string was empty.
EmptyTarget,
/// A `start`/`end` time was not `HH:MM`, or was outside 0..1440.
BadTime(String),
/// A time of day fell outside 0..1440 minutes.
MinuteOutOfRange(i64),
/// A `days` list was malformed, or held a value outside 0..=6.
BadDays(String),
/// A boolean column was neither 0 nor 1.
BadFlag(i64),
/// A numeric column was negative where only a count is meaningful.
NegativeCount(i64),
}
impl std::fmt::Display for DecodeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownMode(m) => write!(f, "unknown schedule mode {m:?}"),
Self::UnknownLevel(l) => write!(f, "unknown lock level {l:?}"),
Self::UnknownKind(k) => write!(f, "unknown condition kind {k:?}"),
Self::EmptyTarget => write!(f, "target value is empty"),
Self::BadTime(t) => write!(f, "time {t:?} is not HH:MM"),
Self::MinuteOutOfRange(m) => write!(f, "minute {m} is outside 0..1440"),
Self::BadDays(d) => write!(f, "day list {d:?} is malformed"),
Self::BadFlag(v) => write!(f, "expected 0 or 1, found {v}"),
Self::NegativeCount(v) => write!(f, "expected a non-negative count, found {v}"),
}
}
}
impl std::error::Error for DecodeError {}
/// `ScheduleMode` as stored.
pub fn mode_to_str(mode: ScheduleMode) -> &'static str {
match mode {
ScheduleMode::Blocklist => "Blocklist",
ScheduleMode::Allowlist => "Allowlist",
}
}
pub fn mode_from_str(raw: &str) -> Result<ScheduleMode, DecodeError> {
match raw {
"Blocklist" => Ok(ScheduleMode::Blocklist),
"Allowlist" => Ok(ScheduleMode::Allowlist),
other => Err(DecodeError::UnknownMode(other.to_owned())),
}
}
/// `LockLevel` as stored.
pub fn level_to_str(level: blocker_core::lock::LockLevel) -> &'static str {
match level {
blocker_core::lock::LockLevel::Normal => "Normal",
blocker_core::lock::LockLevel::Strict => "Strict",
blocker_core::lock::LockLevel::Stubborn => "Stubborn",
}
}
pub fn level_from_str(raw: &str) -> Result<blocker_core::lock::LockLevel, DecodeError> {
use blocker_core::lock::LockLevel;
match raw {
"Normal" => Ok(LockLevel::Normal),
"Strict" => Ok(LockLevel::Strict),
"Stubborn" => Ok(LockLevel::Stubborn),
other => Err(DecodeError::UnknownLevel(other.to_owned())),
}
}
/// A decoded target.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TargetRow {
/// Position within the schedule, preserving the user's ordering.
pub ordinal: i64,
/// Discriminator: `app`, `website`, or `keyword`.
pub kind: String,
/// The identifier itself.
pub value: String,
}
/// Targets in database form.
pub fn encode_targets(targets: &[Target]) -> Vec<TargetRow> {
targets
.iter()
.enumerate()
.map(|(ordinal, t)| {
let (kind, value) = match t {
Target::App(v) => ("app", v.clone()),
Target::Website(v) => ("website", v.clone()),
Target::Keyword(v) => ("keyword", v.clone()),
};
TargetRow {
ordinal: i64::try_from(ordinal).unwrap_or(i64::MAX),
kind: kind.to_owned(),
value,
}
})
.collect()
}
pub fn decode_targets(rows: &[TargetRow]) -> Result<Vec<Target>, DecodeError> {
let mut out: Vec<(i64, Target)> = Vec::with_capacity(rows.len());
for row in rows {
if row.value.is_empty() {
return Err(DecodeError::EmptyTarget);
}
let target = match row.kind.as_str() {
"app" => Target::App(row.value.clone()),
"website" => Target::Website(row.value.clone()),
"keyword" => Target::Keyword(row.value.clone()),
other => return Err(DecodeError::UnknownKind(other.to_owned())),
};
out.push((row.ordinal, target));
}
// Ordinals come from the database unordered; sorting restores user order,
// which matters for a list the user arranged deliberately.
out.sort_by_key(|(ordinal, _)| *ordinal);
Ok(out.into_iter().map(|(_, t)| t).collect())
}
/// A decoded condition. Column-shaped rather than model-shaped, so a NULL in the
/// wrong place is caught here rather than becoming a silently defaulted
/// condition.
#[derive(Debug, Clone, PartialEq)]
pub struct ConditionRow {
pub ordinal: i64,
pub kind: String,
/// Comma-separated weekdays, Monday = 0.
pub days: Option<String>,
pub start_min: Option<i64>,
pub end_min: Option<i64>,
pub limit_value: Option<i64>,
pub window: Option<String>,
pub ssid: Option<String>,
pub latitude: Option<f64>,
pub longitude: Option<f64>,
pub radius_m: Option<i64>,
pub outside: Option<i64>,
}
pub fn encode_conditions(conditions: &[Condition]) -> Vec<ConditionRow> {
conditions
.iter()
.enumerate()
.map(|(ordinal, c)| {
let mut row = ConditionRow {
ordinal: i64::try_from(ordinal).unwrap_or(i64::MAX),
kind: String::new(),
days: None,
start_min: None,
end_min: None,
limit_value: None,
window: None,
ssid: None,
latitude: None,
longitude: None,
radius_m: None,
outside: None,
};
match c {
Condition::Time(w) => {
row.kind = "Time".to_owned();
row.days = Some(w.days.iter().map(u8::to_string).collect::<Vec<_>>().join(","));
row.start_min = Some(i64::from(w.start));
row.end_min = Some(i64::from(w.end));
}
Condition::UsageLimit { limit_minutes, window } => {
row.kind = "UsageLimit".to_owned();
row.limit_value = Some(i64::from(*limit_minutes));
row.window = Some(window_to_str(*window).to_owned());
}
Condition::LaunchCount { limit, window } => {
row.kind = "LaunchCount".to_owned();
row.limit_value = Some(i64::from(*limit));
row.window = Some(window_to_str(*window).to_owned());
}
Condition::WifiSsid(ssid) => {
row.kind = "WifiSsid".to_owned();
row.ssid = Some(ssid.clone());
}
Condition::Location { latitude, longitude, radius_meters, block_when_outside } => {
row.kind = "Location".to_owned();
row.latitude = Some(*latitude);
row.longitude = Some(*longitude);
row.radius_m = Some(i64::from(*radius_meters));
row.outside = Some(i64::from(*block_when_outside));
}
}
row
})
.collect()
}
pub fn decode_conditions(rows: &[ConditionRow]) -> Result<Vec<Condition>, DecodeError> {
let mut out: Vec<(i64, Condition)> = Vec::with_capacity(rows.len());
for row in rows {
out.push((row.ordinal, decode_condition(row)?));
}
out.sort_by_key(|(ordinal, _)| *ordinal);
Ok(out.into_iter().map(|(_, c)| c).collect())
}
fn decode_condition(row: &ConditionRow) -> Result<Condition, DecodeError> {
match row.kind.as_str() {
"Time" => {
let days = row.days.as_deref().ok_or(DecodeError::BadDays("<null>".into()))?;
let start = require_minute(row.start_min)?;
let end = require_minute(row.end_min)?;
Ok(Condition::Time(TimeWindow { days: decode_days(days)?, start, end }))
}
"UsageLimit" => Ok(Condition::UsageLimit {
limit_minutes: require_count(row.limit_value)?,
window: window_from_str(row.window.as_deref())?,
}),
"LaunchCount" => Ok(Condition::LaunchCount {
limit: require_count(row.limit_value)?,
window: window_from_str(row.window.as_deref())?,
}),
"WifiSsid" => Ok(Condition::WifiSsid(row.ssid.clone().ok_or(DecodeError::EmptyTarget)?)),
"Location" => Ok(Condition::Location {
latitude: row.latitude.ok_or(DecodeError::NegativeCount(-1))?,
longitude: row.longitude.ok_or(DecodeError::NegativeCount(-1))?,
radius_meters: require_count(row.radius_m)?,
block_when_outside: decode_flag(row.outside.unwrap_or(0))?,
}),
other => Err(DecodeError::UnknownKind(other.to_owned())),
}
}
fn require_minute(value: Option<i64>) -> Result<MinuteOfDay, DecodeError> {
let v = value.ok_or(DecodeError::MinuteOutOfRange(-1))?;
u16::try_from(v).ok().filter(|m| *m < 1440).ok_or(DecodeError::MinuteOutOfRange(v))
}
fn require_count(value: Option<i64>) -> Result<u32, DecodeError> {
let v = value.ok_or(DecodeError::NegativeCount(-1))?;
u32::try_from(v).map_err(|_| DecodeError::NegativeCount(v))
}
fn decode_flag(value: i64) -> Result<bool, DecodeError> {
match value {
0 => Ok(false),
1 => Ok(true),
other => Err(DecodeError::BadFlag(other)),
}
}
fn window_to_str(window: UsageWindow) -> &'static str {
match window {
UsageWindow::Daily => "Daily",
UsageWindow::Hourly => "Hourly",
}
}
fn window_from_str(raw: Option<&str>) -> Result<UsageWindow, DecodeError> {
match raw {
Some("Daily") => Ok(UsageWindow::Daily),
Some("Hourly") => Ok(UsageWindow::Hourly),
_ => Err(DecodeError::UnknownKind(format!("usage window {raw:?}"))),
}
}
fn decode_days(raw: &str) -> Result<Vec<DayOfWeek>, DecodeError> {
if raw.is_empty() {
return Ok(Vec::new());
}
raw.split(',')
.map(|part| {
let trimmed = part.trim();
trimmed
.parse::<u8>()
.ok()
.filter(|d| *d < 7)
.ok_or_else(|| DecodeError::BadDays(raw.to_owned()))
})
.collect()
}
/// `HH:MM` to minutes since midnight. Used by the golden-table loader, which
/// reads times as strings.
pub fn parse_hhmm(raw: &str) -> Result<MinuteOfDay, DecodeError> {
let (h, m) = raw.split_once(':').ok_or_else(|| DecodeError::BadTime(raw.to_owned()))?;
let h: u16 = h.trim().parse().map_err(|_| DecodeError::BadTime(raw.to_owned()))?;
let m: u16 = m.trim().parse().map_err(|_| DecodeError::BadTime(raw.to_owned()))?;
if m >= 60 || h >= 24 {
return Err(DecodeError::BadTime(raw.to_owned()));
}
Ok(h * 60 + m)
}
/// Reassemble a schedule from its header and its decoded children.
///
/// Kept here rather than in `lib.rs` so the ordering contract — children sorted
/// by ordinal, attached to the right parent — is tested in one place.
pub fn decode_schedule(
id: &str,
name: &str,
icon: Option<&str>,
mode: &str,
targets: &[TargetRow],
conditions: &[ConditionRow],
paused: bool,
) -> Result<Schedule, DecodeError> {
Ok(Schedule {
id: id.to_owned(),
name: name.to_owned(),
icon: icon.map(str::to_owned),
mode: mode_from_str(mode)?,
targets: decode_targets(targets)?,
conditions: decode_conditions(conditions)?,
paused,
})
}
#[cfg(test)]
mod tests;