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,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;
|
||||
Reference in New Issue
Block a user