max / balanced_breakfast
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
13 files changed,
+80 insertions,
-111 deletions
| @@ -7,10 +7,10 @@ | |||
| 7 | 7 | ## [Unreleased] | |
| 8 | 8 | ||
| 9 | 9 | ### Changed | |
| 10 | - | - The database layer moved from sqlx to rusqlite, the house SQLite standard. The repositories are synchronous now: SQLite has no async API, and the async this layer wore came from sqlx rather than from anything the app wanted. No schema change and no migration of your data -- the existing migration ledger is read and appended to exactly as before, so an upgraded install applies nothing on first launch. | |
| 10 | + | - The database layer moved from sqlx to rusqlite, and the repositories are synchronous. No schema change and no migration of your data: the existing migration ledger is read and appended to as before, so an upgraded install applies nothing on first launch. | |
| 11 | 11 | ||
| 12 | 12 | ### Removed | |
| 13 | - | - Supernote push. The `supernote` build feature, the "Send to Supernote" button, and the `supernote-push` dependency are all gone. The library behind it was decommissioned when the reMarkable displaced the Supernote as the daily e-ink device. Releases built before this carried the feature on; releases after it will not. | |
| 13 | + | - Supernote push. The `supernote` build feature, the "Send to Supernote" button, and the `supernote-push` dependency are all gone. | |
| 14 | 14 | ||
| 15 | 15 | ## [0.3.0] — 2026-03-28 | |
| 16 | 16 |
| @@ -17,13 +17,13 @@ | |||
| 17 | 17 | main.rs # Tauri setup | |
| 18 | 18 | state.rs # AppState, scheduler, abort handles | |
| 19 | 19 | commands/ # Tauri commands (thin wrappers) | |
| 20 | - | sync_service.rs # SyncKit change tracking | |
| 20 | + | sync_service/ # SyncKit change tracking | |
| 21 | 21 | frontend/ | |
| 22 | 22 | js/ # JavaScript modules (IIFE + BB.* namespace) | |
| 23 | 23 | css/ # Styles | |
| 24 | 24 | tests/ # Command integration tests | |
| 25 | 25 | plugins/ # Bundled Rhai plugins (rss, mastodon, hackernews, etc.) | |
| 26 | - | migrations/sqlite/ # SQLx SQLite migrations | |
| 26 | + | migrations/sqlite/ # SQLite migrations | |
| 27 | 27 | ``` | |
| 28 | 28 | ||
| 29 | 29 | ### Crate Dependency Flow | |
| @@ -136,7 +136,7 @@ | |||
| 136 | 136 | } | |
| 137 | 137 | ``` | |
| 138 | 138 | ||
| 139 | - | The repositories in `bb-db` are synchronous: SQLite has no async API, and the async this layer used to wear came from sqlx. Call them directly; do not wrap them in `async fn`. | |
| 139 | + | The repositories in `bb-db` are synchronous: SQLite has no async API. Call them directly; do not wrap them in `async fn`. | |
| 140 | 140 | ||
| 141 | 141 | `ApiError` implements `From` for `bb_db::DbError`, `rusqlite::Error`, `FeedError`, and `OrchestratorError`. The frontend receives `{code: "BAD_REQUEST", message: "..."}`. | |
| 142 | 142 |
| @@ -29,7 +29,7 @@ | |||
| 29 | 29 | - **Plugin lifecycle**: load `.rhai` scripts from the plugins directory, initialize them with config from the DB, and provide fetch/shutdown operations. | |
| 30 | 30 | - **Fetch execution**: call a plugin's `fetch()`, strip tracking parameters from item URLs and HTML bodies, upsert results into the DB via the items repository, and record success/failure on the feed. | |
| 31 | 31 | - **Circuit breaker**: after 10 consecutive fetch failures (`CIRCUIT_BREAKER_THRESHOLD`), the feed is marked `circuit_broken` and excluded from auto-fetch until manually reset. | |
| 32 | - | - **Secret management**: holds an optional AES-256-GCM key. On startup, encrypts any plaintext Secret fields in existing feed configs (migration from legacy plaintext). | |
| 32 | + | - **Secret management**: holds an optional AES-256-GCM key. On startup, encrypts any plaintext Secret fields in existing feed configs. | |
| 33 | 33 | - **Fetch-all**: iterates all loaded plugins and fetches each, collecting total item counts. | |
| 34 | 34 | ||
| 35 | 35 | The orchestrator does not own the fetch scheduler or background tasks. Those are managed by `AppState` in the Tauri layer. | |
| @@ -105,7 +105,7 @@ | |||
| 105 | 105 | ||
| 106 | 106 | ## Database Layer | |
| 107 | 107 | ||
| 108 | - | SQLite via rusqlite behind an r2d2 pool (16 connections), with the 13 migrations in `migrations/sqlite/` embedded at build time. The `Database` struct holds the pool and provides typed repository accessors. The repositories are synchronous: SQLite has no async API, and the async this layer used to wear came from sqlx. | |
| 108 | + | SQLite via rusqlite behind an r2d2 pool (16 connections), with the migrations in `migrations/sqlite/` embedded at build time. The `Database` struct holds the pool and provides typed repository accessors. The repositories are synchronous: SQLite has no async API. | |
| 109 | 109 | ||
| 110 | 110 | ### Tables | |
| 111 | 111 | ||
| @@ -207,10 +207,10 @@ | |||
| 207 | 207 | | Ordering/filtering | `crates/bb-feed/src/ordering.rs` | | |
| 208 | 208 | | Database layer | `crates/bb-db/src/` | | |
| 209 | 209 | | Repositories | `crates/bb-db/src/repository/` | | |
| 210 | - | | Migrations | `migrations/sqlite/` (001-010) | | |
| 210 | + | | Migrations | `migrations/sqlite/` | | |
| 211 | 211 | | Tauri app state | `src-tauri/src/state.rs` | | |
| 212 | 212 | | Tauri commands | `src-tauri/src/commands/` | | |
| 213 | - | | Sync service | `src-tauri/src/sync_service.rs` | | |
| 213 | + | | Sync service | `src-tauri/src/sync_service/` | | |
| 214 | 214 | | Bundled plugins | `plugins/` | | |
| 215 | 215 | | Frontend JS | `src-tauri/frontend/js/` | | |
| 216 | 216 | | Frontend CSS | `src-tauri/frontend/css/` | |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Balanced Breakfast Database Schema | |
| 2 | 2 | ||
| 3 | - | SQLite schema reference. 13 migrations. Migrations live in `migrations/sqlite/` as numbered SQL files, embedded at build time and applied on boot by the runner in `crates/bb-db/src/migrate.rs`. The ledger table is still called `_sqlx_migrations`: sqlx wrote it in every install in the field, and adopting it verbatim is what lets an upgrade find nothing pending. | |
| 3 | + | SQLite schema reference. Migrations live in `migrations/sqlite/` as numbered SQL files, embedded at build time and applied on boot by the runner in `crates/bb-db/src/migrate.rs`. The ledger table is named `_sqlx_migrations`; keep that name, or an upgraded install finds every migration pending and re-applies it. | |
| 4 | 4 | ||
| 5 | 5 | ## Domain Map | |
| 6 | 6 | ||
| @@ -28,10 +28,10 @@ | |||
| 28 | 28 | | `config` | TEXT | JSON config blob, default `{}` | | |
| 29 | 29 | | `enabled` | INTEGER | Boolean, default 1 | | |
| 30 | 30 | | `last_fetch` | TEXT | ISO datetime, nullable | | |
| 31 | - | | `consecutive_failures` | INTEGER | Health tracking (migration 006), default 0 | | |
| 32 | - | | `last_error` | TEXT | Last failure message (migration 006), nullable | | |
| 33 | - | | `last_success_at` | TEXT | Last successful fetch (migration 006), nullable | | |
| 34 | - | | `circuit_broken` | INTEGER | Auto-disabled after repeated failures (migration 008), default 0 | | |
| 31 | + | | `consecutive_failures` | INTEGER | Health tracking, default 0 | | |
| 32 | + | | `last_error` | TEXT | Last failure message, nullable | | |
| 33 | + | | `last_success_at` | TEXT | Last successful fetch, nullable | | |
| 34 | + | | `circuit_broken` | INTEGER | Auto-disabled after repeated failures, default 0 | | |
| 35 | 35 | | `created_at`, `updated_at` | TEXT | ISO datetimes | | |
| 36 | 36 | ||
| 37 | 37 | Indexes: `busser_id`, `enabled`. | |
| @@ -60,7 +60,7 @@ | |||
| 60 | 60 | | `tags` | TEXT | JSON array, default `[]` | | |
| 61 | 61 | | `is_read` | INTEGER | Boolean, default 0 | | |
| 62 | 62 | | `is_starred` | INTEGER | Boolean, default 0 | | |
| 63 | - | | `actions` | TEXT | JSON array of action definitions (migration 010), default `[]` | | |
| 63 | + | | `actions` | TEXT | JSON array of action definitions, default `[]` | | |
| 64 | 64 | | `created_at`, `updated_at` | TEXT | ISO datetimes | | |
| 65 | 65 | ||
| 66 | 66 | Indexes: `feed_id`, `busser_id`, `published_at DESC`, `is_read`, `is_starred`. | |
| @@ -70,7 +70,7 @@ | |||
| 70 | 70 | ## Search | |
| 71 | 71 | ||
| 72 | 72 | ### feed_items_fts | |
| 73 | - | FTS5 virtual table in external content mode. Indexes `title`, `body`, and `bite_text` from `feed_items`. Migration 005. | |
| 73 | + | FTS5 virtual table in external content mode. Indexes `title`, `body`, and `bite_text` from `feed_items`. | |
| 74 | 74 | ||
| 75 | 75 | - External content source: `feed_items` (via `content=` and `content_rowid=`) | |
| 76 | 76 | - Kept in sync by three triggers: `feed_items_fts_insert`, `feed_items_fts_update`, `feed_items_fts_delete` | |
| @@ -81,7 +81,7 @@ | |||
| 81 | 81 | ## Organization | |
| 82 | 82 | ||
| 83 | 83 | ### feed_tags | |
| 84 | - | User-assigned flat tags on feeds for organization. Migration 004. | |
| 84 | + | User-assigned flat tags on feeds for organization. | |
| 85 | 85 | ||
| 86 | 86 | | Column | Type | Notes | | |
| 87 | 87 | |--------|------|-------| | |
| @@ -91,7 +91,7 @@ | |||
| 91 | 91 | PK: `(feed_id, tag)`. Index: `tag`. | |
| 92 | 92 | ||
| 93 | 93 | ### query_feeds | |
| 94 | - | Saved filter rules that act as virtual feed sources. Migration 009. | |
| 94 | + | Saved filter rules that act as virtual feed sources. | |
| 95 | 95 | ||
| 96 | 96 | | Column | Type | Notes | | |
| 97 | 97 | |--------|------|-------| | |
| @@ -105,7 +105,7 @@ | |||
| 105 | 105 | ## Busser State | |
| 106 | 106 | ||
| 107 | 107 | ### busser_state | |
| 108 | - | Key-value state storage for Rhai plugins (pagination cursors, auth tokens, etc.). Migration 003. | |
| 108 | + | Key-value state storage for Rhai plugins (pagination cursors, auth tokens, etc.). | |
| 109 | 109 | ||
| 110 | 110 | | Column | Type | Notes | | |
| 111 | 111 | |--------|------|-------| | |
| @@ -122,7 +122,7 @@ | |||
| 122 | 122 | ## Preferences | |
| 123 | 123 | ||
| 124 | 124 | ### user_config | |
| 125 | - | Key-value preferences store. Migration 007. | |
| 125 | + | Key-value preferences store. | |
| 126 | 126 | ||
| 127 | 127 | | Column | Type | Notes | | |
| 128 | 128 | |--------|------|-------| | |
| @@ -134,7 +134,7 @@ | |||
| 134 | 134 | ## SyncKit | |
| 135 | 135 | ||
| 136 | 136 | ### sync_state | |
| 137 | - | Sync metadata key-value store. Single-row-per-key design. Migration 007. | |
| 137 | + | Sync metadata key-value store. Single-row-per-key design. | |
| 138 | 138 | ||
| 139 | 139 | | Column | Type | Notes | | |
| 140 | 140 | |--------|------|-------| | |
| @@ -144,7 +144,7 @@ | |||
| 144 | 144 | Seeded keys: `device_id`, `pull_cursor`, `auto_sync_enabled`, `sync_interval_minutes`, `applying_remote`, `last_sync_at`, `initial_snapshot_done`. | |
| 145 | 145 | ||
| 146 | 146 | ### sync_changelog | |
| 147 | - | Local change log for push/pull sync. Records changes to synced tables for the next push. Migration 007. | |
| 147 | + | Local change log for push/pull sync. Records changes to synced tables for the next push. | |
| 148 | 148 | ||
| 149 | 149 | | Column | Type | Notes | | |
| 150 | 150 | |--------|------|-------| | |
| @@ -178,17 +178,3 @@ | |||
| 178 | 178 | - **Lookup** on feeds (busser_id, enabled), busser_state (busser_id), feed_tags (tag) | |
| 179 | 179 | - **Sync** on sync_changelog (pushed) for pending push detection | |
| 180 | 180 | ||
| 181 | - | ## Schema Evolution | |
| 182 | - | ||
| 183 | - | | Migration | Change | | |
| 184 | - | |-----------|--------| | |
| 185 | - | | 001 | feeds table (sources) | | |
| 186 | - | | 002 | feed_items table (fetched content) | | |
| 187 | - | | 003 | busser_state (plugin key-value storage) | | |
| 188 | - | | 004 | feed_tags (user-assigned tags on feeds) | | |
| 189 | - | | 005 | feed_items_fts (FTS5 full-text search) | | |
| 190 | - | | 006 | Feed health tracking (consecutive_failures, last_error, last_success_at) | | |
| 191 | - | | 007 | SyncKit integration (user_config, sync_state, sync_changelog, sync triggers) | | |
| 192 | - | | 008 | Circuit breaker (circuit_broken column, updated sync triggers) | | |
| 193 | - | | 009 | Query feeds (saved filter rules as virtual sources) | | |
| 194 | - | | 010 | Item actions (actions JSON column on feed_items) | |
| @@ -1,11 +1,9 @@ | |||
| 1 | 1 | //! Single source of truth for `user_config` keys and their sync posture. | |
| 2 | 2 | //! | |
| 3 | - | //! BalancedBreakfast used to sync every `user_config` key unconditionally: the | |
| 4 | - | //! table's triggers enqueued a changelog row for any key, and the initial | |
| 5 | - | //! snapshot swept the whole table. That is the same shape audiofiles was | |
| 6 | - | //! hardened away from (fuzz-2026-07-06 #2, -07-20 #1, -07-21 #3): a config key | |
| 7 | - | //! that names device state must never cross the sync boundary, and "sync | |
| 8 | - | //! everything" cannot hold one back. | |
| 3 | + | //! A config key that names device state must never cross the sync boundary, and | |
| 4 | + | //! a table that syncs every key unconditionally cannot hold one back. So the | |
| 5 | + | //! `user_config` triggers and the initial snapshot both filter on a declared | |
| 6 | + | //! posture rather than sweeping the table. | |
| 9 | 7 | //! | |
| 10 | 8 | //! [`CONFIG`] is the family's shared [`ConfigSpec`] for BB's `user_config` | |
| 11 | 9 | //! table, declaring each known key's [`Posture`]. It drives two filters: | |
| @@ -16,10 +14,10 @@ | |||
| 16 | 14 | //! - the Rust import filter ([`key_excluded_from_sync`]), applied when a remote | |
| 17 | 15 | //! pull batch is written back. | |
| 18 | 16 | //! | |
| 19 | - | //! Storage stays sqlx (BB's `ConfigRepository`), not `synckit_config::ConfigStore` | |
| 20 | - | //! (rusqlite): this crate takes only the pure-data half of synckit-config, the | |
| 21 | - | //! spec and posture vocabulary, so a sqlx app shares the declaration without the | |
| 22 | - | //! rusqlite store. Posture is fail-closed: an undeclared key is | |
| 17 | + | //! Storage is BB's own `ConfigRepository`, not `synckit_config::ConfigStore`: | |
| 18 | + | //! this crate takes only the pure-data half of synckit-config, the spec and | |
| 19 | + | //! posture vocabulary, so an app keeps its own store and still shares the | |
| 20 | + | //! declaration. Posture is fail-closed: an undeclared key is | |
| 23 | 21 | //! [`Local`](Posture::Local) and never syncs. | |
| 24 | 22 | //! | |
| 25 | 23 | //! <!-- wiki: bb-config --> | |
| @@ -29,16 +27,16 @@ | |||
| 29 | 27 | /// BB's `user_config` posture declaration. | |
| 30 | 28 | /// | |
| 31 | 29 | /// Only `theme` crosses the sync boundary. `bb-welcomed` is a per-device | |
| 32 | - | /// first-run flag (each device shows the welcome once). `bb-theme` is the legacy | |
| 33 | - | /// theme key read once to migrate the old value into `theme`; it must not sync | |
| 34 | - | /// on its own. Every other key the generic `set_config` command might write is | |
| 30 | + | /// first-run flag (each device shows the welcome once). `bb-theme` is an alias | |
| 31 | + | /// read once to carry its value into `theme`; it must not sync on its own. | |
| 32 | + | /// Every other key the generic `set_config` command might write is | |
| 35 | 33 | /// undeclared and therefore `Local` (fail-closed). | |
| 36 | 34 | pub const CONFIG: ConfigSpec = ConfigSpec::new( | |
| 37 | 35 | "user_config", | |
| 38 | 36 | &[ | |
| 39 | 37 | // Synced: user preferences carried across the user's devices. | |
| 40 | 38 | ("theme", Posture::Synced), | |
| 41 | - | // Local: per-device state and the legacy alias, never synced. | |
| 39 | + | // Local: per-device state and the alias, never synced. | |
| 42 | 40 | ("bb-welcomed", Posture::Local), | |
| 43 | 41 | ("bb-theme", Posture::Local), | |
| 44 | 42 | ], |
| @@ -18,9 +18,8 @@ | |||
| 18 | 18 | impl DbError { | |
| 19 | 19 | /// Whether this is rusqlite's "no rows returned" for a single-row query. | |
| 20 | 20 | /// | |
| 21 | - | /// Stands in for the `sqlx::Error::RowNotFound` that callers used to match | |
| 22 | - | /// on. Repository methods that can legitimately find nothing return | |
| 23 | - | /// `Option` instead, so this is for the callers that go around them. | |
| 21 | + | /// Repository methods that can legitimately find nothing return `Option` | |
| 22 | + | /// instead, so this is for the callers that go around them. | |
| 24 | 23 | #[must_use] | |
| 25 | 24 | pub fn is_not_found(&self) -> bool { | |
| 26 | 25 | matches!(self, Self::Sqlite(rusqlite::Error::QueryReturnedNoRows)) |
| @@ -1,8 +1,7 @@ | |||
| 1 | 1 | //! BalancedBreakfast Database Layer | |
| 2 | 2 | //! | |
| 3 | 3 | //! rusqlite behind an `r2d2` pool. The repositories are synchronous: SQLite has | |
| 4 | - | //! no async API, and the async this layer used to wear came from sqlx rather | |
| 5 | - | //! than from anything the app wanted. Async callers on a hot path should reach | |
| 4 | + | //! no async API. Async callers on a hot path should reach | |
| 6 | 5 | //! them through `tokio::task::spawn_blocking`; for a local file on a desktop, | |
| 7 | 6 | //! calling them inline is fine. | |
| 8 | 7 | ||
| @@ -32,11 +31,11 @@ | |||
| 32 | 31 | ||
| 33 | 32 | /// How long a connection waits for a write lock before giving up. | |
| 34 | 33 | /// | |
| 35 | - | /// Matches the busy timeout sqlx applied by default. Without it rusqlite fails | |
| 36 | - | /// a contended write immediately instead of waiting for the other writer. | |
| 34 | + | /// Without it rusqlite fails a contended write immediately instead of waiting | |
| 35 | + | /// for the other writer. | |
| 37 | 36 | const BUSY_TIMEOUT: Duration = Duration::from_secs(5); | |
| 38 | 37 | ||
| 39 | - | /// Pool size. Matches the sqlx pool this replaced. | |
| 38 | + | /// Pool size. | |
| 40 | 39 | const MAX_CONNECTIONS: u32 = 16; | |
| 41 | 40 | ||
| 42 | 41 | /// Where a pool's connections are opened. | |
| @@ -52,10 +51,9 @@ | |||
| 52 | 51 | /// Opens rusqlite connections for r2d2, applying the per-connection PRAGMAs. | |
| 53 | 52 | /// | |
| 54 | 53 | /// Every PRAGMA here is per-connection rather than per-database, so it runs on | |
| 55 | - | /// each connection the pool creates. `journal_mode` is the exception -- WAL is | |
| 56 | - | /// persistent in the file header -- but setting it repeatedly is harmless and | |
| 57 | - | /// keeps in-memory databases correct too. Both PRAGMAs match what sqlx applied | |
| 58 | - | /// by default, so an existing database sees no change of behaviour. | |
| 54 | + | /// each connection the pool creates. `journal_mode` is the exception (WAL is | |
| 55 | + | /// persistent in the file header), but setting it repeatedly is harmless and | |
| 56 | + | /// keeps in-memory databases correct too. | |
| 59 | 57 | #[derive(Debug, Clone)] | |
| 60 | 58 | pub struct SqliteConnectionManager { | |
| 61 | 59 | source: Source, |