diff --git a/CHANGELOG.md b/CHANGELOG.md index 4615102..569b99f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.8.5 - Unreleased +### Fixed + +- Keep large Likes, Bookmarks, and Today digest reads on indexed tweet lookups instead of blocking the web server with quadratic SQLite scans. + ## 0.8.4 - 2026-06-19 ### Changed diff --git a/src/lib/queries.test.ts b/src/lib/queries.test.ts index e2b9ca5..ae4ce99 100644 --- a/src/lib/queries.test.ts +++ b/src/lib/queries.test.ts @@ -711,16 +711,85 @@ describe("birdclaw queries", () => { "bookmarks", "2026-03-09T00:00:00.000Z", ); + insertTestCollection( + db, + "tweet_saved_live", + "likes", + "2026-03-09T00:00:00.000Z", + ); const liked = listTimelineItems({ resource: "home", likedOnly: true }); const bookmarked = listTimelineItems({ resource: "home", bookmarkedOnly: true, }); + const likedAndBookmarked = listTimelineItems({ + resource: "home", + likedOnly: true, + bookmarkedOnly: true, + }); expect(liked.every((item) => item.liked)).toBe(true); expect(bookmarked.map((item) => item.id)).toContain("tweet_saved_live"); expect(bookmarked.every((item) => item.bookmarked)).toBe(true); + expect(likedAndBookmarked).toContainEqual( + expect.objectContaining({ + id: "tweet_saved_live", + liked: true, + bookmarked: true, + }), + ); + }); + + it("keeps date-scoped saved timelines fast with a large collection", () => { + setupTempHome(); + const db = getNativeDb(); + db.exec(` + with recursive sequence(value) as ( + select 1 + union all + select value + 1 from sequence where value < 10000 + ) + insert into tweets ( + id, author_profile_id, text, created_at, is_replied, reply_to_id, + like_count, media_count, entities_json, media_json, quoted_tweet_id + ) + select + 'tweet_saved_perf_' || value, + (select id from profiles order by id limit 1), + 'saved performance fixture ' || value, + '2026-01-01T00:00:00.000Z', + 0, null, 0, 0, '{}', '[]', null + from sequence; + + insert into tweet_collections ( + account_id, tweet_id, kind, collected_at, source, raw_json, updated_at + ) + select + (select id from accounts order by is_default desc, id limit 1), + id, + 'likes', + created_at, + 'test', + '{}', + created_at + from tweets + where id like 'tweet_saved_perf_%'; + `); + + const startedAt = performance.now(); + const items = listTimelineItems({ + resource: "home", + likedOnly: true, + since: "2027-01-01T00:00:00.000Z", + until: "2027-01-02T00:00:00.000Z", + limit: 1667, + }); + const durationMs = performance.now() - startedAt; + + expect(items).toEqual([]); + // The indexed plan is normally single-digit milliseconds; leave ample CI room. + expect(durationMs).toBeLessThan(500); }); it("paginates past a shared created_at boundary using the id cursor", () => { diff --git a/src/lib/timeline-read-model.ts b/src/lib/timeline-read-model.ts index 8d9fc63..8ff0494 100644 --- a/src/lib/timeline-read-model.ts +++ b/src/lib/timeline-read-model.ts @@ -534,12 +534,14 @@ export function listTimelineItems({ qualityFilter === "all"; if (likedOnly || bookmarkedOnly) { + // This CTE is also reused by the all-account dedupe subquery below. Keep + // both passes on tweet lookups so SQLite cannot choose a quadratic kind scan. if (likedOnly && bookmarkedOnly) { timelineEdgesCte = ` with timeline_edges as ( select likes.account_id, likes.tweet_id, 'home' as kind, likes.raw_json - from tweet_collections likes - join tweet_collections bookmarks + from tweet_collections likes indexed by idx_tweet_collections_tweet + join tweet_collections bookmarks indexed by idx_tweet_collections_tweet on bookmarks.account_id = likes.account_id and bookmarks.tweet_id = likes.tweet_id and bookmarks.kind = 'bookmarks' @@ -551,7 +553,7 @@ export function listTimelineItems({ timelineEdgesCte = ` with timeline_edges as ( select account_id, tweet_id, 'home' as kind, raw_json - from tweet_collections + from tweet_collections indexed by idx_tweet_collections_tweet where kind = ? ) `;