9a5c529a8e
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>
645 lines
25 KiB
Rust
645 lines
25 KiB
Rust
//! 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<Self, StoreError> {
|
|
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<Self, StoreError> {
|
|
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<Vec<blocker_core::model::Schedule>, 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<String>>(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<Vec<codec::TargetRow>, 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<Vec<codec::ConditionRow>, 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<Option<(Monotonic, BootId)>, 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<i64> = 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<u32, StoreError> {
|
|
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<UsageRow, StoreError> {
|
|
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<u64, StoreError> {
|
|
// 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<String, u32>,
|
|
/// Launch counts per target.
|
|
pub launches: std::collections::BTreeMap<String, u32>,
|
|
}
|
|
|
|
/// 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;
|