max / balanced_breakfast
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. |
| @@ -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: ®ex::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 |