Files
SellPL 9a5c529a8e 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>
2026-10-01 17:14:16 +03:00

35 lines
1.3 KiB
TOML

[package]
name = "blocker-storage"
version = "0.1.0"
edition = "2021"
description = "SQLite persistence for Blocker: schedules, lock state, and usage counters."
license = "UNLICENSED"
[dependencies]
blocker-core = { path = "../core" }
# rusqlite with bundled SQLite, per CLAUDE.md section 3. Bundled rather than
# system-linked because the app ships to machines where libsqlite3 may be absent
# or a different major version, and a storage-format mismatch that only shows up
# on a user's machine is not a failure mode worth carrying.
#
# THIS CRATE IS NOT DEPENDENT ON BY blocker-core.
#
# That is the entire reason this crate exists separately. CLAUDE.md section 2
# requires the engine to be a pure function with no I/O, and the strongest
# mechanical check for that is "blocker-core has zero dependencies" — a test
# that reads Cargo.lock and fails on any entry. Keeping storage in its own crate
# lets that test stay strict and automatic, instead of degrading into a source
# grep that someone can route around.
#
# Both crates sit in the same layer: storage is shared by every platform and
# contains no platform code, so this is a refinement of the section 2 diagram,
# not a departure from it.
rusqlite = { version = "0.37", features = ["bundled"] }
[lints]
workspace = true
[lib]
name = "blocker_storage"
path = "src/lib.rs"