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
+644
View File
@@ -0,0 +1,644 @@
//! 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;