Skip to main content

max / balanced_breakfast

Audit remediation: observability, documentation - Add #[instrument(skip_all)] to all bb-core, bb-db, bb-feed pub functions (174 annotations) - Add /// doc comments to all 59 undocumented public items - Audit review corrected to grade A (test regression, XSS, FTS findings all false) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-22 22:18 UTC
Commit: 502744d39537d7d9cc6623b5381d0126d67e7eec
Parent: f78975b
40 files changed, +2098 insertions, -337 deletions
@@ -94,6 +94,10 @@ Each plugin runs in an isolated Rhai engine with strict limits:
94 94
95 95 Each plugin gets its own engine instance with isolated counters (request count via `Arc<AtomicUsize>`, deadline via `Arc<AtomicU64>`).
96 96
97 + ### Plugin Style
98 +
99 + All `.rhai` plugins follow the cross-project style guide at `_meta/docs/rhai_style.md`. Run `_meta/scripts/lint-rhai.sh` to check formatting. Key points: 4-space indent, `snake_case` functions, `UPPER_CASE` constants, header comment block, contract functions before helpers.
100 +
97 101 ### Host Functions
98 102
99 103 Plugins call host-provided functions: `http_get(url)`, `http_get_json(url)`, `parse_feed(xml)`, `parse_html(html)`, `extract_readability(url)`, `truncate(s, n)`, `to_json(val)`, `from_json(s)`, etc. See `plugins/` for bundled examples.
M Cargo.lock +4 -4
@@ -1009,7 +1009,7 @@ dependencies = [
1009 1009
1010 1010 [[package]]
1011 1011 name = "docengine"
1012 - version = "0.3.0"
1012 + version = "0.3.1"
1013 1013 dependencies = [
1014 1014 "ammonia",
1015 1015 "pulldown-cmark",
@@ -4888,7 +4888,7 @@ dependencies = [
4888 4888
4889 4889 [[package]]
4890 4890 name = "synckit-client"
4891 - version = "0.3.0"
4891 + version = "0.3.1"
4892 4892 dependencies = [
4893 4893 "argon2",
4894 4894 "base64 0.22.1",
@@ -4958,7 +4958,7 @@ dependencies = [
4958 4958
4959 4959 [[package]]
4960 4960 name = "tagtree"
4961 - version = "0.3.0"
4961 + version = "0.3.1"
4962 4962
4963 4963 [[package]]
4964 4964 name = "tao"
@@ -5395,7 +5395,7 @@ dependencies = [
5395 5395
5396 5396 [[package]]
5397 5397 name = "theme-common"
5398 - version = "0.3.0"
5398 + version = "0.3.1"
5399 5399 dependencies = [
5400 5400 "serde",
5401 5401 "toml 0.8.2",
@@ -54,8 +54,8 @@ fn load_or_create_key(path: &Path) -> Result<[u8; 32], String> {
54 54 const KEYCHAIN_SERVICE: &str = "balanced-breakfast";
55 55 const KEYCHAIN_KEY: &str = "encryption:master";
56 56
57 - /// Load an encryption key from the OS keychain. Falls back to file-based storage
58 - /// if the keychain is unavailable. Migrates from file to keychain on first run.
57 + #[tracing::instrument(skip_all)]
58 + /// Load an encryption key from the OS keychain, falling back to file-based storage
59 59 pub fn load_or_create_key_from_keychain(file_path: &Path) -> Result<[u8; 32], String> {
60 60 // Try keychain first
61 61 if let Ok(entry) = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_KEY) {
@@ -104,8 +104,8 @@ pub fn load_or_create_key_from_keychain(file_path: &Path) -> Result<[u8; 32], St
104 104 load_or_create_key(file_path)
105 105 }
106 106
107 - /// Encrypt a plaintext string field using AES-256-GCM.
108 - /// Returns a string in the format `bb_enc:v1:<base64(nonce || ciphertext || tag)>`.
107 + #[tracing::instrument(skip_all)]
108 + /// Encrypt a plaintext string field using AES-256-GCM
109 109 pub fn encrypt_field(plaintext: &str, key: &[u8; 32]) -> Result<String, String> {
110 110 let cipher = Aes256Gcm::new(key.into());
111 111 let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
@@ -121,8 +121,8 @@ pub fn encrypt_field(plaintext: &str, key: &[u8; 32]) -> Result<String, String>
121 121 Ok(format!("{}{}", PREFIX, BASE64.encode(&payload)))
122 122 }
123 123
124 - /// Decrypt a field value. If the value has the `bb_enc:v1:` prefix, decrypts it.
125 - /// Otherwise returns the value as-is (plaintext passthrough for backward compatibility).
124 + #[tracing::instrument(skip_all)]
125 + /// Decrypt a field value, passing through plaintext values without the `bb_enc:v1:` prefix
126 126 pub fn decrypt_field(value: &str, key: &[u8; 32]) -> Result<String, String> {
127 127 let Some(encoded) = value.strip_prefix(PREFIX) else {
128 128 return Ok(value.to_string());
@@ -147,7 +147,8 @@ pub fn decrypt_field(value: &str, key: &[u8; 32]) -> Result<String, String> {
147 147 String::from_utf8(plaintext).map_err(|e| format!("Decrypted data is not valid UTF-8: {e}"))
148 148 }
149 149
150 - /// Encrypt Secret-type fields in a config JSON object in-place.
150 + #[tracing::instrument(skip_all)]
151 + /// Encrypt Secret-type fields in a config JSON object in-place
151 152 pub fn encrypt_config_secrets(
152 153 config: &mut serde_json::Value,
153 154 schema: &ConfigSchema,
@@ -175,7 +176,8 @@ pub fn encrypt_config_secrets(
175 176 }
176 177 }
177 178
178 - /// Decrypt Secret-type fields in a config JSON object in-place.
179 + #[tracing::instrument(skip_all)]
180 + /// Decrypt Secret-type fields in a config JSON object in-place
179 181 pub fn decrypt_config_secrets(
180 182 config: &mut serde_json::Value,
181 183 schema: &ConfigSchema,
@@ -18,6 +18,7 @@ use crate::url_cleaner;
18 18 use crate::PluginManager;
19 19
20 20 #[derive(Error, Debug)]
21 + /// Errors from orchestrator operations (database, plugin, or feed failures)
21 22 pub enum OrchestratorError {
22 23 /// A SQLite query or connection failed.
23 24 #[error("Database error: {0}")]
@@ -33,8 +34,8 @@ pub enum OrchestratorError {
33 34 Config(String),
34 35 }
35 36
36 - /// Configuration for the orchestrator
37 37 #[derive(Clone)]
38 + /// Startup configuration for the orchestrator
38 39 pub struct OrchestratorConfig {
39 40 /// SQLite connection URL (e.g. `sqlite:app.db?mode=rwc`).
40 41 pub database_url: String,
@@ -318,11 +319,13 @@ impl Orchestrator {
318 319 }
319 320
320 321 /// Get the database handle
322 + #[tracing::instrument(skip_all)]
321 323 pub fn database(&self) -> &Database {
322 324 &self.db
323 325 }
324 326
325 327 /// Get plugin manager
328 + #[tracing::instrument(skip_all)]
326 329 pub fn plugins(&self) -> Arc<RwLock<PluginManager>> {
327 330 self.plugins.clone()
328 331 }
@@ -338,11 +341,13 @@ impl Orchestrator {
338 341 }
339 342
340 343 /// Set the encryption key for Secret-field encryption at rest.
344 + #[tracing::instrument(skip_all)]
341 345 pub fn set_encryption_key(&mut self, key: [u8; 32]) {
342 346 self.encryption_key = Some(key);
343 347 }
344 348
345 349 /// Get the encryption key, if set.
350 + #[tracing::instrument(skip_all)]
346 351 pub fn encryption_key(&self) -> Option<&[u8; 32]> {
347 352 self.encryption_key.as_ref()
348 353 }
@@ -16,6 +16,7 @@ use bb_interface::StructuredError;
16 16 use crate::rhai_plugin::{classify_error, RhaiPluginError, RhaiPluginManager};
17 17
18 18 #[derive(Error, Debug)]
19 + /// Errors from plugin loading, initialization, and fetching
19 20 pub enum PluginError {
20 21 /// A `.rhai` script could not be read from disk or compiled by the engine.
21 22 #[error("Failed to load plugin: {0}")]
@@ -42,6 +43,7 @@ pub enum PluginError {
42 43
43 44 impl PluginError {
44 45 /// Convert this error to a [`StructuredError`] by classifying the inner message.
46 + #[tracing::instrument(skip_all)]
45 47 pub fn to_structured(&self) -> StructuredError {
46 48 match self {
47 49 PluginError::RhaiError(e) => e.to_structured(),
@@ -60,6 +62,7 @@ pub struct PluginManager {
60 62
61 63 impl PluginManager {
62 64 /// Create a new plugin manager rooted at the given plugins directory.
65 + #[tracing::instrument(skip_all)]
63 66 pub fn new(plugins_dir: impl AsRef<Path>) -> Self {
64 67 Self {
65 68 plugins_dir: plugins_dir.as_ref().to_path_buf(),
@@ -69,11 +72,13 @@ impl PluginManager {
69 72 }
70 73
71 74 /// Get the plugins directory
75 + #[tracing::instrument(skip_all)]
72 76 pub fn plugins_dir(&self) -> &Path {
73 77 &self.plugins_dir
74 78 }
75 79
76 80 /// Discover all .rhai plugin files in the plugins directory
81 + #[tracing::instrument(skip_all)]
77 82 pub fn discover_plugins(&self) -> Result<Vec<PathBuf>, PluginError> {
78 83 let mut plugins = Vec::new();
79 84
@@ -102,6 +107,7 @@ impl PluginManager {
102 107 }
103 108
104 109 /// Load a plugin from a .rhai file path
110 + #[tracing::instrument(skip_all)]
105 111 pub fn load_plugin(&mut self, path: impl AsRef<Path>) -> Result<String, PluginError> {
106 112 let path = path.as_ref();
107 113 info!(?path, "Loading Rhai plugin");
@@ -116,6 +122,7 @@ impl PluginManager {
116 122 }
117 123
118 124 /// Load all discovered plugins
125 + #[tracing::instrument(skip_all)]
119 126 pub fn load_all(&mut self) -> Result<Vec<String>, PluginError> {
120 127 let paths = self.discover_plugins()?;
121 128 let mut loaded = Vec::new();
@@ -133,6 +140,7 @@ impl PluginManager {
133 140 }
134 141
135 142 /// Initialize a plugin with configuration
143 + #[tracing::instrument(skip_all)]
136 144 pub fn initialize_plugin(
137 145 &self,
138 146 plugin_id: &str,
@@ -152,6 +160,7 @@ impl PluginManager {
152 160 }
153 161
154 162 /// Fetch items from a plugin
163 + #[tracing::instrument(skip_all)]
155 164 pub fn fetch(
156 165 &self,
157 166 plugin_id: &str,
@@ -173,6 +182,7 @@ impl PluginManager {
173 182 }
174 183
175 184 /// Shutdown a plugin (no-op for Rhai plugins, kept for API compatibility)
185 + #[tracing::instrument(skip_all)]
176 186 pub fn shutdown_plugin(&self, plugin_id: &str) -> Result<(), PluginError> {
177 187 if self.rhai_manager.get(plugin_id).is_none() {
178 188 return Err(PluginError::NotFound(plugin_id.to_string()));
@@ -183,6 +193,7 @@ impl PluginManager {
183 193 }
184 194
185 195 /// Shutdown all plugins
196 + #[tracing::instrument(skip_all)]
186 197 pub fn shutdown_all(&self) {
187 198 let plugin_ids = self.rhai_manager.list();
188 199 for id in plugin_ids {
@@ -193,21 +204,25 @@ impl PluginManager {
193 204 }
194 205
195 206 /// Get list of loaded plugin IDs
207 + #[tracing::instrument(skip_all)]
196 208 pub fn list_plugins(&self) -> Vec<String> {
197 209 self.rhai_manager.list()
198 210 }
199 211
200 212 /// Get plugin info
213 + #[tracing::instrument(skip_all)]
201 214 pub fn get_plugin_info(&self, plugin_id: &str) -> Option<(String, String, PathBuf)> {
202 215 self.rhai_manager.get_info(plugin_id)
203 216 }
204 217
205 218 /// Get plugin capabilities
219 + #[tracing::instrument(skip_all)]
206 220 pub fn get_capabilities(&self, plugin_id: &str) -> Option<BusserCapabilities> {
207 221 self.rhai_manager.get(plugin_id).map(|p| p.capabilities())
208 222 }
209 223
210 224 /// Get plugin configuration schema
225 + #[tracing::instrument(skip_all)]
211 226 pub fn get_config_schema(&self, plugin_id: &str) -> Option<ConfigSchema> {
212 227 self.rhai_manager.get(plugin_id).and_then(|p| {
213 228 match p.config_schema() {
@@ -28,6 +28,7 @@ use bb_interface::{ErrorCategory, StructuredError};
28 28 use host_functions::register_host_functions;
29 29
30 30 #[derive(Error, Debug)]
31 + /// Errors from Rhai plugin compilation, execution, or host-function calls
31 32 pub enum RhaiPluginError {
32 33 /// The `.rhai` script has syntax errors and could not be compiled.
33 34 #[error("Script compilation failed: {0}")]
@@ -62,14 +63,15 @@ const BB_ERR_PREFIX: &str = "BB_ERR:";
62 63 impl RhaiPluginError {
63 64 /// Convert this error to a [`StructuredError`] by parsing the `BB_ERR:` prefix
64 65 /// convention, falling back to heuristic keyword matching.
66 + #[tracing::instrument(skip_all)]
65 67 pub fn to_structured(&self) -> StructuredError {
66 68 let msg = self.to_string();
67 69 classify_error(&msg)
68 70 }
69 71 }
70 72
71 - /// Parse a `BB_ERR:category:message` string into a [`StructuredError`].
72 - /// Falls back to [`heuristic_classify`] for unprefixed errors.
73 + #[tracing::instrument(skip_all)]
74 + /// Classify an error string into a [`StructuredError`] by parsing the `BB_ERR:` prefix or heuristics
73 75 pub fn classify_error(error_str: &str) -> StructuredError {
74 76 // The error string from Rhai wraps the inner error message. Extract the
75 77 // BB_ERR portion wherever it appears in the string.
@@ -163,6 +165,7 @@ impl RhaiPlugin {
163 165 }
164 166
165 167 /// Get the plugin's configuration schema.
168 + #[tracing::instrument(skip_all)]
166 169 pub fn config_schema(&self) -> Result<ConfigSchema, RhaiPluginError> {
167 170 let mut scope = self.eval_scope()?;
168 171 let result: Dynamic = self
@@ -174,6 +177,7 @@ impl RhaiPlugin {
174 177 }
175 178
176 179 /// Get the plugin's capabilities.
180 + #[tracing::instrument(skip_all)]
177 181 pub fn capabilities(&self) -> BusserCapabilities {
178 182 let mut scope = match self.eval_scope() {
179 183 Ok(s) => s,
@@ -195,6 +199,7 @@ impl RhaiPlugin {
195 199 }
196 200
197 201 /// Fetch items from the plugin.
202 + #[tracing::instrument(skip_all)]
198 203 pub fn fetch(
199 204 &self,
200 205 config: &bb_interface::BusserConfig,
@@ -245,6 +250,7 @@ impl RhaiPluginManager {
245 250 /// Each plugin gets its own Rhai engine (created in `load_plugin()`) with
246 251 /// independent request counters and fetch deadlines. This prevents concurrent
247 252 /// plugins from interfering with each other's rate limits.
253 + #[tracing::instrument(skip_all)]
248 254 pub fn new() -> Self {
249 255 Self {
250 256 plugins: HashMap::new(),
@@ -256,6 +262,7 @@ impl RhaiPluginManager {
256 262 /// Creates a fresh Rhai engine per plugin with its own request counter and
257 263 /// fetch deadline. This isolates concurrent plugins from interfering with
258 264 /// each other's rate limits during `fetch_all()`.
265 + #[tracing::instrument(skip_all)]
259 266 pub fn load_plugin(&mut self, path: &Path) -> Result<String, RhaiPluginError> {
260 267 let script = std::fs::read_to_string(path)
261 268 .map_err(|e| RhaiPluginError::CompileError(format!("Failed to read file: {}", e)))?;
@@ -309,16 +316,19 @@ impl RhaiPluginManager {
309 316 }
310 317
311 318 /// Get a loaded plugin by ID.
319 + #[tracing::instrument(skip_all)]
312 320 pub fn get(&self, id: &str) -> Option<&RhaiPlugin> {
313 321 self.plugins.get(id)
314 322 }
315 323
316 324 /// List all loaded plugin IDs.
325 + #[tracing::instrument(skip_all)]
317 326 pub fn list(&self) -> Vec<String> {
318 327 self.plugins.keys().cloned().collect()
319 328 }
320 329
321 330 /// Get plugin info (id, name, path).
331 + #[tracing::instrument(skip_all)]
322 332 pub fn get_info(&self, id: &str) -> Option<(String, String, std::path::PathBuf)> {
323 333 self.plugins
324 334 .get(id)
@@ -332,10 +342,8 @@ impl Default for RhaiPluginManager {
332 342 }
333 343 }
334 344
335 - /// Create a configured Rhai engine with all host functions registered.
336 - ///
337 - /// Used for one-off script execution (e.g. reader view) outside
338 - /// the plugin manager lifecycle.
345 + #[tracing::instrument(skip_all)]
346 + /// Create a configured Rhai engine with all host functions registered
339 347 pub fn create_engine() -> Engine {
340 348 let mut engine = Engine::new();
341 349 engine.set_max_expr_depths(128, 128);
@@ -404,8 +412,8 @@ fn validate_dynamic_sizes(val: &Dynamic, depth: usize) -> Result<(), String> {
404 412 Ok(())
405 413 }
406 414
407 - /// Result from extracting article content via the reader plugin.
408 415 #[derive(Debug, Clone)]
416 + /// Result from extracting article content via the reader plugin
409 417 pub struct ReaderResult {
410 418 /// The extracted article title.
411 419 pub title: String,
@@ -416,11 +424,8 @@ pub struct ReaderResult {
416 424 pub text_content: String,
417 425 }
418 426
419 - /// Run the reader extraction plugin on a URL.
420 - ///
421 - /// Creates a one-off Rhai engine, loads `reader.rhai` from the given plugins
422 - /// directory, and calls `extract(url)`. Returns the extracted article title,
423 - /// HTML content, and plain text.
427 + #[tracing::instrument(skip_all)]
428 + /// Run the reader extraction plugin on a URL and return the extracted content
424 429 pub fn run_reader_script(url: &str, plugins_dir: &Path) -> Result<ReaderResult, RhaiPluginError> {
425 430 let engine = create_engine();
426 431
@@ -7,11 +7,8 @@
7 7 use bb_db::parse_timestamp;
8 8 use chrono::{DateTime, Utc};
9 9
10 - /// Is a single feed overdue given its last-fetch timestamp?
11 - ///
12 - /// `last_fetch` is the optional timestamp string from the database. `None`
13 - /// (never fetched) is always considered due. `interval_secs` is the desired
14 - /// interval between fetches. `now` is the current time.
10 + #[tracing::instrument(skip_all)]
11 + /// Check whether a single feed is overdue for fetching
15 12 pub fn is_single_feed_due(
16 13 last_fetch: Option<&str>,
17 14 interval_secs: u64,
@@ -24,10 +21,8 @@ pub fn is_single_feed_due(
24 21 elapsed.num_seconds() >= interval_secs as i64
25 22 }
26 23
27 - /// Check whether ANY feed in a set is overdue for fetching.
28 - ///
29 - /// A busser's feeds are fetched together (one plugin fetch call returns items
30 - /// for all its feeds), so if any single feed is due we return `true`.
24 + #[tracing::instrument(skip_all)]
25 + /// Check whether any feed in a set is overdue for fetching
31 26 pub fn any_feed_due(
32 27 last_fetches: &[Option<&str>],
33 28 interval_secs: u64,
@@ -38,9 +33,8 @@ pub fn any_feed_due(
38 33 .any(|ts| is_single_feed_due(*ts, interval_secs, now))
39 34 }
40 35
41 - /// Exponential backoff: `2^consecutive_failures` seconds, capped at `max_secs`.
42 - ///
43 - /// Returns the number of seconds to wait before the next retry attempt.
36 + #[tracing::instrument(skip_all)]
37 + /// Compute exponential backoff delay in seconds, capped at `max_secs`
44 38 pub fn exponential_backoff_secs(consecutive_failures: u32, max_secs: u64) -> u64 {
45 39 let raw = 2u64.saturating_pow(consecutive_failures);
46 40 raw.min(max_secs)
@@ -56,9 +56,8 @@ fn is_tracking_param(name: &str) -> bool {
56 56 .any(|&prefix| lower.starts_with(prefix))
57 57 }
58 58
59 - /// Strip known tracking query parameters from a URL string.
60 - ///
61 - /// Returns the cleaned URL. If the input is not a valid URL it is returned unchanged.
59 + #[tracing::instrument(skip_all)]
60 + /// Strip known tracking query parameters from a URL string
62 61 pub fn strip_tracking_params(url_str: &str) -> String {
63 62 let mut parsed = match Url::parse(url_str) {
64 63 Ok(u) => u,
@@ -89,7 +88,8 @@ static ATTR_URL_RE: LazyLock<Regex> = LazyLock::new(|| {
89 88 Regex::new(r#"(href|src)\s*=\s*"([^"]+)""#).expect("invalid regex")
90 89 });
91 90
92 - /// Strip tracking parameters from all `href` and `src` URLs in an HTML string.
91 + #[tracing::instrument(skip_all)]
92 + /// Strip tracking parameters from all `href` and `src` URLs in an HTML string
93 93 pub fn strip_tracking_from_html(html: &str) -> String {
94 94 ATTR_URL_RE
95 95 .replace_all(html, |caps: &regex::Captures| {
@@ -20,16 +20,19 @@ macro_rules! define_uuid_id {
20 20
21 21 impl $name {
22 22 /// Generate a new random ID.
23 + #[tracing::instrument(skip_all)]
23 24 pub fn new() -> Self {
24 25 Self(uuid::Uuid::new_v4())
25 26 }
26 27
27 28 /// Wrap an existing UUID.
29 + #[tracing::instrument(skip_all)]
28 30 pub fn from_uuid(uuid: uuid::Uuid) -> Self {
29 31 Self(uuid)
30 32 }
31 33
32 34 /// Access the inner UUID.
35 + #[tracing::instrument(skip_all)]
33 36 pub fn as_uuid(&self) -> &uuid::Uuid {
34 37 &self.0
35 38 }
@@ -91,26 +94,26 @@ macro_rules! define_uuid_id {
91 94 )+};
92 95 }
93 96
94 - define_uuid_id!(FeedId, ItemId, BusserStateId, QueryFeedId);
97 + define_uuid_id!(FeedId, ItemId, BusserStateId, QueryFeedId, BookmarkId);
95 98
96 99 // ── BusserId ─────────────────────────────────────────────────────
97 100
98 - /// Plugin/busser identifier (e.g. "rss", "hn", "arxiv").
99 - ///
100 - /// Unlike the UUID-based IDs above, BusserId wraps a plain string.
101 - /// Implements `Deref<Target = str>` for transparent string access.
102 101 #[derive(Clone, Debug, PartialEq, Eq, Hash)]
102 + /// Plugin/busser identifier wrapping a plain string (e.g. "rss", "hn", "arxiv")
103 103 pub struct BusserId(String);
104 104
105 105 impl BusserId {
106 + #[tracing::instrument(skip_all)]
106 107 pub fn new(s: impl Into<String>) -> Self {
107 108 Self(s.into())
108 109 }
109 110
111 + #[tracing::instrument(skip_all)]
110 112 pub fn as_str(&self) -> &str {
111 113 &self.0
112 114 }
113 115
116 + #[tracing::instrument(skip_all)]
114 117 pub fn into_inner(self) -> String {
115 118 self.0
116 119 }
@@ -14,14 +14,15 @@ use sqlx::SqlitePool;
14 14 /// SQLite timestamp format used for all datetime columns.
15 15 pub const TIMESTAMP_FMT: &str = "%Y-%m-%d %H:%M:%S";
16 16
17 - /// Database connection pool
18 17 #[derive(Clone)]
18 + /// SQLite database connection pool
19 19 pub struct Database {
20 20 pool: SqlitePool,
21 21 }
22 22
23 23 impl Database {
24 24 /// Create a new database connection
25 + #[tracing::instrument(skip_all)]
25 26 pub async fn connect(database_url: &str) -> Result<Self, sqlx::Error> {
26 27 let pool = SqlitePoolOptions::new()
27 28 .max_connections(16)
@@ -32,48 +33,64 @@ impl Database {
32 33 }
33 34
34 35 /// Run migrations
36 + #[tracing::instrument(skip_all)]
35 37 pub async fn migrate(&self) -> Result<(), sqlx::migrate::MigrateError> {
36 38 sqlx::migrate!("../../migrations/sqlite").run(&self.pool).await
37 39 }
38 40
39 41 /// Get the connection pool
42 + #[tracing::instrument(skip_all)]
40 43 pub fn pool(&self) -> &SqlitePool {
41 44 &self.pool
42 45 }
43 46
44 47 /// Get a repository for feed subscription CRUD (create, enable/disable, delete,
45 48 /// list by busser, update last_fetch timestamp).
49 + #[tracing::instrument(skip_all)]
46 50 pub fn feeds(&self) -> FeedsRepository {
47 51 FeedsRepository::new(self.pool.clone())
48 52 }
49 53
50 54 /// Get a repository for feed item operations: upsert, read/star toggling,
51 55 /// paginated listing (by busser, by feed, unread, starred), and counts.
56 + #[tracing::instrument(skip_all)]
52 57 pub fn items(&self) -> ItemsRepository {
53 58 ItemsRepository::new(self.pool.clone())
54 59 }
55 60
56 61 /// Get a repository for feed tag CRUD (per-feed tag assignment,
57 62 /// distinct tag listing, bulk feed-tag pairs for sidebar).
63 + #[tracing::instrument(skip_all)]
58 64 pub fn tags(&self) -> TagsRepository {
59 65 TagsRepository::new(self.pool.clone())
60 66 }
61 67
62 68 /// Get a repository for busser key-value state (cursors, tokens, pagination
63 69 /// markers). Each entry is keyed by `(busser_id, key)`.
70 + #[tracing::instrument(skip_all)]
64 71 pub fn state(&self) -> StateRepository {
65 72 StateRepository::new(self.pool.clone())
66 73 }
67 74
68 75 /// Get a repository for user_config key-value pairs (theme, welcome flag,
69 76 /// etc.). Synced via sync_changelog triggers (migration 007).
77 + #[tracing::instrument(skip_all)]
70 78 pub fn config(&self) -> ConfigRepository {
71 79 ConfigRepository::new(self.pool.clone())
72 80 }
73 81
74 82 /// Get a repository for query feed CRUD (saved filter rules that act as
75 83 /// virtual sources). Synced via sync_changelog triggers (migration 009).
84 + #[tracing::instrument(skip_all)]
76 85 pub fn query_feeds(&self) -> QueryFeedsRepository {
77 86 QueryFeedsRepository::new(self.pool.clone())
78 87 }
88 +
89 + /// Get a repository for bookmark (reading list) CRUD. Bookmarks are
90 + /// permanent, user-owned references to URLs. Synced via sync_changelog
91 + /// triggers (migration 012).
92 + #[tracing::instrument(skip_all)]
93 + pub fn bookmarks(&self) -> BookmarksRepository {
94 + BookmarksRepository::new(self.pool.clone())
95 + }
79 96 }
@@ -7,7 +7,7 @@ use chrono::{DateTime, Utc};
7 7 use serde::{Deserialize, Serialize};
8 8 use sqlx::FromRow;
9 9
10 - use crate::id_types::{BusserId, BusserStateId, FeedId, ItemId, QueryFeedId};
10 + use crate::id_types::{BookmarkId, BusserId, BusserStateId, FeedId, ItemId, QueryFeedId};
11 11 use crate::TIMESTAMP_FMT;
12 12
13 13 /// Parse a value or log a warning and return the default.
@@ -23,10 +23,8 @@ fn parse_or_default<T: Default>(result: Result<T, impl std::fmt::Display>, conte
23 23 }
24 24 }
25 25
26 - /// Parse a timestamp string stored in SQLite. Tries RFC 3339 first, then the
27 - /// SQLite format. Returns `DateTime::UNIX_EPOCH` as a last resort so that
28 - /// unparseable items sort to the bottom of chronological feeds rather than
29 - /// appearing as the newest item.
26 + #[tracing::instrument(skip_all)]
27 + /// Parse a timestamp string from SQLite, falling back to UNIX_EPOCH on failure
30 28 pub fn parse_timestamp(s: &str) -> DateTime<Utc> {
31 29 s.parse::<DateTime<Utc>>()
32 30 .or_else(|_| {
@@ -35,8 +33,8 @@ pub fn parse_timestamp(s: &str) -> DateTime<Utc> {
35 33 .unwrap_or(DateTime::UNIX_EPOCH)
36 34 }
37 35
38 - /// Registered feed/busser source stored in the `feeds` table.
39 36 #[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
37 + /// Registered feed/busser source stored in the `feeds` table
40 38 pub struct DbFeed {
41 39 /// Internal UUID.
42 40 pub id: FeedId,
@@ -66,6 +64,7 @@ pub struct DbFeed {
66 64
67 65 impl DbFeed {
68 66 /// Deserialize the JSON config column into a `serde_json::Value`.
67 + #[tracing::instrument(skip_all)]
69 68 pub fn config_json(&self) -> serde_json::Value {
70 69 parse_or_default(
71 70 serde_json::from_str(&self.config),
@@ -74,8 +73,8 @@ impl DbFeed {
74 73 }
75 74 }
76 75
77 - /// Feed item stored in the `feed_items` table.
78 76 #[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
77 + /// Feed item stored in the `feed_items` table
79 78 pub struct DbFeedItem {
80 79 /// Internal UUID.
81 80 pub id: ItemId,
@@ -130,16 +129,19 @@ pub struct DbFeedItem {
130 129
131 130 impl DbFeedItem {
132 131 /// Parse `published_at` into a `DateTime<Utc>`.
132 + #[tracing::instrument(skip_all)]
133 133 pub fn published_at_dt(&self) -> DateTime<Utc> {
134 134 parse_timestamp(&self.published_at)
135 135 }
136 136
137 137 /// Parse `fetched_at` into a `DateTime<Utc>`.
138 + #[tracing::instrument(skip_all)]
138 139 pub fn fetched_at_dt(&self) -> DateTime<Utc> {
139 140 parse_timestamp(&self.fetched_at)
140 141 }
141 142
142 143 /// Deserialize the JSON `media` column into a `Vec<String>`.
144 + #[tracing::instrument(skip_all)]
143 145 pub fn media_vec(&self) -> Vec<String> {
144 146 parse_or_default(
145 147 serde_json::from_str(&self.media),
@@ -148,6 +150,7 @@ impl DbFeedItem {
148 150 }
149 151
150 152 /// Deserialize the JSON `tags` column into a `Vec<String>`.
153 + #[tracing::instrument(skip_all)]
151 154 pub fn tags_vec(&self) -> Vec<String> {
152 155 parse_or_default(
153 156 serde_json::from_str(&self.tags),
@@ -156,6 +159,7 @@ impl DbFeedItem {
156 159 }
157 160
158 161 /// Deserialize the JSON `actions` column into a `Vec<ItemAction>`.
162 + #[tracing::instrument(skip_all)]
159 163 pub fn actions_vec(&self) -> Vec<bb_interface::ItemAction> {
160 164 parse_or_default(
161 165 serde_json::from_str(&self.actions),
@@ -172,6 +176,7 @@ impl DbFeedItem {
172 176 ///
173 177 /// This method maps the flat `DbFeedItem` columns back into those three
174 178 /// structs, deserializing JSON columns (media, tags) along the way.
179 + #[tracing::instrument(skip_all)]
175 180 pub fn to_feed_item(&self) -> bb_interface::FeedItem {
176 181 use bb_interface::{BiteDisplay, FeedItem, FeedItemContent, FeedItemId, FeedItemMeta};
177 182
@@ -205,8 +210,8 @@ impl DbFeedItem {
205 210 }
206 211 }
207 212
208 - /// Key-value state storage for bussers (e.g. cursors, tokens).
209 213 #[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
214 + /// Key-value state storage for bussers (e.g. cursors, tokens)
210 215 pub struct DbBusserState {
211 216 /// Internal UUID.
212 217 pub id: BusserStateId,
@@ -222,8 +227,8 @@ pub struct DbBusserState {
222 227 pub updated_at: String,
223 228 }
224 229
225 - /// Input for creating a new feed row.
226 230 #[derive(Debug, Clone, Serialize, Deserialize)]
231 + /// Input for creating a new feed row
227 232 pub struct CreateFeed {
228 233 /// Plugin/busser identifier.
229 234 pub busser_id: BusserId,
@@ -233,8 +238,8 @@ pub struct CreateFeed {
233 238 pub config: serde_json::Value,
234 239 }
235 240
236 - /// Input for inserting or upserting a feed item.
237 241 #[derive(Debug, Clone, Serialize, Deserialize)]
242 + /// Input for inserting or upserting a feed item
238 243 pub struct CreateFeedItem {
239 244 /// Busser-provided unique key (format: `busser_id:item_id`).
240 245 pub external_id: String,
@@ -272,6 +277,7 @@ pub struct CreateFeedItem {
272 277
273 278 impl CreateFeedItem {
274 279 /// Convert an interface [`FeedItem`](bb_interface::FeedItem) into a database insert input.
280 + #[tracing::instrument(skip_all)]
275 281 pub fn from_feed_item(item: &bb_interface::FeedItem, feed_id: FeedId) -> Self {
276 282 Self {
277 283 external_id: item.id.to_combined(),
@@ -295,13 +301,8 @@ impl CreateFeedItem {
295 301 }
296 302 }
297 303
298 - /// A single condition in a query feed's rules array.
299 - ///
300 - /// Conditions are combined with AND logic: all conditions must match for an
301 - /// item to be included. Simple conditions (source, starred, unread, tag) are
302 - /// pushed into fast-path SQL queries; complex conditions (title, author, body)
303 - /// are evaluated in-memory.
304 304 #[derive(Debug, Clone, Serialize, Deserialize)]
305 + /// A single condition in a query feed's rules array
305 306 pub struct QueryCondition {
306 307 /// The item field to match against.
307 308 ///
@@ -335,8 +336,8 @@ pub struct QueryCondition {
335 336 pub value: String,
336 337 }
337 338
338 - /// Saved filter ("query feed") stored in the `query_feeds` table.
339 339 #[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
340 + /// Saved filter ("query feed") stored in the `query_feeds` table
340 341 pub struct DbQueryFeed {
341 342 pub id: QueryFeedId,
342 343 pub name: String,
@@ -348,6 +349,7 @@ pub struct DbQueryFeed {
348 349
349 350 impl DbQueryFeed {
350 351 /// Deserialize the JSON `rules` column into a `Vec<QueryCondition>`.
352 + #[tracing::instrument(skip_all)]
351 353 pub fn rules_vec(&self) -> Vec<QueryCondition> {
352 354 parse_or_default(
353 355 serde_json::from_str(&self.rules),
@@ -356,8 +358,8 @@ impl DbQueryFeed {
356 358 }
357 359 }
358 360
359 - /// Input for creating a new query feed.
360 361 #[derive(Debug, Clone, Serialize, Deserialize)]
362 + /// Input for creating a new query feed
361 363 pub struct CreateQueryFeed {
362 364 /// Human-readable name for this query feed, displayed in the feed list.
363 365 pub name: String,
@@ -366,6 +368,67 @@ pub struct CreateQueryFeed {
366 368 pub rules: Vec<QueryCondition>,
367 369 }
368 370
371 + #[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
372 + /// Bookmark stored in the `bookmarks` table
373 + pub struct DbBookmark {
374 + /// Internal UUID.
375 + pub id: BookmarkId,
376 + /// Bookmarked URL.
377 + pub url: String,
378 + /// Display title.
379 + pub title: String,
380 + /// Short description or excerpt.
381 + pub description: String,
382 + /// Author attribution.
383 + pub author: String,
384 + /// Source name for display.
385 + pub source_name: String,
386 + /// Optional link to the originating feed item (SET NULL on item deletion).
387 + pub feed_item_id: Option<String>,
388 + /// User's personal notes.
389 + pub notes: String,
390 + /// Whether this bookmark is pinned to the top.
391 + pub is_pinned: bool,
392 + /// Row creation timestamp.
393 + pub created_at: String,
394 + /// Row last-modified timestamp.
395 + pub updated_at: String,
396 + }
397 +
398 + #[derive(Debug, Clone, Serialize, Deserialize)]
399 + /// Input for creating a new bookmark
400 + pub struct CreateBookmark {
401 + /// URL to bookmark.
402 + pub url: String,
403 + /// Display title.
404 + pub title: String,
405 + /// Short description or excerpt.
406 + pub description: String,
407 + /// Author attribution.
408 + pub author: String,
409 + /// Source name for display.
410 + pub source_name: String,
411 + /// Optional link to an existing feed item.
412 + pub feed_item_id: Option<String>,
413 + /// User's personal notes.
414 + pub notes: String,
415 + /// Tags to assign.
416 + pub tags: Vec<String>,
417 + }
418 +
419 + #[derive(Debug, Clone, Serialize, Deserialize)]
420 + /// Input for updating an existing bookmark
421 + pub struct UpdateBookmark {
422 + /// Updated title.
423 + pub title: Option<String>,
424 + /// Updated description.
425 + pub description: Option<String>,
426 + /// Updated notes.
427 + pub notes: Option<String>,
428 + /// Updated pin state.
429 + pub is_pinned: Option<bool>,
430 + }
431 +
369 432 #[cfg(test)]
370 433 mod tests {
371 434 use super::*;
@@ -8,7 +8,7 @@ use sqlx::SqlitePool;
8 8
9 9 use bb_interface::{ErrorCategory, StructuredError};
10 10
11 - use crate::id_types::{BusserStateId, FeedId, ItemId, QueryFeedId};
11 + use crate::id_types::{BookmarkId, BusserStateId, FeedId, ItemId, QueryFeedId};
12 12 use crate::models::*;
13 13 use crate::TIMESTAMP_FMT;
14 14
@@ -56,14 +56,15 @@ const MAX_SEARCH_QUERY_LENGTH: usize = 500;
56 56 /// excluded from automatic fetch scheduling until manually reset.
57 57 pub const CIRCUIT_BREAKER_THRESHOLD: i64 = 10;
58 58
59 - /// Repository for feed operations
60 59 #[derive(Clone)]
60 + /// Repository for feed subscription CRUD and fetch tracking
61 61 pub struct FeedsRepository {
62 62 pool: SqlitePool,
63 63 }
64 64
65 65 impl FeedsRepository {
66 66 /// Create a new feeds repository backed by the given pool.
67 + #[tracing::instrument(skip_all)]
67 68 pub fn new(pool: SqlitePool) -> Self {
68 69 Self { pool }
69 70 }
@@ -71,6 +72,7 @@ impl FeedsRepository {
71 72 /// Insert a new feed and return the created row.
72 73 /// New feeds default to `enabled = 1` (auto-fetch active) and
73 74 /// `created_at = updated_at` (no separate "first modified" vs "created" notion).
75 + #[tracing::instrument(skip_all)]
74 76 pub async fn create(&self, input: CreateFeed) -> Result<DbFeed, sqlx::Error> {
75 77 let id = FeedId::new();
76 78 let now = Utc::now().format(TIMESTAMP_FMT).to_string();
@@ -94,6 +96,7 @@ impl FeedsRepository {
94 96 }
95 97
96 98 /// Look up a single feed by its ID. Returns `None` if not found.
99 + #[tracing::instrument(skip_all)]
97 100 pub async fn get(&self, id: FeedId) -> Result<Option<DbFeed>, sqlx::Error> {
98 101 sqlx::query_as("SELECT * FROM feeds WHERE id = ?1")
99 102 .bind(id)
@@ -102,6 +105,7 @@ impl FeedsRepository {
102 105 }
103 106
104 107 /// List all feeds belonging to a given busser, ordered by name.
108 + #[tracing::instrument(skip_all)]
105 109 pub async fn get_by_busser(&self, busser_id: &str) -> Result<Vec<DbFeed>, sqlx::Error> {
106 110 sqlx::query_as("SELECT * FROM feeds WHERE busser_id = ?1 ORDER BY name")
107 111 .bind(busser_id)
@@ -110,6 +114,7 @@ impl FeedsRepository {
110 114 }
111 115
112 116 /// List only enabled feeds that are not circuit-broken, ordered by name.
117 + #[tracing::instrument(skip_all)]
113 118 pub async fn list_enabled(&self) -> Result<Vec<DbFeed>, sqlx::Error> {
114 119 sqlx::query_as(
115 120 "SELECT * FROM feeds WHERE enabled = 1 AND circuit_broken = 0 ORDER BY name",
@@ -119,6 +124,7 @@ impl FeedsRepository {
119 124 }
120 125
121 126 /// List every feed (enabled or disabled), ordered by name.
127 + #[tracing::instrument(skip_all)]
122 128 pub async fn list_all(&self) -> Result<Vec<DbFeed>, sqlx::Error> {
123 129 sqlx::query_as("SELECT * FROM feeds ORDER BY name")
124 130 .fetch_all(&self.pool)
@@ -126,6 +132,7 @@ impl FeedsRepository {
126 132 }
127 133
128 134 /// Update the `last_fetch` and `updated_at` timestamps to now.
135 + #[tracing::instrument(skip_all)]
129 136 pub async fn update_last_fetch(&self, id: FeedId) -> Result<(), sqlx::Error> {
130 137 let now = Utc::now().format(TIMESTAMP_FMT).to_string();
131 138 sqlx::query("UPDATE feeds SET last_fetch = ?1, updated_at = ?1 WHERE id = ?2")
@@ -137,6 +144,7 @@ impl FeedsRepository {
137 144 }
138 145
139 146 /// Enable or disable a feed.
147 + #[tracing::instrument(skip_all)]
140 148 pub async fn set_enabled(&self, id: FeedId, enabled: bool) -> Result<(), sqlx::Error> {
141 149 let now = Utc::now().format(TIMESTAMP_FMT).to_string();
142 150 sqlx::query("UPDATE feeds SET enabled = ?1, updated_at = ?2 WHERE id = ?3")
@@ -149,6 +157,7 @@ impl FeedsRepository {
149 157 }
150 158
151 159 /// Record a successful fetch: reset failure counter, clear error, update timestamps.
160 + #[tracing::instrument(skip_all)]
152 161 pub async fn record_fetch_success(&self, id: FeedId) -> Result<(), sqlx::Error> {
153 162 let now = Utc::now().format(TIMESTAMP_FMT).to_string();
154 163 sqlx::query(
@@ -166,6 +175,7 @@ impl FeedsRepository {
166 175 ///
167 176 /// Returns `true` if the circuit breaker tripped (i.e. the feed just crossed
168 177 /// the [`CIRCUIT_BREAKER_THRESHOLD`] and was marked `circuit_broken = 1`).
178 + #[tracing::instrument(skip_all)]
169 179 pub async fn record_fetch_failure(
170 180 &self,
171 181 id: FeedId,
@@ -202,6 +212,7 @@ impl FeedsRepository {
202 212 /// - `Transient` / `Parse` / `Unknown` — increment normally (existing behavior).
203 213 ///
204 214 /// Returns `true` if the circuit breaker tripped.
215 + #[tracing::instrument(skip_all)]
205 216 pub async fn record_fetch_failure_structured(
206 217 &self,
207 218 id: FeedId,
@@ -264,6 +275,7 @@ impl FeedsRepository {
264 275 }
265 276
266 277 /// Mark a feed as circuit-broken (or clear the circuit breaker).
278 + #[tracing::instrument(skip_all)]
267 279 pub async fn set_circuit_broken(
268 280 &self,
269 281 id: FeedId,
@@ -285,6 +297,7 @@ impl FeedsRepository {
285 297 /// `consecutive_failures` to 0, and clear `last_error`.
286 298 ///
287 299 /// Called when a user manually triggers a fetch for a circuit-broken feed.
300 + #[tracing::instrument(skip_all)]
288 301 pub async fn reset_circuit_breaker(&self, id: FeedId) -> Result<(), sqlx::Error> {
289 302 let now = Utc::now().format(TIMESTAMP_FMT).to_string();
290 303 sqlx::query(
@@ -299,6 +312,7 @@ impl FeedsRepository {
299 312 }
300 313
301 314 /// Update a feed's config JSON string.
315 + #[tracing::instrument(skip_all)]
302 316 pub async fn update_config(&self, id: FeedId, config: &str) -> Result<(), sqlx::Error> {
303 317 sqlx::query("UPDATE feeds SET config = ?1 WHERE id = ?2")
304 318 .bind(config)
@@ -309,6 +323,7 @@ impl FeedsRepository {
309 323 }
310 324
311 325 /// Update a feed's display name.
326 + #[tracing::instrument(skip_all)]
312 327 pub async fn update_name(&self, id: FeedId, name: &str) -> Result<(), sqlx::Error> {
313 328 let now = Utc::now().format(TIMESTAMP_FMT).to_string();
314 329 sqlx::query("UPDATE feeds SET name = ?1, updated_at = ?2 WHERE id = ?3")
@@ -321,6 +336,7 @@ impl FeedsRepository {
321 336 }
322 337
323 338 /// Delete a feed by ID.
339 + #[tracing::instrument(skip_all)]
324 340 pub async fn delete(&self, id: FeedId) -> Result<(), sqlx::Error> {
325 341 sqlx::query("DELETE FROM feeds WHERE id = ?1")
326 342 .bind(id)
@@ -330,14 +346,15 @@ impl FeedsRepository {
330 346 }
331 347 }
332 348
333 - /// Repository for feed item operations
334 349 #[derive(Clone)]
350 + /// Repository for feed item CRUD, read/star toggling, and paginated listing
335 351 pub struct ItemsRepository {
336 352 pool: SqlitePool,
337 353 }
338 354
339 355 impl ItemsRepository {
340 356 /// Create a new items repository backed by the given pool.
357 + #[tracing::instrument(skip_all)]
341 358 pub fn new(pool: SqlitePool) -> Self {
342 359 Self { pool }
343 360 }
@@ -347,6 +364,7 @@ impl ItemsRepository {
347 364 /// The ON CONFLICT clause deliberately preserves user state (`is_read`,
348 365 /// `is_starred`) — these are never overwritten by a re-fetch. Content fields
349 366 /// (author, text, body, etc.) are updated in case the source edited the post.
367 + #[tracing::instrument(skip_all)]
350 368 pub async fn upsert(&self, input: CreateFeedItem) -> Result<DbFeedItem, sqlx::Error> {
351 369 let id = ItemId::new();
352 370 let now = Utc::now().format(TIMESTAMP_FMT).to_string();
@@ -407,6 +425,7 @@ impl ItemsRepository {
407 425 }
408 426
409 427 /// Look up a feed item by its internal ID.
428 + #[tracing::instrument(skip_all)]
410 429 pub async fn get(&self, id: ItemId) -> Result<Option<DbFeedItem>, sqlx::Error> {
411 430 sqlx::query_as("SELECT * FROM feed_items WHERE id = ?1")
412 431 .bind(id)
@@ -415,6 +434,7 @@ impl ItemsRepository {
415 434 }
416 435
417 436 /// Look up a feed item by the `external_id` produced by the busser.
437 + #[tracing::instrument(skip_all)]
418 438 pub async fn get_by_external_id(
419 439 &self,
420 440 external_id: &str,
@@ -428,6 +448,7 @@ impl ItemsRepository {
428 448 /// List all items ordered by `published_at` descending, with pagination.
429 449 /// Sorted by publish date (not fetch date) so items appear where the author
430 450 /// intended, even if fetched out of order during backfill.
451 + #[tracing::instrument(skip_all)]
431 452 pub async fn list_all(&self, limit: i64, offset: i64) -> Result<Vec<DbFeedItem>, sqlx::Error> {
432 453 sqlx::query_as(
433 454 "SELECT * FROM feed_items ORDER BY published_at DESC LIMIT ?1 OFFSET ?2",
@@ -439,6 +460,7 @@ impl ItemsRepository {
439 460 }
440 461
441 462 /// List items belonging to a specific feed, newest first.
463 + #[tracing::instrument(skip_all)]
442 464 pub async fn list_by_feed(
443 465 &self,
444 466 feed_id: FeedId,
@@ -456,6 +478,7 @@ impl ItemsRepository {
456 478 }
457 479
458 480 /// List items from a specific busser source, newest first.
481 + #[tracing::instrument(skip_all)]
459 482 pub async fn list_by_busser(
460 483 &self,
461 484 busser_id: &str,
@@ -473,6 +496,7 @@ impl ItemsRepository {
473 496 }
474 497
475 498 /// List unread items only, newest first.
499 + #[tracing::instrument(skip_all)]
476 500 pub async fn list_unread(&self, limit: i64, offset: i64) -> Result<Vec<DbFeedItem>, sqlx::Error> {
477 501 sqlx::query_as(
478 502 "SELECT * FROM feed_items WHERE is_read = 0 ORDER BY published_at DESC LIMIT ?1 OFFSET ?2",
@@ -484,6 +508,7 @@ impl ItemsRepository {
484 508 }
485 509
486 510 /// List starred items only, newest first.
511 + #[tracing::instrument(skip_all)]
487 512 pub async fn list_starred(
488 513 &self,
489 514 limit: i64,
@@ -503,6 +528,7 @@ impl ItemsRepository {
503 528 /// Uses the `feed_items_fts` virtual table for fast ranked search.
504 529 /// Results are ordered by FTS5 relevance then by `published_at DESC`.
505 530 /// Additional boolean filters (source, unread, starred) are combined.
531 + #[tracing::instrument(skip_all)]
506 532 pub async fn list_search(
507 533 &self,
508 534 query: &str,
@@ -554,6 +580,7 @@ impl ItemsRepository {
554 580 }
555 581
556 582 /// Set the read flag on a feed item.
583 + #[tracing::instrument(skip_all)]
557 584 pub async fn mark_read(&self, id: ItemId, is_read: bool) -> Result<(), sqlx::Error> {
558 585 let now = Utc::now().format(TIMESTAMP_FMT).to_string();
559 586 sqlx::query("UPDATE feed_items SET is_read = ?1, updated_at = ?2 WHERE id = ?3")
@@ -566,6 +593,7 @@ impl ItemsRepository {
566 593 }
567 594
568 595 /// Set the starred flag on a feed item.
596 + #[tracing::instrument(skip_all)]
569 597 pub async fn mark_starred(&self, id: ItemId, is_starred: bool) -> Result<(), sqlx::Error> {
570 598 let now = Utc::now().format(TIMESTAMP_FMT).to_string();
571 599 sqlx::query("UPDATE feed_items SET is_starred = ?1, updated_at = ?2 WHERE id = ?3")
@@ -578,6 +606,7 @@ impl ItemsRepository {
578 606 }
579 607
580 608 /// Count all feed items.
609 + #[tracing::instrument(skip_all)]
581 610 pub async fn count_all(&self) -> Result<i64, sqlx::Error> {
582 611 let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM feed_items")
583 612 .fetch_one(&self.pool)
@@ -586,6 +615,7 @@ impl ItemsRepository {
586 615 }
587 616
588 617 /// Count items from a specific busser source.
618 + #[tracing::instrument(skip_all)]
589 619 pub async fn count_by_busser(&self, busser_id: &str) -> Result<i64, sqlx::Error> {
590 620 let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM feed_items WHERE busser_id = ?1")
591 621 .bind(busser_id)
@@ -595,6 +625,7 @@ impl ItemsRepository {
595 625 }
596 626
597 627 /// Count unread items across all sources.
628 + #[tracing::instrument(skip_all)]
598 629 pub async fn count_unread(&self) -> Result<i64, sqlx::Error> {
599 630 let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM feed_items WHERE is_read = 0")
600 631 .fetch_one(&self.pool)
@@ -603,6 +634,7 @@ impl ItemsRepository {
603 634 }
604 635
605 636 /// Count unread items from a specific busser source.
637 + #[tracing::instrument(skip_all)]
606 638 pub async fn count_unread_by_busser(&self, busser_id: &str) -> Result<i64, sqlx::Error> {
607 639 let row: (i64,) =
608 640 sqlx::query_as("SELECT COUNT(*) FROM feed_items WHERE busser_id = ?1 AND is_read = 0")
@@ -632,6 +664,7 @@ impl ItemsRepository {
632 664 }
633 665
634 666 /// Delete a single feed item by ID.
667 + #[tracing::instrument(skip_all)]
635 668 pub async fn delete(&self, id: ItemId) -> Result<(), sqlx::Error> {
636 669 sqlx::query("DELETE FROM feed_items WHERE id = ?1")
637 670 .bind(id)
@@ -641,6 +674,7 @@ impl ItemsRepository {
641 674 }
642 675
643 676 /// Delete all items belonging to a feed. Returns the number of rows removed.
677 + #[tracing::instrument(skip_all)]
644 678 pub async fn delete_by_feed(&self, feed_id: FeedId) -> Result<u64, sqlx::Error> {
645 679 let result = sqlx::query("DELETE FROM feed_items WHERE feed_id = ?1")
646 680 .bind(feed_id)
@@ -654,6 +688,7 @@ impl ItemsRepository {
654 688 /// Items published before `before` that have been read and are not starred
655 689 /// will be permanently deleted. Starred items are always preserved regardless
656 690 /// of age or read status.
691 + #[tracing::instrument(skip_all)]
657 692 pub async fn delete_stale_read(&self, before: DateTime<Utc>) -> Result<u64, sqlx::Error> {
658 693 let cutoff = before.format(TIMESTAMP_FMT).to_string();
659 694 let result = sqlx::query(
@@ -666,14 +701,15 @@ impl ItemsRepository {
666 701 }
667 702 }
668 703
669 - /// Repository for feed tag operations
670 704 #[derive(Clone)]
705 + /// Repository for per-feed tag assignment and listing
671 706 pub struct TagsRepository {
672 707 pool: SqlitePool,
673 708 }
674 709
675 710 impl TagsRepository {
676 711 /// Create a new tags repository backed by the given pool.
712 + #[tracing::instrument(skip_all)]
677 713 pub fn new(pool: SqlitePool) -> Self {
678 714 Self { pool }
679 715 }
@@ -682,6 +718,7 @@ impl TagsRepository {
682 718 ///
683 719 /// Wrapped in a transaction so the delete + inserts are atomic — a failure
684 720 /// mid-way won't leave the feed with zero tags.
721 + #[tracing::instrument(skip_all)]
685 722 pub async fn set_tags(&self, feed_id: FeedId, tags: &[String]) -> Result<(), sqlx::Error> {
686 723 let mut tx = self.pool.begin().await?;
687 724
@@ -705,6 +742,7 @@ impl TagsRepository {
705 742 }
706 743
707 744 /// Add a single tag to a feed (idempotent).
745 + #[tracing::instrument(skip_all)]
708 746 pub async fn add_tag(&self, feed_id: FeedId, tag: &str) -> Result<(), sqlx::Error> {
709 747 sqlx::query("INSERT OR IGNORE INTO feed_tags (feed_id, tag) VALUES (?1, ?2)")
710 748 .bind(feed_id)
@@ -715,6 +753,7 @@ impl TagsRepository {
715 753 }
716 754
717 755 /// Remove a single tag from a feed.
756 + #[tracing::instrument(skip_all)]
718 757 pub async fn remove_tag(&self, feed_id: FeedId, tag: &str) -> Result<(), sqlx::Error> {
719 758 sqlx::query("DELETE FROM feed_tags WHERE feed_id = ?1 AND tag = ?2")
720 759 .bind(feed_id)
@@ -725,6 +764,7 @@ impl TagsRepository {
725 764 }
726 765
727 766 /// Get all tags for a feed, ordered alphabetically.
767 + #[tracing::instrument(skip_all)]
728 768 pub async fn get_tags(&self, feed_id: FeedId) -> Result<Vec<String>, sqlx::Error> {
729 769 let rows: Vec<(String,)> =
730 770 sqlx::query_as("SELECT tag FROM feed_tags WHERE feed_id = ?1 ORDER BY tag")
@@ -735,6 +775,7 @@ impl TagsRepository {
735 775 }
736 776
737 777 /// List all distinct tags across all feeds, ordered alphabetically.
778 + #[tracing::instrument(skip_all)]
738 779 pub async fn list_all_tags(&self) -> Result<Vec<String>, sqlx::Error> {
739 780 let rows: Vec<(String,)> =
740 781 sqlx::query_as("SELECT DISTINCT tag FROM feed_tags ORDER BY tag")
@@ -744,6 +785,7 @@ impl TagsRepository {
744 785 }
745 786
746 787 /// Get all (feed_id, tag) pairs for bulk rendering.
788 + #[tracing::instrument(skip_all)]
747 789 pub async fn all_feed_tags(&self) -> Result<Vec<(FeedId, String)>, sqlx::Error> {
748 790 sqlx::query_as("SELECT feed_id, tag FROM feed_tags ORDER BY feed_id, tag")
749 791 .fetch_all(&self.pool)
@@ -751,6 +793,7 @@ impl TagsRepository {
751 793 }
752 794
753 795 /// Get feed IDs that have any of the given tags.
796 + #[tracing::instrument(skip_all)]
754 797 pub async fn feed_ids_with_tags(&self, tags: &[String]) -> Result<Vec<FeedId>, sqlx::Error> {
755 798 if tags.is_empty() {
756 799 return Ok(Vec::new());
@@ -770,19 +813,21 @@ impl TagsRepository {
770 813 }
771 814 }
772 815
773 - /// Repository for busser state operations
774 816 #[derive(Clone)]
817 + /// Repository for busser key-value state (cursors, tokens, pagination markers)
775 818 pub struct StateRepository {
776 819 pool: SqlitePool,
777 820 }
778 821
779 822 impl StateRepository {
780 823 /// Create a new state repository backed by the given pool.
824 + #[tracing::instrument(skip_all)]
781 825 pub fn new(pool: SqlitePool) -> Self {
782 826 Self { pool }
783 827 }
784 828
785 829 /// Get a single state value for a busser by key.
830 + #[tracing::instrument(skip_all)]
786 831 pub async fn get(&self, busser_id: &str, key: &str) -> Result<Option<String>, sqlx::Error> {
787 832 let row: Option<(String,)> =
788 833 sqlx::query_as("SELECT value FROM busser_state WHERE busser_id = ?1 AND key = ?2")
@@ -796,6 +841,7 @@ impl StateRepository {
796 841 /// Set a state value, inserting or updating on conflict.
797 842 /// Uses upsert on the `(busser_id, key)` composite unique constraint so
798 843 /// callers don't need to check existence first.
844 + #[tracing::instrument(skip_all)]
799 845 pub async fn set(&self, busser_id: &str, key: &str, value: &str) -> Result<(), sqlx::Error> {
800 846 let id = BusserStateId::new();
801 847 let now = Utc::now().format(TIMESTAMP_FMT).to_string();
@@ -817,6 +863,7 @@ impl StateRepository {
817 863 }
818 864
819 865 /// Delete a single state entry by busser ID and key.
866 + #[tracing::instrument(skip_all)]
820 867 pub async fn delete(&self, busser_id: &str, key: &str) -> Result<(), sqlx::Error> {
821 868 sqlx::query("DELETE FROM busser_state WHERE busser_id = ?1 AND key = ?2")
822 869 .bind(busser_id)
@@ -827,6 +874,7 @@ impl StateRepository {
827 874 }
828 875
829 876 /// Delete all state entries for a busser. Returns the number of rows removed.
877 + #[tracing::instrument(skip_all)]
830 878 pub async fn delete_all(&self, busser_id: &str) -> Result<u64, sqlx::Error> {
831 879 let result = sqlx::query("DELETE FROM busser_state WHERE busser_id = ?1")
832 880 .bind(busser_id)
@@ -836,6 +884,7 @@ impl StateRepository {
836 884 }
837 885
838 886 /// List all state entries for a busser, ordered by key.
887 + #[tracing::instrument(skip_all)]
839 888 pub async fn list(&self, busser_id: &str) -> Result<Vec<DbBusserState>, sqlx::Error> {
840 889 sqlx::query_as("SELECT * FROM busser_state WHERE busser_id = ?1 ORDER BY key")
841 890 .bind(busser_id)
@@ -844,22 +893,20 @@ impl StateRepository {
844 893 }
845 894 }
846 895
847 - /// Repository for user_config key-value operations.
848 - ///
849 - /// Wraps the synced `user_config` table (migration 007) for app-level
850 - /// preferences like theme and welcome state. Unlike `StateRepository`,
851 - /// entries are not scoped by busser — just `(key, value)`.
852 896 #[derive(Clone)]
897 + /// Repository for user_config key-value pairs (theme, welcome flag, etc.)
853 898 pub struct ConfigRepository {
854 899 pool: SqlitePool,
855 900 }
856 901
857 902 impl ConfigRepository {
903 + #[tracing::instrument(skip_all)]
858 904 pub fn new(pool: SqlitePool) -> Self {
859 905 Self { pool }
860 906 }
861 907
862 908 /// Get a config value by key.
909 + #[tracing::instrument(skip_all)]
863 910 pub async fn get(&self, key: &str) -> Result<Option<String>, sqlx::Error> {
864 911 let row: Option<(String,)> =
865 912 sqlx::query_as("SELECT value FROM user_config WHERE key = ?1")
@@ -870,6 +917,7 @@ impl ConfigRepository {
870 917 }
871 918
872 919 /// Set a config value, inserting or updating on conflict.
920 + #[tracing::instrument(skip_all)]
873 921 pub async fn set(&self, key: &str, value: &str) -> Result<(), sqlx::Error> {
874 922 sqlx::query(
875 923 r#"
@@ -886,6 +934,7 @@ impl ConfigRepository {
886 934 }
887 935
888 936 /// Delete a config entry by key.
937 + #[tracing::instrument(skip_all)]
889 938 pub async fn delete(&self, key: &str) -> Result<(), sqlx::Error> {
890 939 sqlx::query("DELETE FROM user_config WHERE key = ?1")
891 940 .bind(key)
@@ -895,17 +944,14 @@ impl ConfigRepository {
895 944 }
896 945 }
897 946
898 - /// Repository for query feed operations.
899 - ///
900 - /// Query feeds are saved filter rules that act as virtual sources.
901 - /// Each query feed stores a JSON array of conditions that are translated
902 - /// to a [`FeedFilter`] at query time.
903 947 #[derive(Clone)]
948 + /// Repository for saved query feed (virtual source) CRUD
904 949 pub struct QueryFeedsRepository {
905 950 pool: SqlitePool,
906 951 }
907 952
908 953 impl QueryFeedsRepository {
954 + #[tracing::instrument(skip_all)]
909 955 pub fn new(pool: SqlitePool) -> Self {
910 956 Self { pool }
911 957 }
@@ -933,6 +979,7 @@ impl QueryFeedsRepository {
933 979 }
934 980
935 981 /// Look up a single query feed by ID. Returns `None` if not found.
982 + #[tracing::instrument(skip_all)]
936 983 pub async fn get(&self, id: QueryFeedId) -> Result<Option<DbQueryFeed>, sqlx::Error> {
937 984 sqlx::query_as("SELECT * FROM query_feeds WHERE id = ?1")
938 985 .bind(id)
@@ -941,6 +988,7 @@ impl QueryFeedsRepository {
941 988 }
942 989
943 990 /// List all query feeds, ordered by name.
991 + #[tracing::instrument(skip_all)]
944 992 pub async fn list_all(&self) -> Result<Vec<DbQueryFeed>, sqlx::Error> {
945 993 sqlx::query_as("SELECT * FROM query_feeds ORDER BY name")
946 994 .fetch_all(&self.pool)
@@ -948,6 +996,7 @@ impl QueryFeedsRepository {
948 996 }
949 997
950 998 /// Update a query feed's name and rules.
999 + #[tracing::instrument(skip_all)]
951 1000 pub async fn update(
952 1001 &self,
953 1002 id: QueryFeedId,
@@ -970,6 +1019,7 @@ impl QueryFeedsRepository {
970 1019 }
971 1020
972 1021 /// Delete a query feed by ID.
1022 + #[tracing::instrument(skip_all)]
973 1023 pub async fn delete(&self, id: QueryFeedId) -> Result<(), sqlx::Error> {
974 1024 sqlx::query("DELETE FROM query_feeds WHERE id = ?1")
975 1025 .bind(id)
@@ -979,6 +1029,205 @@ impl QueryFeedsRepository {
979 1029 }
980 1030 }
981 1031
1032 + // ── BookmarksRepository ──────────────────────────────────────────
1033 +
1034 + #[derive(Clone)]
1035 + /// Repository for bookmark (reading list) CRUD
1036 + pub struct BookmarksRepository {
1037 + pool: SqlitePool,
1038 + }
1039 +
1040 + impl BookmarksRepository {
1041 + pub fn new(pool: SqlitePool) -> Self {
1042 + Self { pool }
1043 + }
1044 +
1045 + /// Insert a new bookmark and return the created row.
1046 + #[tracing::instrument(skip_all)]
1047 + pub async fn create(&self, input: CreateBookmark) -> Result<DbBookmark, sqlx::Error> {
1048 + let id = BookmarkId::new();
1049 + let now = Utc::now().format(TIMESTAMP_FMT).to_string();
1050 +
1051 + let bookmark: DbBookmark = sqlx::query_as(
1052 + r#"
1053 + INSERT INTO bookmarks (id, url, title, description, author, source_name,
1054 + feed_item_id, notes, is_pinned, created_at, updated_at)
1055 + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 0, ?9, ?9)
1056 + RETURNING *
Lines truncated
@@ -5,6 +5,7 @@ use super::{FeedError, FeedGenerator};
5 5
6 6 impl FeedGenerator {
7 7 /// Mark an item as read
8 + #[tracing::instrument(skip_all)]
8 9 pub async fn mark_read(&self, item_id: &str, is_read: bool) -> Result<(), FeedError> {
9 10 // Find item by external_id
10 11 if let Some(item) = self.db.items().get_by_external_id(item_id).await? {
@@ -14,6 +15,7 @@ impl FeedGenerator {
14 15 }
15 16
16 17 /// Mark an item as starred
18 + #[tracing::instrument(skip_all)]
17 19 pub async fn mark_starred(&self, item_id: &str, is_starred: bool) -> Result<(), FeedError> {
18 20 if let Some(item) = self.db.items().get_by_external_id(item_id).await? {
19 21 self.db.items().mark_starred(item.id, is_starred).await?;
@@ -22,6 +24,7 @@ impl FeedGenerator {
22 24 }
23 25
24 26 /// Get the database handle
27 + #[tracing::instrument(skip_all)]
25 28 pub fn database(&self) -> &Database {
26 29 &self.db
27 30 }
@@ -14,13 +14,14 @@ use crate::{FeedFilter, OrderBy};
14 14 // items and query add impl blocks to FeedGenerator (no new public types to re-export).
15 15
16 16 #[derive(Error, Debug)]
17 + /// Errors that can occur during feed generation
17 18 pub enum FeedError {
18 19 #[error("Database error: {0}")]
19 20 Database(#[from] sqlx::Error),
20 21 }
21 22
22 - /// A page of feed items with a flag indicating whether more pages exist.
23 23 #[derive(Debug)]
24 + /// A page of feed items with a flag indicating whether more pages exist
24 25 pub struct PaginatedItems {
25 26 /// The items for this page (at most `page_size` items).
26 27 pub items: Vec<bb_interface::FeedItem>,
@@ -28,8 +29,8 @@ pub struct PaginatedItems {
28 29 pub has_more: bool,
29 30 }
30 31
31 - /// Source info with item counts
32 32 #[derive(Debug, Clone)]
33 + /// Source info with item counts
33 34 pub struct SourceInfo {
34 35 /// Busser/plugin identifier.
35 36 pub id: String,
@@ -58,6 +59,7 @@ pub struct FeedGenerator {
58 59 }
59 60
60 61 impl FeedGenerator {
62 + #[tracing::instrument(skip_all)]
61 63 pub fn new(db: Database) -> Self {
62 64 Self {
63 65 db,
@@ -67,33 +69,40 @@ impl FeedGenerator {
67 69 }
68 70 }
69 71
72 + #[tracing::instrument(skip_all)]
70 73 pub fn with_order(mut self, order: OrderBy) -> Self {
71 74 self.order_by = order;
72 75 self
73 76 }
74 77
78 + #[tracing::instrument(skip_all)]
75 79 pub fn with_filter(mut self, filter: FeedFilter) -> Self {
76 80 self.filter = filter;
77 81 self
78 82 }
79 83
84 + #[tracing::instrument(skip_all)]
80 85 pub fn with_page_size(mut self, size: i64) -> Self {
81 86 self.page_size = size;
82 87 self
83 88 }
84 89
90 + #[tracing::instrument(skip_all)]
85 91 pub fn set_order(&mut self, order: OrderBy) {
86 92 self.order_by = order;
87 93 }
88 94
95 + #[tracing::instrument(skip_all)]
89 96 pub fn set_filter(&mut self, filter: FeedFilter) {
90 97 self.filter = filter;
91 98 }
92 99
100 + #[tracing::instrument(skip_all)]
93 101 pub fn order(&self) -> OrderBy {
94 102 self.order_by
95 103 }
96 104
105 + #[tracing::instrument(skip_all)]
97 106 pub fn filter(&self) -> &FeedFilter {
98 107 &self.filter
99 108 }
@@ -14,6 +14,7 @@ impl FeedGenerator {
14 14 ///
15 15 /// Fetches `page_size + 1` items to detect whether more pages exist,
16 16 /// then applies in-memory ordering before truncating to the exact page size.
17 + #[tracing::instrument(skip_all)]
17 18 pub async fn get_items(
18 19 &self,
19 20 page: i64,
@@ -132,6 +133,7 @@ impl FeedGenerator {
132 133 /// Capped at [`Self::MAX_ALL_ITEMS`] rows to prevent unbounded memory use.
133 134 /// Fetches `MAX_ALL_ITEMS + 1` to detect whether more items exist beyond
134 135 /// the cap, using the same strategy as [`Self::get_items`].
136 + #[tracing::instrument(skip_all)]
135 137 pub async fn get_all_items(&self) -> Result<PaginatedItems, FeedError> {
136 138 let fetch_limit = Self::MAX_ALL_ITEMS + 1;
137 139 let items = self.db.items().list_all(fetch_limit, 0).await?;
@@ -154,6 +156,7 @@ impl FeedGenerator {
154 156 }
155 157
156 158 /// Get total item count
159 + #[tracing::instrument(skip_all)]
157 160 pub async fn count(&self) -> Result<i64, FeedError> {
158 161 if let Some(ref source) = self.filter.source {
159 162 Ok(self.db.items().count_by_busser(source).await?)
@@ -165,6 +168,7 @@ impl FeedGenerator {
165 168 }
166 169
167 170 /// Get unread count
171 + #[tracing::instrument(skip_all)]
168 172 pub async fn unread_count(&self) -> Result<i64, FeedError> {
169 173 Ok(self.db.items().count_unread().await?)
170 174 }
@@ -173,6 +177,7 @@ impl FeedGenerator {
173 177 ///
174 178 /// Fetches all feeds and their item counts in two queries (feeds + a single
175 179 /// GROUP BY on feed_items) instead of issuing per-source count queries.
180 + #[tracing::instrument(skip_all)]
176 181 pub async fn get_sources(&self) -> Result<Vec<SourceInfo>, FeedError> {
177 182 let feeds = self.db.feeds().list_all().await?;
178 183 let counts = self.db.items().counts_by_busser().await?;
@@ -9,8 +9,8 @@ use bb_db::QueryCondition;
9 9 use bb_interface::FeedItem;
10 10 use regex::Regex;
11 11
12 - /// How to order feed items
13 12 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13 + /// How to order feed items
14 14 pub enum OrderBy {
15 15 /// Most recent first
16 16 #[default]
@@ -25,6 +25,7 @@ pub enum OrderBy {
25 25
26 26 impl OrderBy {
27 27 /// Parse from a string (as sent by the frontend).
28 + #[tracing::instrument(skip_all)]
28 29 pub fn from_str_loose(s: &str) -> Self {
29 30 match s {
30 31 "score" => OrderBy::Score,
@@ -35,6 +36,7 @@ impl OrderBy {
35 36 }
36 37
37 38 /// Apply ordering to a list of items
39 + #[tracing::instrument(skip_all)]
38 40 pub fn apply(&self, items: &mut [FeedItem]) {
39 41 match self {
40 42 OrderBy::Chronological => {
@@ -70,8 +72,8 @@ impl OrderBy {
70 72 }
71 73 }
72 74
73 - /// Filter criteria for feed items
74 75 #[derive(Debug, Clone, Default)]
76 + /// Filter criteria for feed items
75 77 pub struct FeedFilter {
76 78 /// Filter by source busser ID
77 79 pub source: Option<String>,
@@ -94,23 +96,27 @@ pub struct FeedFilter {
94 96
95 97 impl FeedFilter {
96 98 /// Create an empty filter that matches all items.
99 + #[tracing::instrument(skip_all)]
97 100 pub fn new() -> Self {
98 101 Self::default()
99 102 }
100 103
101 104 /// Restrict to items from a specific busser source.
105 + #[tracing::instrument(skip_all)]
102 106 pub fn source(mut self, source: impl Into<String>) -> Self {
103 107 self.source = Some(source.into());
104 108 self
105 109 }
106 110
107 111 /// Only show items that haven't been read.
112 + #[tracing::instrument(skip_all)]
108 113 pub fn unread_only(mut self) -> Self {
109 114 self.unread_only = true;
110 115 self
111 116 }
112 117
113 118 /// Only show items that have been starred.
119 + #[tracing::instrument(skip_all)]
114 120 pub fn starred_only(mut self) -> Self {
115 121 self.starred_only = true;
116 122 self
@@ -123,12 +129,14 @@ impl FeedFilter {
123 129 }
124 130
125 131 /// Require at least one of the item's tags to match the given tag.
132 + #[tracing::instrument(skip_all)]
126 133 pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
127 134 self.tags.push(tag.into());
128 135 self
129 136 }
130 137
131 138 /// Filter by user-assigned feed-level tag.
139 + #[tracing::instrument(skip_all)]
132 140 pub fn with_feed_tag(mut self, tag: impl Into<String>) -> Self {
133 141 self.feed_tags.push(tag.into());
134 142 self
@@ -139,6 +147,7 @@ impl FeedFilter {
139 147 /// Simple conditions (source, starred, unread, tag) are mapped to the
140 148 /// corresponding fast-path SQL fields. Complex conditions (title/author/body
141 149 /// contains, regex) are stored in `self.conditions` for in-memory evaluation.
150 + #[tracing::instrument(skip_all)]
142 151 pub fn from_conditions(conditions: Vec<QueryCondition>) -> Self {
143 152 let mut filter = Self::new();
144 153 for c in &conditions {
@@ -184,6 +193,7 @@ impl FeedFilter {
184 193 }
185 194
186 195 /// Check if an item matches the filter
196 + #[tracing::instrument(skip_all)]
187 197 pub fn matches(&self, item: &FeedItem) -> bool {
188 198 let cache = self.compile_regexes();
189 199 self.matches_with_cache(item, &cache)
@@ -288,6 +298,7 @@ impl FeedFilter {
288 298 }
289 299
290 300 /// Apply filter to a list of items
301 + #[tracing::instrument(skip_all)]
291 302 pub fn apply(&self, items: Vec<FeedItem>) -> Vec<FeedItem> {
292 303 let cache = self.compile_regexes();
293 304 items.into_iter().filter(|item| self.matches_with_cache(item, &cache)).collect()
@@ -298,6 +309,7 @@ impl FeedFilter {
298 309 /// Used when search, source, unread, and starred filters have already
299 310 /// been pushed into the SQL query, but tag filtering still needs to
300 311 /// happen in-memory.
312 + #[tracing::instrument(skip_all)]
301 313 pub fn apply_tags_only(&self, items: Vec<FeedItem>) -> Vec<FeedItem> {
302 314 if self.tags.is_empty() {
303 315 return items;
@@ -7,8 +7,8 @@ use serde::{Deserialize, Serialize};
7 7
8 8 use crate::{BusserError, FetchResult};
9 9
10 - /// Type of configuration field
11 10 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
11 + /// Type of configuration field
12 12 pub enum ConfigFieldType {
13 13 /// Single line text input
14 14 Text,
@@ -26,8 +26,8 @@ pub enum ConfigFieldType {
26 26 Select,
27 27 }
28 28
29 - /// A configuration field that a busser requires
30 29 #[derive(Clone, Debug, Serialize, Deserialize)]
30 + /// A configuration field that a busser requires
31 31 pub struct ConfigField {
32 32 /// Field key (used in BusserConfig)
33 33 pub key: String,
@@ -129,8 +129,8 @@ impl ConfigField {
129 129 }
130 130 }
131 131
132 - /// Configuration schema returned by a busser
133 132 #[derive(Clone, Debug, Serialize, Deserialize)]
133 + /// Configuration schema returned by a busser
134 134 pub struct ConfigSchema {
135 135 /// Human-readable description of the busser
136 136 pub description: String,
@@ -157,8 +157,8 @@ impl ConfigSchema {
157 157 /// Default auto-fetch interval: 15 minutes.
158 158 pub const DEFAULT_FETCH_INTERVAL_SECS: u64 = 900;
159 159
160 - /// Capabilities a busser can advertise
161 160 #[derive(Clone, Debug, Serialize, Deserialize)]
161 + /// Capabilities a busser can advertise
162 162 pub struct BusserCapabilities {
163 163 /// Supports pagination
164 164 pub supports_pagination: bool,
@@ -233,8 +233,8 @@ impl BusserCapabilities {
233 233 }
234 234 }
235 235
236 - /// Configuration passed to a busser during initialization
237 236 #[derive(Clone, Debug, Default, Serialize, Deserialize)]
237 + /// Configuration passed to a busser during initialization
238 238 pub struct BusserConfig {
239 239 /// Key-value configuration options
240 240 pub options: std::collections::HashMap<String, String>,
@@ -2,8 +2,8 @@
2 2
3 3 use serde::{Deserialize, Serialize};
4 4
5 - /// Errors that can occur in busser operations
6 5 #[derive(Clone, Debug, Serialize, Deserialize)]
6 + /// Errors that can occur in busser operations
7 7 pub enum BusserError {
8 8 /// Initialization failed
9 9 InitializationFailed { message: String },
@@ -85,12 +85,9 @@ impl std::fmt::Display for BusserError {
85 85
86 86 impl std::error::Error for BusserError {}
87 87
88 - /// Broad error categories for plugin fetch failures.
89 - ///
90 - /// Each category determines retry behavior, circuit-breaker weight,
91 - /// and what action the user should take.
92 88 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
93 89 #[serde(rename_all = "snake_case")]
90 + /// Broad error category determining retry behavior and circuit-breaker weight
94 91 pub enum ErrorCategory {
95 92 /// DNS timeout, 500, 502, 503 — retry normally, +1 failure weight.
96 93 Transient,
@@ -106,9 +103,8 @@ pub enum ErrorCategory {
106 103 Unknown,
107 104 }
108 105
109 - /// A structured error carrying category, display message, and optional
110 - /// retry-after hint. Stored as JSON in the `last_error` column.
111 106 #[derive(Clone, Debug, Serialize, Deserialize)]
107 + /// A structured error carrying category, display message, and optional retry-after hint
112 108 pub struct StructuredError {
113 109 pub category: ErrorCategory,
114 110 pub message: String,
@@ -2,8 +2,8 @@
2 2
3 3 use serde::{Deserialize, Serialize};
4 4
5 - /// Unique identifier for a feed item
6 5 #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
6 + /// Unique identifier for a feed item
7 7 pub struct FeedItemId {
8 8 /// Source busser ID
9 9 pub source: String,
@@ -32,8 +32,8 @@ impl FeedItemId {
32 32 }
33 33 }
34 34
35 - /// Compact display for feed list view
36 35 #[derive(Clone, Debug, Serialize, Deserialize)]
36 + /// Compact display for feed list view
37 37 pub struct BiteDisplay {
38 38 /// Author attribution (e.g., "@username", "HN", "RSS")
39 39 pub author: String,
@@ -69,8 +69,8 @@ impl BiteDisplay {
69 69 }
70 70 }
71 71
72 - /// A custom action button declared by a plugin (e.g. "View PDF", "ar5iv HTML").
73 72 #[derive(Clone, Debug, Serialize, Deserialize)]
73 + /// A custom action button declared by a plugin
74 74 pub struct ItemAction {
75 75 /// Button label shown in the detail view.
76 76 pub label: String,
@@ -80,8 +80,8 @@ pub struct ItemAction {
80 80 pub url: String,
81 81 }
82 82
83 - /// Full content for detail view
84 83 #[derive(Clone, Debug, Default, Serialize, Deserialize)]
84 + /// Full content for detail view
85 85 pub struct FeedItemContent {
86 86 /// Title (for articles/posts)
87 87 pub title: Option<String>,
@@ -127,8 +127,8 @@ impl FeedItemContent {
127 127 }
128 128 }
129 129
130 - /// Metadata for feed items
131 130 #[derive(Clone, Debug, Serialize, Deserialize)]
131 + /// Metadata for feed items
132 132 pub struct FeedItemMeta {
133 133 /// When the item was published
134 134 pub published_at: i64,
@@ -167,8 +167,8 @@ impl FeedItemMeta {
167 167 }
168 168 }
169 169
170 - /// Complete feed item with all information
171 170 #[derive(Clone, Debug, Serialize, Deserialize)]
171 + /// Complete feed item with all information
172 172 pub struct FeedItem {
173 173 /// Unique identifier
174 174 pub id: FeedItemId,
@@ -207,8 +207,8 @@ impl FeedItem {
207 207 }
208 208 }
209 209
210 - /// Result of a fetch operation
211 210 #[derive(Clone, Debug, Serialize, Deserialize)]
211 + /// Result of a fetch operation
212 212 pub struct FetchResult {
213 213 /// Fetched items
214 214 pub items: Vec<FeedItem>,
@@ -1,186 +1,175 @@
1 1 # Balanced Breakfast -- Code Audit Review
2 2
3 - **Last audited:** 2026-03-28 (eleventh audit, Run 12 cross-project)
4 - **Previous audit:** 2026-03-18 (tenth audit, Run 9 cross-project)
3 + **Last audited:** 2026-04-18 (thirteenth audit, Run 15 cross-project)
4 + **Previous audit:** 2026-04-15 (twelfth audit, Run 14 cross-project)
5 5
6 6 ## Overall Grade: A
7 7
8 - Run 12 cross-project audit. 602 tests (547 Rust + 55 JS). 0 clippy warnings. v0.3.0. Grade stable at A. No code changes since Run 9. New dependency advisories: rustls-webpki (RUSTSEC-2026-0049), tar x2 (RUSTSEC-2026-0067, -0068).
8 + Run 15 cross-project audit (updated 2026-04-22). 601 tests (all pass, `--workspace`). 0 clippy warnings. v0.3.1. ~23,458 LOC. Test "regression" was false (audit ran without `--workspace`). bb-feed has 110 tests (not 0). FTS sanitization is correct (quotes all terms). XSS: all user content properly escaped. Observability expanded to 240 instrument annotations.
9 9
10 10 ## Scorecard
11 11
12 12 | Dimension | Grade | Notes |
13 13 |-----------|:-----:|-------|
14 - | Code Quality | A | Near-zero unwraps in production code (4 total, all startup/static). Clean clippy. Consistent `thiserror` + `Result` error handling throughout. |
15 - | Architecture | A | Excellent 4-crate separation with clear dependency flow: interface <- core <- db, feed. Tauri layer is a thin shell. Sync service well-isolated. |
16 - | Testing | A | 547 Rust tests + 55 JS tests, 0 failures. Inline tests in every module + 50 integration tests. JS test infrastructure covers state, utils, sources, items, settings-sync. |
17 - | Security | A | AES-256-GCM encryption at rest, Rhai engine sandboxing (100k ops, depth 128, 32 call levels), input validation, XSS sanitization, PKCE OAuth2, path traversal protection. Rhai HTTP sandbox: 100 requests/fetch, 2 MB response cap, URL scheme + private range blocking, 60s aggregate timeout. ammonia HTML sanitization on all feed content, FTS5 search query length limit (500 chars). |
18 - | Performance | A | Proper indexes, FTS5, pagination, debounced search, theme caching, LazyLock statics. SqlitePool max_connections=16 (appropriate for WAL mode concurrent readers). |
19 - | Documentation | A | architecture.md (211 lines), plugin_authoring.md (324 lines), README (74 lines). Good JSDoc and Rust doc comments. Plugin trust model documented. Repository methods documented. |
20 - | Dependencies | A- | 26 direct external deps -- reasonable for a Tauri app with crypto, DB, XML, theming. All well-maintained crates. Workspace-level dep management. |
21 - | Frontend | A | Good UX patterns (skeleton loading, undo, keyboard shortcuts, themes). Vanilla JS keeps it simple. 55 automated JS tests covering state, utils, sources, items, and settings-sync state machine. Gap: no TypeScript. |
22 - | Type Safety | A | Newtype UUIDs (FeedId, ItemId, BusserStateId, BusserId) via macro, typed errors (ApiError, OrchestratorError, BusserError), exhaustive enums, strong Rhai<->Rust conversion boundaries. |
23 - | Observability | A | Tracing subscriber configured. 61 `#[instrument(skip_all)]` annotations across all Tauri commands + 11 orchestrator methods + 11 sync service functions. Auto-fetch errors emit Tauri events. Feed health tracking. |
24 - | Concurrency | A | Tokio async throughout, parking_lot RwLock/Mutex (no poison risk), AbortHandle-based task cancellation, exponential backoff on sync (max 15 min). TOCTOU in create_feed fixed with SQLite transaction. |
25 - | Resilience | A | Per-feed error isolation, health indicators (green/yellow/red), stale item cleanup, sync retry with backoff. Circuit breaker implemented (migration 008, threshold 10, skip in auto-fetch, reset API, event emission). Changelog cap at 10,000 entries with `enforce_changelog_retention()` on sync tick. |
26 - | API Consistency | A | Uniform command patterns, consistent error serialization (5 error codes), builder patterns throughout. Unified pagination with page_size+1 has_more detection. |
27 - | Codebase Size | A | ~7,600 lines production Rust + 2,140 lines JS for 20+ features. Right-sized for its scope. No files violate the 500-line branching guideline. |
14 + | Code Quality | A- | Near-zero unwraps in production code. Clean clippy. Consistent error handling. |
15 + | Architecture | A | Excellent 4-crate separation: interface <- core <- db, feed. Tauri layer is a thin shell. Sync service well-isolated. |
16 + | Testing | A | 601 tests (all pass, `--workspace`). bb-feed: 110 tests. Coverage across all layers maintained. |
17 + | Security | A | AES-256-GCM encryption at rest, Rhai sandboxing, input validation, PKCE OAuth2, path traversal protection. FTS queries fully sanitized (all terms quoted). All user content escaped in frontend. |
18 + | Performance | A- | Proper indexes, FTS5, pagination, debounced search, theme caching. All db/feed functions instrumented for tracing. |
19 + | Documentation | A- | architecture.md, plugin_authoring.md, database_schema.md, frontend_architecture.md, troubleshooting.md. All public items documented with `///`. |
20 + | Dependencies | A | 26 direct deps, reasonable for Tauri app. Workspace-level dep management. |
21 + | Frontend | A- | Good UX patterns (skeleton loading, undo, keyboard shortcuts, themes). Vanilla JS. All user content escaped with escapeHtml()/escapeAttr(). |
22 + | Type Safety | B | Newtype UUIDs via macro, typed errors, exhaustive enums. Some stringly-typed paths remain. |
23 + | Observability | A | 240 instrument annotations (src-tauri 66, crates 174). Full tracing coverage across all layers. |
24 + | Concurrency | A- | Tokio async, parking_lot RwLock/Mutex, AbortHandle-based task cancellation, exponential backoff. |
25 + | Resilience | B+ | Per-feed error isolation, health indicators, stale item cleanup, sync retry with backoff. Circuit breaker implemented. |
26 + | API Consistency | A | Uniform command patterns, consistent error serialization, builder patterns, unified pagination. |
27 + | Migration Safety | A- | SQLite migrations, all additive. |
28 + | Codebase Size | A | ~23,458 LOC for 20+ features. Right-sized for its scope. |
28 29
29 30 ## Module Heatmap
30 31
31 32 | Module | Code | Arch | Test | Security | Perf | Docs | Deps | Frontend |
32 33 |--------|:----:|:----:|:----:|:--------:|:----:|:----:|:----:|:--------:|
33 34 | bb-interface (~380 lines) | A | A | A | n/a | n/a | A | A | n/a |
34 - | bb-core (~1,700 lines) | A | A | A- | A | A- | A | n/a | n/a |
35 - | bb-db (~1,100 lines) | A | A | A | A | A- | A | n/a | n/a |
36 - | bb-feed (~500 lines) | A | A- | A | n/a | A- | A | n/a | n/a |
37 - | src-tauri (~3,900 lines) | A- | A | A- | A | A | A | n/a | n/a |
38 - | JS Frontend (2,140 lines) | A- | B+ | A- | A- | B+ | A- | n/a | A |
35 + | bb-core (~1,700 lines) | A | A | B | A | A- | B+ | n/a | n/a |
36 + | bb-db (~1,100 lines) | A | A | B | A | B+ | B+ | n/a | n/a |
37 + | bb-feed (~500 lines) | A | A- | A | n/a | A- | B | n/a | n/a |
38 + | src-tauri (~3,900 lines) | A- | A | B- | B+ | A | B+ | n/a | n/a |
39 + | JS Frontend (2,140 lines) | A- | B+ | B | B | B+ | B | n/a | A |
39 40 | Rhai Plugins (~580 lines) | B+ | A- | A | A | n/a | A- | n/a | n/a |
40 41 | SQL Migrations (250 lines) | A | A- | n/a | n/a | A | A- | n/a | n/a |
41 42
42 - ### Cold Spots
43 + ### Cold Spots (all resolved or incorrect, verified 2026-04-22)
43 44
44 - - ~~**JS Frontend (Testing): D**~~ -- 55 automated JS tests covering state management, utilities, sources sidebar, items list, and settings-sync 4-state flow. Upgraded D -> A-.
45 -
46 - - ~~**Rhai Plugins (Security): B**~~ -- All 4 gaps resolved: request count limit (100/fetch), response size cap (2 MB), URL scheme + private range blocking, aggregate fetch timeout (60s). Upgraded B -> A.
47 -
48 - - **Resilience: No circuit breaker** -- Feed health is tracked (consecutive_failures) and displayed (green/yellow/red), but no mechanism to auto-disable a feed after N consecutive failures. A dead feed retries every 60 seconds forever.
49 -
50 - - **Sync changelog unbounded** -- `cleanup_pushed_changelog` runs after each push, but if sync is disconnected or failing, the changelog grows without limit. No retention cap.
51 -
52 - - **FTS query injection** -- `sanitize_fts_query` strips standard FTS5 operators but may not cover all edge cases (`NEAR`, `column:` prefix syntax). Low risk (local SQLite DB) but worth hardening.
45 + 1. ~~**bb-feed: 0 tests**~~ -- Incorrect finding. bb-feed has 110 tests. Audit ran without `--workspace`.
46 + 2. ~~**Manual XSS risks**~~ -- Incorrect finding. All user content escaped with escapeHtml()/escapeAttr(). Rich HTML uses sanitizeHtml().
47 + 3. ~~**db/feed not instrumented**~~ -- Fixed 2026-04-22. 174 instrument annotations added to crates.
48 + 4. ~~**FTS query injection**~~ -- Incorrect finding. sanitize_fts_query quotes all terms (neutralizes NEAR, column: prefix). Tests confirm at lines 1862-1874.
53 49
54 50 ## Mandatory Surprise
55 51
56 - **[RESOLVED] The Rhai plugin runtime had no HTTP request limits or timeout enforcement.**
57 -
58 - All four gaps have been fixed in `host_functions.rs`:
59 -
60 - - **Request count limit:** `MAX_REQUESTS_PER_FETCH = 100` with `check_request_limit()` on every HTTP call.
61 - - **Response size cap:** `MAX_RESPONSE_BYTES = 2 MB` via `.take()` on response reader.
62 - - **URL restriction:** `validate_url()` blocks non-HTTP schemes and localhost/private ranges (10.x, 192.168.x, 172.16-31.x, 169.254.x, [::1], 0.0.0.0).
63 - - **Aggregate fetch timeout:** `MAX_FETCH_DURATION = 60s` with `check_fetch_deadline()` (AtomicU64 epoch-ms deadline) checked on every HTTP call. Prevents 100 x 15s = 25 min worst case.
52 + **Keychain migration race condition (crypto.rs:74-85).**
64 53
65 - **Verdict: Resolved.** The most significant gap in the codebase has been fully addressed with defense-in-depth limits at multiple layers.
54 + The keychain migration path reads the old encryption key from disk, stores it in the OS keychain, then deletes the disk file. If the process crashes between the keychain store and the disk delete, the key exists in both locations -- this is fine (idempotent on next run). However, if the process crashes between reading from disk and storing in the keychain, the key is only on disk -- also fine (retry on next startup).
66 55
67 - ## Metrics Over Time
68 -
69 - | Metric | Audit 1 (2026-02-27) | Audit 2 (2026-02-28) | Audit 3 (2026-02-28) | Audit 4 (2026-03-01) | Audit 5 (2026-03-11) | Audit 6 (2026-03-13) | Adversarial (2026-03-13) | 2026-03-22 |
70 - |--------|:----:|:----:|:----:|:----:|:----:|:----:|:----:|:----:|
71 - | Overall Grade | A | A- | A- | A- | A- | A- | A- | A | A |
72 - | Tests | 178 | 225 | 225 | 315 | 359 | 520 | 536 | 599 | 602 |
73 - | Clippy warnings | 0 | 0 | 8 | 1 | 0 | 0 | 0 | 0 | 0 |
74 - | Unwrap/expect (prod) | 6 | 3 | 3 | 3 | 4 | 7 | 7 | 7+111 | 7+111 |
75 - | Rust source LOC | ~5,400 | ~5,400 | ~5,400 | ~5,400 | ~7,600 | ~7,600 | ~7,600 | ~7,600 | ~7,600 |
76 - | JS LOC | 1,658 | 1,658 | 1,658 | 1,658 | 2,140 | 2,140 | 2,140 | 2,140 | 2,140 |
77 - | Cold spots | 5 | 5 | 2 | 8 | 5 | 4 | 4 | 1 | 0 |
56 + The actual race condition is: if two instances of the app start simultaneously during migration, both read the disk key, both try to store in the keychain, and one may overwrite the other's store. Since both are storing the same key value, this is technically safe, but the second instance may also try to delete the disk file while the first is still reading it. On macOS, this is a benign race (the file handle remains valid), but on Windows, the delete could fail or cause the first instance's read to fail.
78 57
79 - ---
58 + **Verdict:** Low practical risk -- simultaneous app launches during first-time migration is unlikely. But the migration should use a file lock or atomic rename to be fully correct.
80 59
81 - See [audit_history.md](./audit_history.md) for full chronological audit log.
60 + ### Previous Surprise
82 61
83 - ---
62 + **Rhai plugin runtime HTTP limits** -- all 4 gaps resolved (request count limit, response size cap, URL restriction, aggregate fetch timeout). Verdict: Resolved.
84 63
85 64 ## Strengths
86 65
87 66 - **Zero `.unwrap()` in business logic.** Every fallible operation uses `Result`, `unwrap_or_else`, or `unwrap_or_default` with reasoning comments. Only 4 unwrap/expect in startup/static contexts.
88 67
89 - - **All SQL is parameterized.** Every query in `repository.rs` (1,822 lines) uses `?N` placeholders with `.bind()`. The `sanitize_fts_query` function wraps search terms in double quotes to prevent FTS5 operator injection. Dynamic placeholder strings built safely.
90 -
91 - - **Defense-in-depth security.** Multiple independent layers: Rhai engine sandboxed (100k ops, depth 128), AES-256-GCM encryption for secrets at rest with versioned format, HTML sanitization strips dangerous elements, XML escaping in OPML export, URL tracker parameter stripping, extensive input validation.
68 + - **All SQL is parameterized.** Every query in `repository.rs` uses `?N` placeholders with `.bind()`. Dynamic placeholder strings built safely.
92 69
93 - - **Clean crate boundaries.** `bb-interface` defines types/traits with zero impl deps. `bb-db` handles persistence with no plugin knowledge. `bb-core` orchestrates without touching SQL. `bb-feed` handles aggregation. No circular dependencies.
70 + - **Defense-in-depth security.** Multiple independent layers: Rhai engine sandboxed (100k ops, depth 128), AES-256-GCM encryption for secrets at rest, HTML sanitization, XML escaping in OPML export, URL tracker parameter stripping.
94 71
95 - - **Comprehensive test suite (602 tests).** Coverage spans crypto roundtrips, FTS5 search, feed health, tag CRUD, stale cleanup, upsert conflict, plugin manager lifecycle, Rhai conversions (50+ tests), ordering (25 tests), generator pagination, command integration (50 tests), sync service.
72 + - **Clean crate boundaries.** `bb-interface` defines types/traits with zero impl deps. `bb-db` handles persistence with no plugin knowledge. `bb-core` orchestrates without touching SQL. No circular dependencies.
96 73
97 - - **Well-designed sync integration.** FK-safe ordering on remote changes, table column whitelists preventing arbitrary data injection, `applying_remote` flag prevents changelog loops, exponential backoff with 15-minute cap.
74 + - **Well-designed sync integration.** FK-safe ordering, table column whitelists, `applying_remote` flag prevents changelog loops, exponential backoff with 15-minute cap.
98 75
99 76 ## Weaknesses
100 77
101 - - ~~**Rhai HTTP functions with no limits.**~~ All 4 gaps resolved: request count limit (100/fetch), response size cap (2 MB), URL scheme + private range blocking, aggregate fetch timeout (60s).
78 + ### 1. ~~Massive test count regression (-392 tests)~~ (False finding)
79 + Audit ran `cargo test` without `--workspace`. BB uses `default-members = ["src-tauri"]`, so only 210 of 601 tests were counted. Verified 2026-04-22.
102 80
103 - - ~~**No circuit breaker.**~~ Circuit breaker implemented (migration 008, threshold 10, skip in auto-fetch, reset API, event emission).
81 + ### 2. ~~bb-feed has 0 tests~~ (False finding)
82 + bb-feed has 110 tests. Same `--workspace` issue. Verified 2026-04-22.
104 83
105 - - ~~**Sync changelog unbounded growth.**~~ Changelog cap at 10,000 entries with `enforce_changelog_retention()` on sync tick.
84 + ### 3. ~~Manual XSS risks~~ (False finding)
85 + All user content (feed titles, authors, descriptions, tags) escaped with escapeHtml()/escapeAttr(). Rich HTML body uses sanitizeHtml(). Verified 2026-04-22.
106 86
107 - - ~~**Zero automated JS tests.**~~ 55 JS tests covering state management, utilities, sources sidebar rendering, items rendering, and settings-sync 4-state flow.
87 + ### 4. ~~FTS query injection~~ (False finding)
88 + sanitize_fts_query wraps every word in double quotes, which neutralizes all FTS5 operators including NEAR and column: prefixes. Tests confirm this at lines 1862-1874 of repository.rs. Verified 2026-04-22.
108 89
109 - - **FTS query sanitization gaps.** `sanitize_fts_query` strips standard operators but may not cover `NEAR` or `column:` prefix syntax. Low risk (local DB) but incomplete.
90 + ### 5. ~~Sparse documentation~~ (Fixed)
91 + All public items now have `///` doc comments. 14 doc files in docs/. Fixed 2026-04-22.
110 92
111 93 ## Competitive Comparison
112 94
113 95 Based on `docs/apps/bb/competition.md`, BB holds a unique position as the only native desktop feed reader with a user-scriptable plugin system.
114 96
115 97 **Gaps closed since the competition analysis was written:**
116 - - Full-text search: Implemented via FTS5
117 - - Tags/categories: Feed tags with junction table, sidebar filter bar
118 - - URL tracker stripping: Implemented in `url_cleaner.rs`
119 - - JSON Feed format: Implemented in `conversions.rs` (1.0/1.1)
120 - - Feed config validation: Schema-aware validation in `create_feed`
121 - - Secret encryption: AES-256-GCM with versioned format
122 - - Feed health monitoring: consecutive_failures tracking, health dots
123 - - Stale item cleanup: Background task every 6h
124 - - Theming: 9 TOML themes, bundled + user custom
125 - - Cloud sync: SyncKit integration with E2E encryption
126 -
127 - **Remaining competitive gaps (from competition.md priority list):**
98 + - Full-text search, Tags/categories, URL tracker stripping, JSON Feed format, Feed config validation, Secret encryption, Feed health monitoring, Stale item cleanup, Theming, Cloud sync
99 +
100 + **Remaining competitive gaps:**
128 101 1. Reader view / full-article fetch -- planned as a plugin (Phase 5)
129 102 2. Filter/query feeds (virtual feeds from rules) -- Phase 5
130 103 3. Mobile support -- deferred (Tauri mobile)
131 104
132 105 ## Action Items
133 106
134 - All resolved from prior audits. New items from fifth audit filed in `docs/apps/bb/todo.md`.
107 + ### Run 15 (2026-04-18, corrected 2026-04-22)
108 + 1. ~~**[CRITICAL]** Investigate test regression~~ -- False finding. 601 tests with `--workspace`.
109 + 2. ~~**[HIGH]** Add tests to bb-feed~~ -- False finding. bb-feed has 110 tests.
110 + 3. ~~**[MEDIUM]** Audit and fix manual XSS gaps~~ -- False finding. All content properly escaped.
111 + 4. ~~**[MEDIUM]** Add tracing instrumentation to db/feed modules~~ -- Done (174 annotations added).
112 + 5. ~~**[LOW]** Harden FTS query sanitization~~ -- False finding. Already secure (terms quoted).
113 + 6. ~~**[LOW]** Improve documentation coverage~~ -- Done. All public items documented.
114 +
115 + ### Previous action items
116 + - ~~Circuit breaker~~ -- FIXED (migration 008, threshold 10, skip in auto-fetch, reset API)
117 + - Changelog maintenance -- PARTIALLY FIXED
118 + - ~~FTS injection~~ -- RESOLVED (was already secure, terms quoted)
135 119
136 120 ### Resolved (prior audits)
137 121 - ~~Fix single-quote XSS in tag filter~~ -- sources.js uses addEventListener
138 122 - ~~Set 0600 permissions on encryption.key~~ -- crypto.rs sets 0o600
139 123 - ~~Replace `.expect("poisoned")`~~ -- replaced with error propagation
140 - - ~~Remove deprecated health stubs~~ -- markFailed/clearFailed/clearAllFailed removed
124 + - ~~Remove deprecated health stubs~~ -- removed
141 125 - ~~Add `data:` URI blocking~~ -- utils.js blocks both javascript: and data:
142 126 - ~~bb-feed generator tests~~ -- tag filtering tests added
143 127 - ~~Tauri command integration tests~~ -- 50 tests added
144 - - ~~README with setup instructions~~ -- README.md at project root
145 128 - ~~Plugin authoring guide~~ -- included in README
146 - - ~~Fix 8 clippy violations~~ -- all resolved
147 - - ~~Fix `.env` PostgreSQL URL~~ -- changed to sqlite
148 - - ~~Fix clippy warning (unused OrderBy import)~~ -- removed
129 + - ~~Fix clippy violations~~ -- all resolved
149 130 - ~~Add repository doc comments~~ -- 46+ methods documented
150 131 - ~~Unify pagination strategy~~ -- standardized page_size+1
151 - - ~~Add background task unit tests~~ -- any_feed_due extracted + tested
152 - - ~~Add search loading indicator~~ -- CSS spinner added
153 132 - ~~Entity ID newtypes~~ -- FeedId, ItemId, BusserStateId, BusserId
154 133 - ~~ApiErrorCode enum~~ -- replaces String error codes
155 134
135 + ## Metrics Over Time
136 +
137 + | Metric | Audit 1 (02-27) | Audit 2 (02-28) | Audit 3 (02-28) | Audit 4 (03-01) | Audit 5 (03-11) | Audit 6 (03-13) | Adversarial (03-13) | 03-22 | Run 12 (03-28) | Run 14 (04-15) | Run 15 (04-18) | Run 15 corrected (04-22) |
138 + |--------|:----:|:----:|:----:|:----:|:----:|:----:|:----:|:----:|:----:|:----:|:----:|:----:|
139 + | Overall Grade | A | A- | A- | A- | A- | A- | A- | A | A | A | B+ | A |
140 + | Tests | 178 | 225 | 225 | 315 | 359 | 520 | 536 | 599 | 602 | 602 | 210 | 601 |
141 + | Clippy warnings | 0 | 0 | 8 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
142 + | Unwrap/expect (prod) | 6 | 3 | 3 | 3 | 4 | 7 | 7 | 7+111 | 7+111 | 7+111 | 7+111 | 7+111 |
143 + | Rust source LOC | ~5,400 | ~5,400 | ~5,400 | ~5,400 | ~7,600 | ~7,600 | ~7,600 | ~7,600 | ~7,600 | ~7,600 | ~23,458 | ~23,458 |
144 + | JS LOC | 1,658 | 1,658 | 1,658 | 1,658 | 2,140 | 2,140 | 2,140 | 2,140 | 2,140 | 2,140 | 2,140 | 2,140 |
145 + | Cold spots | 5 | 5 | 2 | 8 | 5 | 4 | 4 | 1 | 0 | 0 | 4 | 0 |
146 +
147 + ---
148 +
149 + See [audit_history.md](./audit_history.md) for full chronological audit log.
150 +
156 151 ---
157 152
158 153 ## Documentation Review
159 154
160 155 **Last reviewed:** 2026-03-04 (first doc audit)
161 156
162 - ### Overall Doc Grade: A
157 + ### Overall Doc Grade: B-
163 158
164 - Clean doc set. README and plugin_authoring.md are good additions from recent audit work. Main issue was stale test count in todo.md (106 vs 225 actual). description.md is an intentional placeholder.
159 + Doc set exists but is sparse in some areas. Changelog only partially maintained. Module docs need expansion.
165 160
166 161 ### Document Heatmap
167 162
168 163 | Document | Status | Last Verified | Notes |
169 164 |----------|:------:|:-------------:|-------|
170 - | docs/apps/bb/todo.md | Current | 2026-03-11 | Updated with audit 5 action items |
165 + | docs/apps/bb/todo.md | Current | 2026-04-18 | Active task list |
171 166 | docs/apps/bb/description.md | Placeholder | 2026-03-04 | Intentional placeholder |
172 167 | docs/apps/bb/competition.md | Current | 2026-03-04 | Competitive analysis |
173 - | docs/apps/bb/structural_metrics.md | New | 2026-03-11 | Structural metrics from audit 5 |
174 - | docs/apps/bb/stress_test.md | New | 2026-03-11 | Phase 4/5 stress test |
168 + | docs/apps/bb/structural_metrics.md | Stale | 2026-03-11 | Needs update for current LOC/test counts |
169 + | docs/apps/bb/stress_test.md | Current | 2026-03-11 | Phase 4/5 stress test |
175 170 | README.md | Current | 2026-03-04 | Setup instructions |
176 171
177 - ### Stale References Found (Doc Audit)
178 -
179 - | Location | Issue | Resolution |
180 - |----------|-------|------------|
181 - | docs/apps/bb/todo.md | Status line says "106 tests passing" -- actual is 225 | Updated to "225 tests passing" (audit 4) |
182 - | docs/apps/bb/todo.md | Status line updated to 359 tests | Updated (audit 5) |
183 -
184 172 ### Doc Action Items
185 173
186 - - None critical. Test count kept in sync.
174 + - Update structural_metrics.md for current LOC/test counts
175 + - Expand module-level documentation
M docs/todo.md +1 -1