Skip to main content

max / balanced_breakfast

8.7 KB · 250 lines History Blame Raw
1 # Contributing to Balanced Breakfast
2
3 Patterns, conventions, and rules for working on the Balanced Breakfast codebase.
4
5 ## Project Structure
6
7 ```
8 balanced_breakfast/ (workspace root)
9 Cargo.toml # Workspace definition
10 crates/
11 bb-interface/ # Shared types (leaf crate, no internal deps)
12 bb-core/ # Orchestrator, Rhai plugin runtime, crypto
13 bb-feed/ # Feed aggregation, ordering, scoring
14 bb-db/ # SQLite persistence, repositories
15 src-tauri/
16 src/
17 main.rs # Tauri setup
18 state.rs # AppState, scheduler, abort handles
19 commands/ # Tauri commands (thin wrappers)
20 sync_service.rs # SyncKit change tracking
21 frontend/
22 js/ # JavaScript modules (IIFE + BB.* namespace)
23 css/ # Styles
24 tests/ # Command integration tests
25 plugins/ # Bundled Rhai plugins (rss, mastodon, hackernews, etc.)
26 migrations/sqlite/ # SQLx SQLite migrations
27 ```
28
29 ### Crate Dependency Flow
30
31 ```
32 bb-interface (leaf, shared types)
33
34 bb-core (orchestrator, Rhai runtime)
35 bb-feed (aggregation, ordering)
36 bb-db (SQLite persistence)
37
38 src-tauri (Tauri shell)
39 ```
40
41 **Strict rule:** `bb-interface` has no internal dependencies. Library crates never depend on `src-tauri`.
42
43 ## Orchestrator Pattern
44
45 The `Orchestrator` (`bb-core::orchestrator`) is the central coordinator. It owns the `Database` and a `PluginManager` behind `Arc<RwLock<>>`.
46
47 **Responsibilities:**
48 - Plugin lifecycle (load, init, fetch, shutdown)
49 - Upserting fetched items (with HTML sanitization and URL tracker stripping)
50 - Secret encryption for plugin configs
51 - Circuit breaker enforcement (10 consecutive failures disables auto-fetch)
52
53 **Lock discipline:** Hold the `PluginManager` lock only during synchronous operations. Release before any `.await`:
54
55 ```rust
56 // GOOD: Release lock before async DB ops
57 let fetch_result = {
58 let plugins = self.plugins.read().await;
59 plugins.fetch(plugin_id, None)
60 };
61 // Lock released here, now safe to await
62 self.db.feeds().record_fetch_success(feed_id).await?;
63 ```
64
65 ## Plugin System
66
67 ### Contract: 4 Required Functions
68
69 Every `.rhai` plugin must implement:
70
71 | Function | Returns | Purpose |
72 |----------|---------|---------|
73 | `fn id()` | `String` | Unique identifier (e.g., `"rss"`) |
74 | `fn name()` | `String` | Display name (e.g., `"RSS"`) |
75 | `fn config_schema()` | `Map` | `#{ description, fields: [...] }` |
76 | `fn fetch(config, cursor)` | `Map` | `#{ items: [...], has_more: bool }` |
77
78 Config field types: `Text`, `TextArea`, `Secret`, `Url`, `Number`, `Toggle`, `Select`.
79
80 ### Sandbox Limits
81
82 Each plugin runs in an isolated Rhai engine with strict limits (operations, recursion, HTTP timeout/count, response size, SSRF blocking). Full limit table and isolation details in `docs/architecture.md` § Sandboxing.
83
84 ### Plugin Style
85
86 All `.rhai` plugins follow the cross-project Rhai style guide. 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.
87
88 ### Host Functions
89
90 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.
91
92 ## Thin Tauri Commands
93
94 Commands in `src-tauri/src/commands/` are thin wrappers. They extract parameters, call orchestrator or repository methods, and return serialized response types:
95
96 ```rust
97 #[tauri::command]
98 #[instrument(skip_all)]
99 pub async fn list_items(
100 state: State<'_, Arc<AppState>>,
101 filter: ItemFilter,
102 ) -> Result<Vec<ItemSnapshot>, ApiError> {
103 let db = state.orchestrator.database();
104 let items = db.items().list(filter).await?;
105 Ok(items.into_iter().map(ItemSnapshot::from).collect())
106 }
107 ```
108
109 **Rules:**
110 - Every command: `#[instrument(skip_all)]`, returns `Result<T, ApiError>`
111 - No business logic in commands — that belongs in library crates
112 - Response types use `#[serde(rename_all = "camelCase")]` for JS consumption
113
114 ## Error Types
115
116 All commands return `Result<T, ApiError>`. The error has typed codes:
117
118 ```rust
119 pub enum ApiErrorCode {
120 BadRequest, // User-caused errors
121 NotFound, // Missing resource
122 Database, // DB errors
123 Internal, // Unexpected errors
124 Plugin, // Rhai plugin errors
125 }
126
127 pub struct ApiError {
128 pub code: ApiErrorCode,
129 pub message: String,
130 }
131 ```
132
133 `ApiError` implements `From` for `sqlx::Error`, `FeedError`, and `OrchestratorError`. The frontend receives `{code: "BAD_REQUEST", message: "..."}`.
134
135 ## Circuit Breaker
136
137 Feeds are auto-disabled after 10 consecutive fetch failures:
138
139 - `consecutive_failures` counter increments on each failure, resets to 0 on success.
140 - When the counter reaches 10, `circuit_broken` is set to true.
141 - Circuit-broken feeds skip auto-fetch scheduling.
142 - Users can manually reset via `reset_circuit_breaker` command.
143
144 ## Secret Encryption
145
146 Plugin configs with `Secret`-type fields are encrypted at rest using AES-256-GCM:
147
148 - Format: `bb_enc:v1:<base64(nonce[12] || ciphertext || tag[16])>`
149 - Key stored in OS keychain (via `keyring` crate), with file fallback at `~/.config/balanced-breakfast/encryption.key`
150 - Backward compatible: unencrypted values are read as plaintext, encrypted on next save
151 - `encrypt_config_secrets()` / `decrypt_config_secrets()` operate on JSON values in-place, using the config schema to identify Secret fields
152
153 ## JavaScript Architecture
154
155 ### BB.* Namespace
156
157 All JavaScript lives under the `BB` global namespace (defined in `bb.js`, loaded first):
158
159 ```javascript
160 window.BB = {
161 api: {}, // Tauri IPC wrappers (thin invoke calls)
162 state: {}, // Centralized reactive state (Proxy-based)
163 ui: {}, // Toast, modal, form builder, confirm dialog
164 utils: {}, // escapeHtml, escapeAttr, sanitizeHtml, debounce
165 sources: {}, // Source list, tag filters, feed CRUD
166 items: {}, // Item list, read/star toggle, pagination
167 detail: {}, // Detail panel, reader view
168 feeds: {}, // Feed management, OPML import/export
169 themes: {}, // Theme loading
170 sync: {}, // Cloud sync settings
171 app: {}, // Bootstrap, keyboard shortcuts
172 };
173 ```
174
175 ### Module Pattern
176
177 Every JS file is a strict-mode IIFE:
178
179 ```javascript
180 (function() {
181 'use strict';
182 const { escapeHtml, getErrorMessage } = BB.utils;
183
184 async function load() { /* ... */ }
185 function render(sources) { /* ... */ }
186
187 BB.sources = { load, render };
188 })();
189 ```
190
191 ### State Management
192
193 `BB.state` is a Proxy over a data object with pub/sub:
194
195 ```javascript
196 BB.state.set('sources', data); // Set + notify
197 BB.state.sources; // Direct read
198 BB.state.subscribe('items', (newVal, oldVal) => {}); // Subscribe
199 ```
200
201 ### XSS Prevention
202
203 - `BB.utils.escapeHtml()` for text content
204 - `BB.utils.escapeAttr()` for HTML attributes
205 - `BB.utils.sanitizeHtml()` for feed body content (strips script, iframe, on* handlers, javascript: URLs)
206
207 All user-provided content and feed content must be escaped or sanitized before insertion into the DOM.
208
209 ## Testing
210
211 ### Rust Integration Tests
212
213 Integration tests in `src-tauri/tests/` use in-memory SQLite and bypass Tauri `State<>` wrappers:
214
215 ```rust
216 // Common setup
217 pub async fn setup(suffix: &str) -> Orchestrator {
218 let config = OrchestratorConfig {
219 database_url: "sqlite::memory:".to_string(),
220 plugins_dir: temp_dir.to_string(),
221 fetch_interval_secs: 300,
222 };
223 let orchestrator = Orchestrator::new(config).await.unwrap();
224 orchestrator.migrate().await.unwrap();
225 orchestrator
226 }
227 ```
228
229 Tests exercise command-layer code paths without the Tauri runtime. Test helpers in `tests/common/mod.rs` provide setup, fixture creation, and teardown.
230
231 ### JS Tests
232
233 Node.js test runner at `src-tauri/frontend/js/tests/run.js` with browser API mocks. Run with `node src-tauri/frontend/js/tests/run.js`.
234
235 ### Full Pipeline
236
237 Always verify: plugin fetch → DB upsert → command response → JS render. A bug in any layer can appear as a symptom in another.
238
239 ## When Adding a New Source Type
240
241 1. Create a `.rhai` file in `plugins/` implementing the 4 required functions
242 2. Test it manually: add a feed with the new busser, trigger a fetch
243 3. The orchestrator auto-discovers plugins on startup — no Rust changes needed
244
245 ## When Adding Features
246
247 1. **New data type:** Add to `bb-interface` (types), `bb-db` (persistence), then commands
248 2. **New command:** Add in `src-tauri/src/commands/`, register in `main.rs`
249 3. **New JS module:** Create IIFE file, attach to `BB.moduleName`, add to `index.html`
250