Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions src/lib/queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
8 changes: 5 additions & 3 deletions src/lib/timeline-read-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve account-scoped saved timeline index lookups

useTimelineRouteData sends the selected account id for the saved timeline routes, so normal Likes/Bookmarks requests hit this branch with account set and no all-account dedupe subquery. For that case, forcing idx_tweet_collections_tweet makes SQLite scan the whole collection table before applying e.account_id (SCAN tweet_collections USING INDEX idx_tweet_collections_tweet) instead of using the existing idx_tweet_collections_kind_account (kind=? AND account_id=?) lookup, so a multi-account or large like/bookmark database now pays for unrelated rows on account-scoped saved views. Keep the tweet-id hint only for the all-account/dedupe path, or use a separate account-scoped CTE that lets the kind/account index remain usable.

Useful? React with 👍 / 👎.

where kind = ?
)
`;
Expand Down