birdclaw 0.9.3 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 0.9.4 - 2026-07-05
4
+
5
+ ### Fixed
6
+
7
+ - `search tweets` now returns in well under a second on large archives (previously ~60s, minutes for common terms): the FTS5 match set is materialized once instead of SQLite re-running the MATCH scan per timeline edge row, the join order adapts to term frequency, the LIMIT is resolved on an id-only inner query, and snippets are computed only for returned rows.
8
+
3
9
  ## 0.9.3 - 2026-07-04
4
10
 
5
11
  ### Changed
@@ -11166,7 +11166,8 @@ function getTimelineQualityReason(row, lowQualityThreshold) {
11166
11166
  return "keep:long-text";
11167
11167
  }
11168
11168
  var RECENT_TIMELINE_EDGE_CANDIDATES = 5e3;
11169
- function listTimelineItems({
11169
+ var FTS_DRIVE_FROM_MATCHES_MAX = 1e4;
11170
+ function buildTimelineItemsQuery({
11170
11171
  resource,
11171
11172
  account,
11172
11173
  listAccountId,
@@ -11179,13 +11180,12 @@ function listTimelineItems({
11179
11180
  includeReplies = true,
11180
11181
  qualityFilter = "all",
11181
11182
  lowQualityThreshold,
11182
- includeQualityReason = false,
11183
11183
  likedOnly = false,
11184
11184
  bookmarkedOnly = false,
11185
11185
  limit = 18
11186
- }) {
11187
- const db = getReadDb();
11186
+ }, ftsMatchCountHint = 0) {
11188
11187
  const kind = resource === "mentions" ? "mention" : resource;
11188
+ const cteParams = [];
11189
11189
  const params = [];
11190
11190
  const normalizedLowQualityThreshold = normalizeLowQualityThreshold(lowQualityThreshold);
11191
11191
  const shouldDedupeAcrossAccounts = !account || account === "all";
@@ -11198,9 +11198,8 @@ function listTimelineItems({
11198
11198
  `;
11199
11199
  const unwindowedTimelineEdgesCte = timelineEdgesCte;
11200
11200
  let usedRecentEdgeWindow = false;
11201
- let join3 = "";
11202
11201
  let where = "where e.kind = ?";
11203
- let searchSnippetSelect = "";
11202
+ const ftsSearch = search?.trim() ? toFtsSearchQuery(search) : "";
11204
11203
  const canUseRecentEdgeWindow = !likedOnly && !bookmarkedOnly && !listId && !account && !search?.trim() && replyFilter === "all" && !since?.trim() && !until?.trim() && includeReplies && qualityFilter === "all";
11205
11204
  if (likedOnly || bookmarkedOnly) {
11206
11205
  if (likedOnly && bookmarkedOnly) {
@@ -11224,7 +11223,7 @@ function listTimelineItems({
11224
11223
  where kind = ?
11225
11224
  )
11226
11225
  `;
11227
- params.push(collectionKind2);
11226
+ cteParams.push(collectionKind2);
11228
11227
  }
11229
11228
  where = "where 1 = 1";
11230
11229
  } else if (canUseRecentEdgeWindow) {
@@ -11246,11 +11245,11 @@ function listTimelineItems({
11246
11245
  RECENT_TIMELINE_EDGE_CANDIDATES,
11247
11246
  limit * 50
11248
11247
  );
11249
- params.push(kind, candidateLimit);
11248
+ cteParams.push(kind, candidateLimit);
11250
11249
  where = "where e.kind = ?";
11251
11250
  params.push(kind);
11252
11251
  } else {
11253
- params.push(kind);
11252
+ cteParams.push(kind);
11254
11253
  where = "where e.kind = ?";
11255
11254
  params.push(kind);
11256
11255
  }
@@ -11313,16 +11312,32 @@ function listTimelineItems({
11313
11312
  `;
11314
11313
  params.push(listAccountId, listId);
11315
11314
  }
11316
- const ftsSearch = search?.trim() ? toFtsSearchQuery(search) : "";
11315
+ const ftsMatchesCte = ftsSearch ? `, fts_matches as materialized (
11316
+ select tweet_id
11317
+ from tweets_fts
11318
+ where tweets_fts.text match ?
11319
+ )` : "";
11317
11320
  if (ftsSearch) {
11318
- join3 += " join tweets_fts on tweets_fts.tweet_id = t.id ";
11319
- where += " and tweets_fts.text match ?";
11320
- searchSnippetSelect = ", snippet(tweets_fts, 1, '<mark>', '</mark>', '...', 16) as search_snippet";
11321
- params.push(ftsSearch);
11321
+ cteParams.push(ftsSearch);
11322
11322
  }
11323
+ const searchDrivenFrom = ftsMatchCountHint > FTS_DRIVE_FROM_MATCHES_MAX ? `tweets t
11324
+ cross join fts_matches on fts_matches.tweet_id = t.id
11325
+ cross join timeline_edges e on e.tweet_id = t.id` : `fts_matches
11326
+ cross join tweets t on t.id = fts_matches.tweet_id
11327
+ cross join timeline_edges e on e.tweet_id = t.id`;
11323
11328
  params.push(limit);
11329
+ if (ftsSearch) {
11330
+ params.push(limit);
11331
+ }
11332
+ const searchSelectionCte = ftsSearch ? `, search_selection as materialized (
11333
+ select t.id as tweet_id, e.account_id, e.kind, e.raw_json
11334
+ from ${searchDrivenFrom}
11335
+ ${where}
11336
+ order by t.created_at desc, t.id desc
11337
+ limit ?
11338
+ )` : "";
11324
11339
  const buildTimelineSelectSql = (timelineEdgesSql) => `
11325
- ${timelineEdgesSql}
11340
+ ${timelineEdgesSql}${ftsMatchesCte}${searchSelectionCte}
11326
11341
  select
11327
11342
  t.id,
11328
11343
  e.account_id,
@@ -11399,8 +11414,7 @@ function listTimelineItems({
11399
11414
  qp.avatar_hue as quoted_avatar_hue,
11400
11415
  qp.avatar_url as quoted_avatar_url,
11401
11416
  qp.created_at as quoted_profile_created_at
11402
- ${searchSnippetSelect}
11403
- from timeline_edges e
11417
+ from ${ftsSearch ? "search_selection e" : "timeline_edges e"}
11404
11418
  join tweets t on t.id = e.tweet_id
11405
11419
  join accounts a on a.id = e.account_id
11406
11420
  join profiles p on p.id = t.author_profile_id
@@ -11408,14 +11422,52 @@ function listTimelineItems({
11408
11422
  left join profiles rp on rp.id = rt.author_profile_id
11409
11423
  left join tweets qt on qt.id = t.quoted_tweet_id
11410
11424
  left join profiles qp on qp.id = qt.author_profile_id
11411
- ${join3}
11412
- ${where}
11425
+ ${ftsSearch ? "" : where}
11413
11426
  order by t.created_at desc, t.id desc
11414
11427
  limit ?
11415
11428
  `;
11416
- let rows = db.prepare(buildTimelineSelectSql(timelineEdgesCte)).all(...params);
11417
- if (usedRecentEdgeWindow && rows.length < limit) {
11418
- rows = db.prepare(buildTimelineSelectSql(unwindowedTimelineEdgesCte)).all(kind, kind, limit);
11429
+ return {
11430
+ sql: buildTimelineSelectSql(timelineEdgesCte),
11431
+ params: [...cteParams, ...params],
11432
+ fallbackSql: buildTimelineSelectSql(unwindowedTimelineEdgesCte),
11433
+ fallbackParams: [kind, kind, limit],
11434
+ usedRecentEdgeWindow,
11435
+ ftsSearch
11436
+ };
11437
+ }
11438
+ var SEARCH_SNIPPET_SQL = "snippet(tweets_fts, 1, '<mark>', '</mark>', '...', 16)";
11439
+ function listTimelineItems(query) {
11440
+ const db = getReadDb();
11441
+ const {
11442
+ includeQualityReason = false,
11443
+ lowQualityThreshold,
11444
+ limit = 18
11445
+ } = query;
11446
+ const normalizedLowQualityThreshold = normalizeLowQualityThreshold(lowQualityThreshold);
11447
+ const ftsSearch = query.search?.trim() ? toFtsSearchQuery(query.search) : "";
11448
+ const ftsMatchCount = ftsSearch ? Number(
11449
+ db.prepare(
11450
+ "select count(*) as match_count from tweets_fts where tweets_fts.text match ?"
11451
+ ).get(ftsSearch).match_count
11452
+ ) : 0;
11453
+ const plan = buildTimelineItemsQuery(query, ftsMatchCount);
11454
+ let rows = db.prepare(plan.sql).all(...plan.params);
11455
+ if (plan.usedRecentEdgeWindow && rows.length < limit) {
11456
+ rows = db.prepare(plan.fallbackSql).all(...plan.fallbackParams);
11457
+ }
11458
+ if (plan.ftsSearch && rows.length > 0) {
11459
+ const snippetRows = db.prepare(
11460
+ `select tweet_id, ${SEARCH_SNIPPET_SQL} as search_snippet
11461
+ from tweets_fts
11462
+ where tweets_fts.text match ?
11463
+ and tweet_id in (${rows.map(() => "?").join(",")})`
11464
+ ).all(plan.ftsSearch, ...rows.map((row) => String(row.id)));
11465
+ const snippetByTweetId = new Map(
11466
+ snippetRows.map((row) => [String(row.tweet_id), row.search_snippet])
11467
+ );
11468
+ for (const row of rows) {
11469
+ row.search_snippet = snippetByTweetId.get(String(row.id));
11470
+ }
11419
11471
  }
11420
11472
  const urlExpansionCache = /* @__PURE__ */ new Map();
11421
11473
  const profileByHandleCache = /* @__PURE__ */ new Map();
@@ -8902,9 +8902,10 @@ function getTimelineQualityReason(row, lowQualityThreshold) {
8902
8902
  return "keep:long-text";
8903
8903
  }
8904
8904
  var RECENT_TIMELINE_EDGE_CANDIDATES = 5e3;
8905
- function listTimelineItems({ resource, account, listAccountId, listId, search, replyFilter = "all", since, until, untilId, includeReplies = true, qualityFilter = "all", lowQualityThreshold, includeQualityReason = false, likedOnly = false, bookmarkedOnly = false, limit = 18 }) {
8906
- const db = getReadDb();
8905
+ var FTS_DRIVE_FROM_MATCHES_MAX = 1e4;
8906
+ function buildTimelineItemsQuery({ resource, account, listAccountId, listId, search, replyFilter = "all", since, until, untilId, includeReplies = true, qualityFilter = "all", lowQualityThreshold, likedOnly = false, bookmarkedOnly = false, limit = 18 }, ftsMatchCountHint = 0) {
8907
8907
  const kind = resource === "mentions" ? "mention" : resource;
8908
+ const cteParams = [];
8908
8909
  const params = [];
8909
8910
  const normalizedLowQualityThreshold = normalizeLowQualityThreshold(lowQualityThreshold);
8910
8911
  const shouldDedupeAcrossAccounts = !account || account === "all";
@@ -8917,9 +8918,8 @@ function listTimelineItems({ resource, account, listAccountId, listId, search, r
8917
8918
  `;
8918
8919
  const unwindowedTimelineEdgesCte = timelineEdgesCte;
8919
8920
  let usedRecentEdgeWindow = false;
8920
- let join = "";
8921
8921
  let where = "where e.kind = ?";
8922
- let searchSnippetSelect = "";
8922
+ const ftsSearch = search?.trim() ? toFtsSearchQuery(search) : "";
8923
8923
  const canUseRecentEdgeWindow = !likedOnly && !bookmarkedOnly && !listId && !account && !search?.trim() && replyFilter === "all" && !since?.trim() && !until?.trim() && includeReplies && qualityFilter === "all";
8924
8924
  if (likedOnly || bookmarkedOnly) {
8925
8925
  if (likedOnly && bookmarkedOnly) timelineEdgesCte = `
@@ -8942,7 +8942,7 @@ function listTimelineItems({ resource, account, listAccountId, listId, search, r
8942
8942
  where kind = ?
8943
8943
  )
8944
8944
  `;
8945
- params.push(collectionKind);
8945
+ cteParams.push(collectionKind);
8946
8946
  }
8947
8947
  where = "where 1 = 1";
8948
8948
  } else if (canUseRecentEdgeWindow) {
@@ -8961,11 +8961,11 @@ function listTimelineItems({ resource, account, listAccountId, listId, search, r
8961
8961
  )
8962
8962
  `;
8963
8963
  const candidateLimit = Math.max(RECENT_TIMELINE_EDGE_CANDIDATES, limit * 50);
8964
- params.push(kind, candidateLimit);
8964
+ cteParams.push(kind, candidateLimit);
8965
8965
  where = "where e.kind = ?";
8966
8966
  params.push(kind);
8967
8967
  } else {
8968
- params.push(kind);
8968
+ cteParams.push(kind);
8969
8969
  where = "where e.kind = ?";
8970
8970
  params.push(kind);
8971
8971
  }
@@ -9014,16 +9014,28 @@ function listTimelineItems({ resource, account, listAccountId, listId, search, r
9014
9014
  `;
9015
9015
  params.push(listAccountId, listId);
9016
9016
  }
9017
- const ftsSearch = search?.trim() ? toFtsSearchQuery(search) : "";
9018
- if (ftsSearch) {
9019
- join += " join tweets_fts on tweets_fts.tweet_id = t.id ";
9020
- where += " and tweets_fts.text match ?";
9021
- searchSnippetSelect = ", snippet(tweets_fts, 1, '<mark>', '</mark>', '...', 16) as search_snippet";
9022
- params.push(ftsSearch);
9023
- }
9017
+ const ftsMatchesCte = ftsSearch ? `, fts_matches as materialized (
9018
+ select tweet_id
9019
+ from tweets_fts
9020
+ where tweets_fts.text match ?
9021
+ )` : "";
9022
+ if (ftsSearch) cteParams.push(ftsSearch);
9023
+ const searchDrivenFrom = ftsMatchCountHint > FTS_DRIVE_FROM_MATCHES_MAX ? `tweets t
9024
+ cross join fts_matches on fts_matches.tweet_id = t.id
9025
+ cross join timeline_edges e on e.tweet_id = t.id` : `fts_matches
9026
+ cross join tweets t on t.id = fts_matches.tweet_id
9027
+ cross join timeline_edges e on e.tweet_id = t.id`;
9024
9028
  params.push(limit);
9029
+ if (ftsSearch) params.push(limit);
9030
+ const searchSelectionCte = ftsSearch ? `, search_selection as materialized (
9031
+ select t.id as tweet_id, e.account_id, e.kind, e.raw_json
9032
+ from ${searchDrivenFrom}
9033
+ ${where}
9034
+ order by t.created_at desc, t.id desc
9035
+ limit ?
9036
+ )` : "";
9025
9037
  const buildTimelineSelectSql = (timelineEdgesSql) => `
9026
- ${timelineEdgesSql}
9038
+ ${timelineEdgesSql}${ftsMatchesCte}${searchSelectionCte}
9027
9039
  select
9028
9040
  t.id,
9029
9041
  e.account_id,
@@ -9100,8 +9112,7 @@ function listTimelineItems({ resource, account, listAccountId, listId, search, r
9100
9112
  qp.avatar_hue as quoted_avatar_hue,
9101
9113
  qp.avatar_url as quoted_avatar_url,
9102
9114
  qp.created_at as quoted_profile_created_at
9103
- ${searchSnippetSelect}
9104
- from timeline_edges e
9115
+ from ${ftsSearch ? "search_selection e" : "timeline_edges e"}
9105
9116
  join tweets t on t.id = e.tweet_id
9106
9117
  join accounts a on a.id = e.account_id
9107
9118
  join profiles p on p.id = t.author_profile_id
@@ -9109,13 +9120,40 @@ function listTimelineItems({ resource, account, listAccountId, listId, search, r
9109
9120
  left join profiles rp on rp.id = rt.author_profile_id
9110
9121
  left join tweets qt on qt.id = t.quoted_tweet_id
9111
9122
  left join profiles qp on qp.id = qt.author_profile_id
9112
- ${join}
9113
- ${where}
9123
+ ${ftsSearch ? "" : where}
9114
9124
  order by t.created_at desc, t.id desc
9115
9125
  limit ?
9116
9126
  `;
9117
- let rows = db.prepare(buildTimelineSelectSql(timelineEdgesCte)).all(...params);
9118
- if (usedRecentEdgeWindow && rows.length < limit) rows = db.prepare(buildTimelineSelectSql(unwindowedTimelineEdgesCte)).all(kind, kind, limit);
9127
+ return {
9128
+ sql: buildTimelineSelectSql(timelineEdgesCte),
9129
+ params: [...cteParams, ...params],
9130
+ fallbackSql: buildTimelineSelectSql(unwindowedTimelineEdgesCte),
9131
+ fallbackParams: [
9132
+ kind,
9133
+ kind,
9134
+ limit
9135
+ ],
9136
+ usedRecentEdgeWindow,
9137
+ ftsSearch
9138
+ };
9139
+ }
9140
+ var SEARCH_SNIPPET_SQL = "snippet(tweets_fts, 1, '<mark>', '</mark>', '...', 16)";
9141
+ function listTimelineItems(query) {
9142
+ const db = getReadDb();
9143
+ const { includeQualityReason = false, lowQualityThreshold, limit = 18 } = query;
9144
+ const normalizedLowQualityThreshold = normalizeLowQualityThreshold(lowQualityThreshold);
9145
+ const ftsSearch = query.search?.trim() ? toFtsSearchQuery(query.search) : "";
9146
+ const plan = buildTimelineItemsQuery(query, ftsSearch ? Number(db.prepare("select count(*) as match_count from tweets_fts where tweets_fts.text match ?").get(ftsSearch).match_count) : 0);
9147
+ let rows = db.prepare(plan.sql).all(...plan.params);
9148
+ if (plan.usedRecentEdgeWindow && rows.length < limit) rows = db.prepare(plan.fallbackSql).all(...plan.fallbackParams);
9149
+ if (plan.ftsSearch && rows.length > 0) {
9150
+ const snippetRows = db.prepare(`select tweet_id, ${SEARCH_SNIPPET_SQL} as search_snippet
9151
+ from tweets_fts
9152
+ where tweets_fts.text match ?
9153
+ and tweet_id in (${rows.map(() => "?").join(",")})`).all(plan.ftsSearch, ...rows.map((row) => String(row.id)));
9154
+ const snippetByTweetId = new Map(snippetRows.map((row) => [String(row.tweet_id), row.search_snippet]));
9155
+ for (const row of rows) row.search_snippet = snippetByTweetId.get(String(row.id));
9156
+ }
9119
9157
  const urlExpansionCache = /* @__PURE__ */ new Map();
9120
9158
  const profileByHandleCache = /* @__PURE__ */ new Map();
9121
9159
  return rows.map((row) => {
@@ -1767,7 +1767,7 @@ var getBaseManifest = getProdBaseManifest;
1767
1767
  var createEarlyHintsForRequest = createEarlyHintsCollector;
1768
1768
  async function loadEntries() {
1769
1769
  const [routerEntry, startEntry, pluginAdapters] = await Promise.all([
1770
- import("./assets/router-B874XqWN.js"),
1770
+ import("./assets/router-DVQ3M870.js"),
1771
1771
  import("./assets/start-5Z2QO8AU.js"),
1772
1772
  import("./assets/empty-plugin-adapters-D9UWiqvJ.js")
1773
1773
  ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "birdclaw",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
4
4
  "description": "Local Twitter memory in SQLite for archives, DMs, likes, bookmarks, and moderation",
5
5
  "homepage": "https://github.com/steipete/birdclaw#readme",
6
6
  "license": "MIT",