Skip to main content

max / balanced_breakfast

UX audit fixes: unread filter, mark-all-read, discoverability, learnability Separate unread toggle, mark-all-read, source gear always visible, renamed query feeds, OPML import link, keyboard shortcut tooltips, recommended badge in plugin picker, auto-refresh after first feed, sync encryption clarity, renamed Reading List to Saved Articles, and fuzz fixes.
Co-Authored-By
Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-07 03:26 UTC
Commit: f4684ca65913669078fbd0f677a26ee777c7602b
Parent: de079cd
23 files changed, +429 insertions, -283 deletions
M CONTRIBUTING.md +1 -14
@@ -79,20 +79,7 @@
79 79
80 80 ### Sandbox Limits
81 81
82 - Each plugin runs in an isolated Rhai engine with strict limits:
83 -
84 - | Limit | Value | Purpose |
85 - |-------|-------|---------|
86 - | Max operations | 100,000 | Catches infinite loops |
87 - | Max expression depth | 128 | Prevents stack overflow |
88 - | Max recursion | 32 | Limits call depth |
89 - | HTTP timeout | 15s per request | Prevents hanging |
90 - | Response size | 2 MB per response | Prevents memory exhaustion |
91 - | Max requests | 100 per fetch | Catches runaway fetchers |
92 - | Aggregate timeout | 60s per fetch | Hard ceiling on total fetch time |
93 - | URL restrictions | Block localhost, private IPs | Prevents SSRF |
94 -
95 - Each plugin gets its own engine instance with isolated counters (request count via `Arc<AtomicUsize>`, deadline via `Arc<AtomicU64>`).
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.
96 83
97 84 ### Plugin Style
98 85
M Cargo.lock +30 -1
@@ -3692,6 +3692,16 @@
3692 3692 "rand_core 0.6.4",
3693 3693 ]
3694 3694
3695 + [[package]]
3696 + name = "rand"
3697 + version = "0.9.4"
3698 + source = "registry+https://github.com/rust-lang/crates.io-index"
3699 + checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
3700 + dependencies = [
3701 + "rand_chacha 0.9.0",
3702 + "rand_core 0.9.5",
3703 + ]
3704 +
3695 3705 [[package]]
3696 3706 name = "rand_chacha"
3697 3707 version = "0.2.2"
@@ -3712,6 +3722,16 @@
3712 3722 "rand_core 0.6.4",
3713 3723 ]
3714 3724
3725 + [[package]]
3726 + name = "rand_chacha"
3727 + version = "0.9.0"
3728 + source = "registry+https://github.com/rust-lang/crates.io-index"
3729 + checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
3730 + dependencies = [
3731 + "ppv-lite86",
3732 + "rand_core 0.9.5",
3733 + ]
3734 +
3715 3735 [[package]]
3716 3736 name = "rand_core"
3717 3737 version = "0.5.1"
@@ -3730,6 +3750,15 @@
3730 3750 "getrandom 0.2.17",
3731 3751 ]
3732 3752
3753 + [[package]]
3754 + name = "rand_core"
3755 + version = "0.9.5"
3756 + source = "registry+https://github.com/rust-lang/crates.io-index"
3757 + checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
3758 + dependencies = [
3759 + "getrandom 0.3.4",
3760 + ]
3761 +
3733 3762 [[package]]
3734 3763 name = "rand_hc"
3735 3764 version = "0.2.0"
@@ -5052,7 +5081,7 @@
5052 5081 "chrono",
5053 5082 "keyring",
5054 5083 "parking_lot",
5055 - "rand 0.8.5",
5084 + "rand 0.9.4",
5056 5085 "reqwest 0.12.28",
5057 5086 "serde",
5058 5087 "serde_json",
M Cargo.toml +1 -1
@@ -11,7 +11,7 @@
11 11
12 12 [workspace.package]
13 13 version = "0.3.1"
14 - edition = "2021"
14 + edition = "2024"
15 15 authors = ["BalancedBreakfast Contributors"]
16 16 license-file = "LICENSE"
17 17
@@ -1,7 +1,7 @@
1 1 [package]
2 2 name = "balanced-breakfast-desktop"
3 3 version = "0.3.0"
4 - edition = "2021"
4 + edition = "2024"
5 5
6 6 [[bin]]
7 7 name = "balanced-breakfast-desktop"
@@ -16,14 +16,17 @@
16 16 <input type="text" id="search-input" placeholder="Search... (/) | ? for help" class="search-input">
17 17 <span id="search-spinner" class="search-spinner" aria-hidden="true"></span>
18 18 <label for="sort-select" class="sr-only">Sort order</label>
19 - <select id="sort-select" class="sort-select" title="Sort order">
19 + <button id="unread-toggle" class="btn btn-small" title="Show unread only (U)" aria-pressed="false">Unread</button>
20 + <select id="sort-select" class="sort-select" title="Sort order (S)">
20 21 <option value="chronological">Newest First</option>
21 22 <option value="score">By Score</option>
22 23 <option value="unread">Unread First</option>
23 24 <option value="starred">Starred First</option>
24 25 </select>
25 - <button id="refresh-btn" class="btn btn-primary" title="Refresh all feeds">Refresh</button>
26 + <button id="mark-all-read-btn" class="btn btn-small" title="Mark all as read (Shift+A)">Mark All Read</button>
27 + <button id="refresh-btn" class="btn btn-primary" title="Refresh all feeds (R)">Refresh</button>
26 28 <button id="add-feed-btn" class="btn btn-success" title="Add a new feed source">+ Add Feed</button>
29 + <button id="help-btn" class="btn btn-small" title="Keyboard shortcuts (?)" aria-label="Help">?</button>
27 30 <button id="settings-btn" class="btn btn-small" title="Settings" aria-label="Settings">&#9881;</button>
28 31 </div>
29 32 </header>
@@ -41,8 +44,8 @@
41 44 </li>
42 45 </ul>
43 46 </nav>
44 - <div id="saved-articles-btn" class="sidebar-saved" title="Reading List">
45 - <span class="source-name">Reading List</span>
47 + <div id="saved-articles-btn" class="sidebar-saved" title="Saved Articles">
48 + <span class="source-name">Saved Articles</span>
46 49 <span id="saved-count" class="source-count">0</span>
47 50 </div>
48 51 <div class="sidebar-footer">
@@ -17,22 +17,22 @@
17 17 common::insert_item(&db, &feed, "rss:mr2", "Article 2", 2).await;
18 18 common::insert_item(&db, &feed, "rss:mr3", "Article 3", 3).await;
19 19
20 - let gen = FeedGenerator::new(db.clone());
20 + let fg = FeedGenerator::new(db.clone());
21 21
22 22 // All 3 unread initially
23 - assert_eq!(gen.unread_count().await.unwrap(), 3);
23 + assert_eq!(fg.unread_count().await.unwrap(), 3);
24 24
25 25 // Mark one read via generator (uses external_id lookup)
26 - gen.mark_read("rss:mr1", true).await.unwrap();
27 - assert_eq!(gen.unread_count().await.unwrap(), 2);
26 + fg.mark_read("rss:mr1", true).await.unwrap();
27 + assert_eq!(fg.unread_count().await.unwrap(), 2);
28 28
29 29 // Mark another read
30 - gen.mark_read("rss:mr2", true).await.unwrap();
31 - assert_eq!(gen.unread_count().await.unwrap(), 1);
30 + fg.mark_read("rss:mr2", true).await.unwrap();
31 + assert_eq!(fg.unread_count().await.unwrap(), 1);
32 32
33 33 // Mark one back to unread
34 - gen.mark_read("rss:mr1", false).await.unwrap();
35 - assert_eq!(gen.unread_count().await.unwrap(), 2);
34 + fg.mark_read("rss:mr1", false).await.unwrap();
35 + assert_eq!(fg.unread_count().await.unwrap(), 2);
36 36 }
37 37
38 38 #[tokio::test]
@@ -42,15 +42,15 @@
42 42 common::insert_item(&db, &feed, "rss:ms1", "Article 1", 1).await;
43 43 common::insert_item(&db, &feed, "rss:ms2", "Article 2", 2).await;
44 44
45 - let gen = FeedGenerator::new(db.clone());
46 - assert_eq!(gen.unread_count().await.unwrap(), 2);
45 + let fg = FeedGenerator::new(db.clone());
46 + assert_eq!(fg.unread_count().await.unwrap(), 2);
47 47
48 48 // Starring should not change unread count
49 - gen.mark_starred("rss:ms1", true).await.unwrap();
50 - assert_eq!(gen.unread_count().await.unwrap(), 2);
49 + fg.mark_starred("rss:ms1", true).await.unwrap();
50 + assert_eq!(fg.unread_count().await.unwrap(), 2);
51 51
52 - gen.mark_starred("rss:ms1", false).await.unwrap();
53 - assert_eq!(gen.unread_count().await.unwrap(), 2);
52 + fg.mark_starred("rss:ms1", false).await.unwrap();
53 + assert_eq!(fg.unread_count().await.unwrap(), 2);
54 54 }
55 55
56 56 #[tokio::test]
@@ -59,11 +59,11 @@
59 59 let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await;
60 60 common::insert_item(&db, &feed, "rss:1", "Article", 1).await;
61 61
62 - let gen = FeedGenerator::new(db.clone());
62 + let fg = FeedGenerator::new(db.clone());
63 63 // Should not panic or error
64 - gen.mark_read("nonexistent:999", true).await.unwrap();
64 + fg.mark_read("nonexistent:999", true).await.unwrap();
65 65 // Original item should remain unread
66 - assert_eq!(gen.unread_count().await.unwrap(), 1);
66 + assert_eq!(fg.unread_count().await.unwrap(), 1);
67 67 }
68 68
69 69 // ── Ordering ─────────────────────────────────────────────────────────
@@ -102,8 +102,8 @@
102 102 .unwrap();
103 103 }
104 104
105 - let gen = FeedGenerator::new(db).with_order(bb_feed::OrderBy::Score);
106 - let result = gen.get_items(0).await.unwrap();
105 + let fg = FeedGenerator::new(db).with_order(bb_feed::OrderBy::Score);
106 + let result = fg.get_items(0).await.unwrap();
107 107 assert_eq!(result.items.len(), 3);
108 108 assert_eq!(result.items[0].meta.score, Some(100));
109 109 assert_eq!(result.items[1].meta.score, Some(10));
@@ -119,8 +119,8 @@
119 119
120 120 db.items().mark_starred(item2.id, true).await.unwrap();
121 121
122 - let gen = FeedGenerator::new(db).with_order(bb_feed::OrderBy::StarredFirst);
123 - let result = gen.get_items(0).await.unwrap();
122 + let fg = FeedGenerator::new(db).with_order(bb_feed::OrderBy::StarredFirst);
123 + let result = fg.get_items(0).await.unwrap();
124 124 assert!(result.items[0].is_starred, "starred item should sort first");
125 125 assert!(!result.items[1].is_starred);
126 126 // item1 should not have id leakage
@@ -136,8 +136,8 @@
136 136
137 137 db.items().mark_read(item1.id, true).await.unwrap();
138 138
139 - let gen = FeedGenerator::new(db).with_order(bb_feed::OrderBy::UnreadFirst);
140 - let result = gen.get_items(0).await.unwrap();
139 + let fg = FeedGenerator::new(db).with_order(bb_feed::OrderBy::UnreadFirst);
140 + let result = fg.get_items(0).await.unwrap();
141 141 // UnreadFirst groups items by read status — verify they're grouped
142 142 let first_read = result.items[0].is_read;
143 143 let last_read = result.items[1].is_read;
@@ -154,8 +154,8 @@
154 154 common::insert_item(&db, &feed, "rss:2", "B", 2).await;
155 155 common::insert_item(&db, &feed, "rss:3", "C", 3).await;
156 156
157 - let gen = FeedGenerator::new(db);
158 - assert_eq!(gen.count().await.unwrap(), 3);
157 + let fg = FeedGenerator::new(db);
158 + assert_eq!(fg.count().await.unwrap(), 3);
159 159 }
160 160
161 161 #[tokio::test]
@@ -167,8 +167,8 @@
167 167 common::insert_item(&db, &feed_hn, "hn:1", "B", 1).await;
168 168 common::insert_item(&db, &feed_hn, "hn:2", "C", 2).await;
169 169
170 - let gen = FeedGenerator::new(db.clone()).with_filter(FeedFilter::new().source("hn"));
171 - assert_eq!(gen.count().await.unwrap(), 2);
170 + let fg = FeedGenerator::new(db.clone()).with_filter(FeedFilter::new().source("hn"));
171 + assert_eq!(fg.count().await.unwrap(), 2);
172 172
173 173 // Source that doesn't exist
174 174 let gen2 = FeedGenerator::new(db).with_filter(FeedFilter::new().source("nonexistent"));
@@ -185,6 +185,6 @@
185 185
186 186 db.items().mark_read(item1.id, true).await.unwrap();
187 187
188 - let gen = FeedGenerator::new(db).with_filter(FeedFilter::new().unread_only());
189 - assert_eq!(gen.count().await.unwrap(), 2);
188 + let fg = FeedGenerator::new(db).with_filter(FeedFilter::new().unread_only());
189 + assert_eq!(fg.count().await.unwrap(), 2);
190 190 }
@@ -296,10 +296,10 @@
296 296 common::insert_item(&db, &feed_rss, "rss:2", "RSS 2", 2).await;
297 297 common::insert_item(&db, &feed_hn, "hn:1", "HN 1", 1).await;
298 298
299 - let gen = FeedGenerator::new(db.clone());
299 + let fg = FeedGenerator::new(db.clone());
300 300
301 301 // Initial state: RSS has 2 unread, HN has 1 unread
302 - let sources = gen.get_sources().await.unwrap();
302 + let sources = fg.get_sources().await.unwrap();
303 303 let rss = sources.iter().find(|s| s.id == "rss").unwrap();
304 304 assert_eq!(rss.unread_count, 2);
305 305 let hn = sources.iter().find(|s| s.id == "hn").unwrap();
@@ -308,7 +308,7 @@
308 308 // Mark one RSS item read
309 309 db.items().mark_read(rss1.id, true).await.unwrap();
310 310
311 - let sources = gen.get_sources().await.unwrap();
311 + let sources = fg.get_sources().await.unwrap();
312 312 let rss = sources.iter().find(|s| s.id == "rss").unwrap();
313 313 assert_eq!(rss.unread_count, 1);
314 314 assert_eq!(rss.total_count, 2); // total unchanged
@@ -337,8 +337,8 @@
337 337 assert!(refreshed.circuit_broken);
338 338
339 339 // Replicate the health mapping from sources.rs
340 - let gen = FeedGenerator::new(db.clone());
341 - let sources = gen.get_sources().await.unwrap();
340 + let fg = FeedGenerator::new(db.clone());
341 + let sources = fg.get_sources().await.unwrap();
342 342 let source = &sources[0];
343 343
344 344 // The sources.rs command checks circuit_broken first, before failure count
@@ -372,8 +372,8 @@
372 372 // Reset via the repository method (mirrors reset_circuit_breaker command)
373 373 db.feeds().reset_circuit_breaker(feed.id).await.unwrap();
374 374
375 - let gen = FeedGenerator::new(db.clone());
376 - let sources = gen.get_sources().await.unwrap();
375 + let fg = FeedGenerator::new(db.clone());
376 + let sources = fg.get_sources().await.unwrap();
377 377 let source = &sources[0];
378 378
379 379 assert!(!source.circuit_broken);
@@ -409,8 +409,8 @@
409 409 .await
410 410 .unwrap();
411 411
412 - let gen = FeedGenerator::new(db);
413 - let sources = gen.get_sources().await.unwrap();
412 + let fg = FeedGenerator::new(db);
413 + let sources = fg.get_sources().await.unwrap();
414 414
415 415 let rss = sources.iter().find(|s| s.id == "rss").unwrap();
416 416 assert!(rss.tags.contains(&"tech".to_string()));
@@ -428,8 +428,8 @@
428 428 common::create_rss_feed(&db, "Empty RSS", "https://example.com/rss").await;
429 429 common::create_other_feed(&db, "hn", "Empty HN").await;
430 430
431 - let gen = FeedGenerator::new(db);
432 - let sources = gen.get_sources().await.unwrap();
431 + let fg = FeedGenerator::new(db);
432 + let sources = fg.get_sources().await.unwrap();
433 433 assert_eq!(sources.len(), 2);
434 434
435 435 for source in &sources {
@@ -115,16 +115,16 @@
115 115 common::insert_item(&db, &feed, &format!("rss:p{i}"), &format!("Item {i}"), i).await;
116 116 }
117 117
118 - let gen = FeedGenerator::new(db).with_page_size(3);
119 - let page0 = gen.get_items(0).await.unwrap();
118 + let fg = FeedGenerator::new(db).with_page_size(3);
119 + let page0 = fg.get_items(0).await.unwrap();
120 120 assert_eq!(page0.items.len(), 3);
121 121 assert!(page0.has_more);
122 122
123 - let page1 = gen.get_items(1).await.unwrap();
123 + let page1 = fg.get_items(1).await.unwrap();
124 124 assert_eq!(page1.items.len(), 3);
125 125 assert!(page1.has_more);
126 126
127 - let page2 = gen.get_items(2).await.unwrap();
127 + let page2 = fg.get_items(2).await.unwrap();
128 128 assert_eq!(page2.items.len(), 1);
129 129 assert!(!page2.has_more);
130 130 }
@@ -138,8 +138,8 @@
138 138 common::insert_item(&db, &feed_hn, "hn:1", "HN Article", 1).await;
139 139 common::insert_item(&db, &feed_hn, "hn:2", "HN Article 2", 2).await;
140 140
141 - let gen = FeedGenerator::new(db).with_filter(FeedFilter::new().source("hn"));
142 - let result = gen.get_items(0).await.unwrap();
141 + let fg = FeedGenerator::new(db).with_filter(FeedFilter::new().source("hn"));
142 + let result = fg.get_items(0).await.unwrap();
143 143 assert_eq!(result.items.len(), 2);
144 144 for item in &result.items {
145 145 assert_eq!(item.id.source, "hn");
@@ -155,8 +155,8 @@
155 155
156 156 db.items().mark_read(item1.id, true).await.unwrap();
157 157
158 - let gen = FeedGenerator::new(db).with_filter(FeedFilter::new().unread_only());
159 - let result = gen.get_items(0).await.unwrap();
158 + let fg = FeedGenerator::new(db).with_filter(FeedFilter::new().unread_only());
159 + let result = fg.get_items(0).await.unwrap();
160 160 assert_eq!(result.items.len(), 1);
161 161 assert!(!result.items[0].is_read);
162 162 }
@@ -170,8 +170,8 @@
170 170
171 171 db.items().mark_starred(item2.id, true).await.unwrap();
172 172
173 - let gen = FeedGenerator::new(db).with_filter(FeedFilter::new().starred_only());
174 - let result = gen.get_items(0).await.unwrap();
173 + let fg = FeedGenerator::new(db).with_filter(FeedFilter::new().starred_only());
174 + let result = fg.get_items(0).await.unwrap();
175 175 assert_eq!(result.items.len(), 1);
176 176 assert!(result.items[0].is_starred);
177 177 }
@@ -307,25 +307,25 @@
307 307
308 308 // Filter: source=rss — gets both RSS items (source branch doesn't also
309 309 // filter by unread; only list_search combines source+unread)
310 - let gen = FeedGenerator::new(db.clone())
310 + let fg = FeedGenerator::new(db.clone())
311 311 .with_filter(FeedFilter::new().source("rss"));
312 - let result = gen.get_items(0).await.unwrap();
312 + let result = fg.get_items(0).await.unwrap();
313 313 assert_eq!(result.items.len(), 2);
314 314 for item in &result.items {
315 315 assert_eq!(item.id.source, "rss");
316 316 }
317 317
318 318 // Filter: starred only — should get rss:2 only
319 - let gen2 = FeedGenerator::new(db.clone())
319 + let fg2 = FeedGenerator::new(db.clone())
320 320 .with_filter(FeedFilter::new().starred_only());
321 - let result2 = gen2.get_items(0).await.unwrap();
321 + let result2 = fg2.get_items(0).await.unwrap();
322 322 assert_eq!(result2.items.len(), 1);
323 323 assert!(result2.items[0].is_starred);
324 324
325 325 // Search + source + unread combines all filters in SQL
326 - let gen3 = FeedGenerator::new(db)
326 + let fg3 = FeedGenerator::new(db)
327 327 .with_filter(FeedFilter::new().search("RSS").source("rss").unread_only());
328 - let result3 = gen3.get_items(0).await.unwrap();
328 + let result3 = fg3.get_items(0).await.unwrap();
329 329 assert_eq!(result3.items.len(), 1);
330 330 assert!(!result3.items[0].is_read);
331 331 }
@@ -335,8 +335,8 @@
335 335 #[tokio::test]
336 336 async fn list_items_empty_database() {
337 337 let db = common::test_db().await;
338 - let gen = FeedGenerator::new(db);
339 - let result = gen.get_items(0).await.unwrap();
338 + let fg = FeedGenerator::new(db);
339 + let result = fg.get_items(0).await.unwrap();
340 340 assert!(result.items.is_empty());
341 341 assert!(!result.has_more);
342 342 }
@@ -344,8 +344,8 @@
344 344 #[tokio::test]
345 345 async fn list_sources_empty_database() {
346 346 let db = common::test_db().await;
347 - let gen = FeedGenerator::new(db);
348 - let sources = gen.get_sources().await.unwrap();
347 + let fg = FeedGenerator::new(db);
348 + let sources = fg.get_sources().await.unwrap();
349 349 assert!(sources.is_empty());
350 350 }
351 351
@@ -354,15 +354,15 @@
354 354 let db = common::test_db().await;
355 355 assert_eq!(db.items().count_unread().await.unwrap(), 0);
356 356
357 - let gen = FeedGenerator::new(db);
358 - assert_eq!(gen.unread_count().await.unwrap(), 0);
357 + let fg = FeedGenerator::new(db);
358 + assert_eq!(fg.unread_count().await.unwrap(), 0);
359 359 }
360 360
361 361 #[tokio::test]
362 362 async fn count_empty_database() {
363 363 let db = common::test_db().await;
364 - let gen = FeedGenerator::new(db);
365 - assert_eq!(gen.count().await.unwrap(), 0);
364 + let fg = FeedGenerator::new(db);
365 + assert_eq!(fg.count().await.unwrap(), 0);
366 366 }
367 367
368 368 // ── Mark Read/Star: Verify Persisted State in Filtered Queries ───────
@@ -375,19 +375,19 @@
375 375 common::insert_item(&db, &feed, "rss:ex2", "Article 2", 2).await;
376 376 common::insert_item(&db, &feed, "rss:ex3", "Article 3", 3).await;
377 377
378 - let gen = FeedGenerator::new(db.clone())
378 + let fg = FeedGenerator::new(db.clone())
379 379 .with_filter(FeedFilter::new().unread_only());
380 380
381 381 // All 3 visible
382 - assert_eq!(gen.get_items(0).await.unwrap().items.len(), 3);
382 + assert_eq!(fg.get_items(0).await.unwrap().items.len(), 3);
383 383
384 384 // Mark one read
385 385 db.items().mark_read(item1.id, true).await.unwrap();
386 386
387 387 // Only 2 visible now
388 - assert_eq!(gen.get_items(0).await.unwrap().items.len(), 2);
388 + assert_eq!(fg.get_items(0).await.unwrap().items.len(), 2);
389 389 // None of the returned items should be the read one
390 - let ids: Vec<String> = gen
390 + let ids: Vec<String> = fg
391 391 .get_items(0)
392 392 .await
393 393 .unwrap()
@@ -406,23 +406,23 @@
406 406 let item2 = common::insert_item(&db, &feed, "rss:st2", "Article 2", 2).await;
407 407 common::insert_item(&db, &feed, "rss:st3", "Article 3", 3).await;
408 408
409 - let gen_starred = FeedGenerator::new(db.clone())
409 + let fg_starred = FeedGenerator::new(db.clone())
410 410 .with_filter(FeedFilter::new().starred_only());
411 411
412 412 // Nothing starred yet
413 - assert!(gen_starred.get_items(0).await.unwrap().items.is_empty());
413 + assert!(fg_starred.get_items(0).await.unwrap().items.is_empty());
414 414
415 415 // Star one item
416 416 db.items().mark_starred(item2.id, true).await.unwrap();
417 417
418 - let result = gen_starred.get_items(0).await.unwrap();
418 + let result = fg_starred.get_items(0).await.unwrap();
419 419 assert_eq!(result.items.len(), 1);
420 420 assert_eq!(result.items[0].id.item_id, "rss:st2");
421 421 assert!(result.items[0].is_starred);
422 422
423 423 // Unstar it
424 424 db.items().mark_starred(item2.id, false).await.unwrap();
425 - assert!(gen_starred.get_items(0).await.unwrap().items.is_empty());
425 + assert!(fg_starred.get_items(0).await.unwrap().items.is_empty());
426 426 }
427 427
428 428 // ── Upsert Preserves User State ──────────────────────────────────────