birdclaw 0.3.0 → 0.4.1

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.
@@ -0,0 +1,716 @@
1
+ import { getNativeDb } from "./db";
2
+ import type { Database } from "./sqlite";
3
+ import {
4
+ expandUrls,
5
+ extractUrls,
6
+ type ExpandUrlsOptions,
7
+ } from "./url-expansion";
8
+ import type {
9
+ LinkIndexItem,
10
+ LinkOccurrenceItem,
11
+ LinkSearchItem,
12
+ ProfileRecord,
13
+ TimelineItem,
14
+ TweetEntities,
15
+ TweetMediaItem,
16
+ } from "./types";
17
+
18
+ const DEFAULT_EXPAND_CONCURRENCY = 12;
19
+
20
+ interface TweetUrlEntityLike {
21
+ url?: unknown;
22
+ expandedUrl?: unknown;
23
+ expanded_url?: unknown;
24
+ displayUrl?: unknown;
25
+ display_url?: unknown;
26
+ title?: unknown;
27
+ description?: unknown;
28
+ }
29
+
30
+ interface SourceUrl {
31
+ url: string;
32
+ expandedUrl?: string;
33
+ title?: string;
34
+ description?: string | null;
35
+ }
36
+
37
+ export interface LinkBackfillOptions {
38
+ includeAllUrls?: boolean;
39
+ refresh?: boolean;
40
+ source?: "dm" | "tweet";
41
+ limit?: number;
42
+ concurrency?: number;
43
+ fetchImpl?: ExpandUrlsOptions["fetchImpl"];
44
+ timeoutMs?: number;
45
+ }
46
+
47
+ export interface LinkBackfillResult {
48
+ occurrences: number;
49
+ uniqueUrls: number;
50
+ entityExpansions: number;
51
+ networkExpansions: number;
52
+ cacheExpansions: number;
53
+ misses: number;
54
+ errors: number;
55
+ remainingUnexpanded: number;
56
+ }
57
+
58
+ export interface LinkSearchOptions {
59
+ since?: string;
60
+ until?: string;
61
+ account?: string;
62
+ source?: "dm" | "tweet";
63
+ direction?: "inbound" | "outbound";
64
+ participant?: string;
65
+ mediaType?: "image" | "video" | "gif";
66
+ limit?: number;
67
+ }
68
+
69
+ function parseJsonField<T>(value: unknown, fallback: T): T {
70
+ if (typeof value !== "string" || value.length === 0) {
71
+ return fallback;
72
+ }
73
+
74
+ try {
75
+ return JSON.parse(value) as T;
76
+ } catch {
77
+ return fallback;
78
+ }
79
+ }
80
+
81
+ function getString(value: unknown) {
82
+ return typeof value === "string" && value.length > 0 ? value : undefined;
83
+ }
84
+
85
+ function isIndexedUrl(url: string, includeAllUrls: boolean) {
86
+ if (includeAllUrls) {
87
+ return true;
88
+ }
89
+
90
+ try {
91
+ const host = new URL(url).hostname.toLowerCase();
92
+ return host === "t.co" || host.endsWith(".t.co");
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ function getTweetTarget(url: string) {
99
+ try {
100
+ const parsed = new URL(url);
101
+ const host = parsed.hostname.toLowerCase();
102
+ if (
103
+ host !== "x.com" &&
104
+ host !== "twitter.com" &&
105
+ host !== "mobile.twitter.com" &&
106
+ host !== "www.x.com" &&
107
+ host !== "www.twitter.com"
108
+ ) {
109
+ return {};
110
+ }
111
+
112
+ const parts = parsed.pathname.split("/").filter(Boolean);
113
+ const statusIndex = parts.findIndex(
114
+ (part) => part === "status" || part === "statuses",
115
+ );
116
+ if (statusIndex === -1 || !parts[statusIndex + 1]) {
117
+ return {};
118
+ }
119
+
120
+ const tweetId = parts[statusIndex + 1]?.match(/^\d+/)?.[0];
121
+ const handle =
122
+ parts[statusIndex - 1] && parts[statusIndex - 1] !== "i"
123
+ ? parts[statusIndex - 1]
124
+ : undefined;
125
+ return {
126
+ expandedTweetId: tweetId,
127
+ expandedHandle: handle,
128
+ };
129
+ } catch {
130
+ return {};
131
+ }
132
+ }
133
+
134
+ function normalizeExpansion(item: {
135
+ url: string;
136
+ expandedUrl: string;
137
+ finalUrl: string;
138
+ status: "hit" | "miss" | "error";
139
+ title?: string;
140
+ description?: string | null;
141
+ error?: string;
142
+ source: string;
143
+ updatedAt: string;
144
+ }): LinkIndexItem {
145
+ const target = getTweetTarget(item.finalUrl);
146
+ return {
147
+ shortUrl: item.url,
148
+ expandedUrl: item.expandedUrl,
149
+ finalUrl: item.finalUrl,
150
+ status: item.status,
151
+ expandedTweetId: target.expandedTweetId ?? null,
152
+ expandedHandle: target.expandedHandle ?? null,
153
+ title: item.title ?? null,
154
+ description: item.description ?? null,
155
+ error: item.error ?? null,
156
+ source: item.source,
157
+ updatedAt: item.updatedAt,
158
+ };
159
+ }
160
+
161
+ function upsertExpansion(db: Database, item: LinkIndexItem) {
162
+ db.prepare(`
163
+ insert into url_expansions (
164
+ short_url, expanded_url, final_url, status, expanded_tweet_id,
165
+ expanded_handle, title, description, error, source, updated_at
166
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
167
+ on conflict(short_url) do update set
168
+ expanded_url = excluded.expanded_url,
169
+ final_url = excluded.final_url,
170
+ status = excluded.status,
171
+ expanded_tweet_id = excluded.expanded_tweet_id,
172
+ expanded_handle = excluded.expanded_handle,
173
+ title = excluded.title,
174
+ description = excluded.description,
175
+ error = excluded.error,
176
+ source = excluded.source,
177
+ updated_at = excluded.updated_at
178
+ `).run(
179
+ item.shortUrl,
180
+ item.expandedUrl,
181
+ item.finalUrl,
182
+ item.status,
183
+ item.expandedTweetId,
184
+ item.expandedHandle,
185
+ item.title,
186
+ item.description,
187
+ item.error,
188
+ item.source,
189
+ item.updatedAt,
190
+ );
191
+ }
192
+
193
+ function toTweetEntityUrls(entitiesJson: string): SourceUrl[] {
194
+ const entities = parseJsonField<TweetEntities>(entitiesJson, {});
195
+ const urls = Array.isArray(entities.urls) ? entities.urls : [];
196
+ return urls
197
+ .map((entry: TweetUrlEntityLike) => {
198
+ const url = getString(entry.url);
199
+ const expandedUrl =
200
+ getString(entry.expandedUrl) ?? getString(entry.expanded_url);
201
+ if (!url) {
202
+ return undefined;
203
+ }
204
+ return {
205
+ url,
206
+ ...(expandedUrl ? { expandedUrl } : {}),
207
+ ...(getString(entry.title) ? { title: getString(entry.title) } : {}),
208
+ ...(entry.description !== undefined
209
+ ? { description: getString(entry.description) ?? null }
210
+ : {}),
211
+ };
212
+ })
213
+ .filter((entry): entry is SourceUrl => Boolean(entry));
214
+ }
215
+
216
+ function uniqueSourceUrls(
217
+ text: string,
218
+ entityUrls: SourceUrl[],
219
+ includeAllUrls: boolean,
220
+ ) {
221
+ const byUrl = new Map<string, SourceUrl>();
222
+ for (const url of extractUrls(text)) {
223
+ if (isIndexedUrl(url, includeAllUrls)) {
224
+ byUrl.set(url, { url });
225
+ }
226
+ }
227
+ for (const entityUrl of entityUrls) {
228
+ if (!isIndexedUrl(entityUrl.url, includeAllUrls)) {
229
+ continue;
230
+ }
231
+ byUrl.set(entityUrl.url, { ...byUrl.get(entityUrl.url), ...entityUrl });
232
+ }
233
+ return Array.from(byUrl.values());
234
+ }
235
+
236
+ function rebuildOccurrences(
237
+ db: Database,
238
+ includeAllUrls: boolean,
239
+ source: LinkBackfillOptions["source"],
240
+ ) {
241
+ const insert = db.prepare(`
242
+ insert or replace into link_occurrences (
243
+ source_kind, source_id, source_position, short_url, account_id,
244
+ conversation_id, direction, created_at
245
+ ) values (?, ?, ?, ?, ?, ?, ?, ?)
246
+ `);
247
+ let occurrences = 0;
248
+ let entityExpansions = 0;
249
+ const now = new Date().toISOString();
250
+
251
+ db.transaction(() => {
252
+ if (source) {
253
+ db.prepare("delete from link_occurrences where source_kind = ?").run(
254
+ source,
255
+ );
256
+ } else {
257
+ db.exec("delete from link_occurrences");
258
+ }
259
+
260
+ const dmRows =
261
+ source === "tweet"
262
+ ? []
263
+ : (db
264
+ .prepare(`
265
+ select m.id, m.conversation_id, c.account_id, m.direction, m.created_at, m.text
266
+ from dm_messages m
267
+ join dm_conversations c on c.id = m.conversation_id
268
+ where m.text like '%://%'
269
+ `)
270
+ .all() as Array<Record<string, unknown>>);
271
+ for (const row of dmRows) {
272
+ const urls = uniqueSourceUrls(String(row.text), [], includeAllUrls);
273
+ urls.forEach((entry, index) => {
274
+ insert.run(
275
+ "dm",
276
+ String(row.id),
277
+ index,
278
+ entry.url,
279
+ String(row.account_id),
280
+ String(row.conversation_id),
281
+ String(row.direction),
282
+ String(row.created_at),
283
+ );
284
+ occurrences++;
285
+ });
286
+ }
287
+
288
+ const tweetRows =
289
+ source === "dm"
290
+ ? []
291
+ : (db
292
+ .prepare(`
293
+ select id, account_id, created_at, text, entities_json
294
+ from tweets
295
+ where text like '%://%' or entities_json like '%://%'
296
+ `)
297
+ .all() as Array<Record<string, unknown>>);
298
+ for (const row of tweetRows) {
299
+ const entityUrls = toTweetEntityUrls(String(row.entities_json));
300
+ const urls = uniqueSourceUrls(
301
+ String(row.text),
302
+ entityUrls,
303
+ includeAllUrls,
304
+ );
305
+ urls.forEach((entry, index) => {
306
+ insert.run(
307
+ "tweet",
308
+ String(row.id),
309
+ index,
310
+ entry.url,
311
+ String(row.account_id),
312
+ null,
313
+ null,
314
+ String(row.created_at),
315
+ );
316
+ occurrences++;
317
+ if (entry.expandedUrl) {
318
+ upsertExpansion(
319
+ db,
320
+ normalizeExpansion({
321
+ url: entry.url,
322
+ expandedUrl: entry.expandedUrl,
323
+ finalUrl: entry.expandedUrl,
324
+ status: "hit",
325
+ title: entry.title,
326
+ description: entry.description,
327
+ source: "entity",
328
+ updatedAt: now,
329
+ }),
330
+ );
331
+ entityExpansions++;
332
+ }
333
+ });
334
+ }
335
+ })();
336
+
337
+ return { occurrences, entityExpansions };
338
+ }
339
+
340
+ async function expandWithConcurrency(
341
+ db: Database,
342
+ urls: string[],
343
+ options: LinkBackfillOptions,
344
+ ) {
345
+ const concurrency = Math.max(
346
+ 1,
347
+ Math.min(options.concurrency ?? DEFAULT_EXPAND_CONCURRENCY, 64),
348
+ );
349
+ const counts = {
350
+ networkExpansions: 0,
351
+ cacheExpansions: 0,
352
+ misses: 0,
353
+ errors: 0,
354
+ };
355
+ let nextIndex = 0;
356
+
357
+ async function worker() {
358
+ for (;;) {
359
+ const index = nextIndex++;
360
+ const url = urls[index];
361
+ if (!url) {
362
+ return;
363
+ }
364
+ const result = (
365
+ await expandUrls([url], {
366
+ refresh: options.refresh,
367
+ fetchImpl: options.fetchImpl,
368
+ timeoutMs: options.timeoutMs,
369
+ })
370
+ )[0]!;
371
+ if (result.source === "network") {
372
+ counts.networkExpansions++;
373
+ } else {
374
+ counts.cacheExpansions++;
375
+ }
376
+ if (result.status === "miss") {
377
+ counts.misses++;
378
+ }
379
+ if (result.status === "error") {
380
+ counts.errors++;
381
+ }
382
+ upsertExpansion(db, normalizeExpansion(result));
383
+ }
384
+ }
385
+
386
+ await Promise.all(
387
+ Array.from({ length: Math.min(concurrency, urls.length) }, () => worker()),
388
+ );
389
+ return counts;
390
+ }
391
+
392
+ export async function backfillLinkIndex(
393
+ options: LinkBackfillOptions = {},
394
+ ): Promise<LinkBackfillResult> {
395
+ const db = getNativeDb({ seedDemoData: false });
396
+ const { occurrences, entityExpansions } = rebuildOccurrences(
397
+ db,
398
+ Boolean(options.includeAllUrls),
399
+ options.source,
400
+ );
401
+ const limit =
402
+ typeof options.limit === "number" && Number.isFinite(options.limit)
403
+ ? Math.max(0, Math.trunc(options.limit))
404
+ : undefined;
405
+ const needsExpansionClause = options.refresh
406
+ ? "1 = 1"
407
+ : "(e.short_url is null or e.status in ('error', 'miss'))";
408
+
409
+ const urlsToExpand = db
410
+ .prepare(`
411
+ select distinct o.short_url
412
+ from link_occurrences o
413
+ left join url_expansions e on e.short_url = o.short_url
414
+ where ${needsExpansionClause}
415
+ ${options.source ? "and o.source_kind = ?" : ""}
416
+ order by o.short_url
417
+ ${limit === undefined ? "" : "limit ?"}
418
+ `)
419
+ .all(
420
+ ...(options.source ? [options.source] : []),
421
+ ...(limit === undefined ? [] : [limit]),
422
+ ) as Array<{
423
+ short_url: string;
424
+ }>;
425
+
426
+ const expansionCounts = await expandWithConcurrency(
427
+ db,
428
+ urlsToExpand.map((row) => row.short_url),
429
+ options,
430
+ );
431
+
432
+ const uniqueUrls = db
433
+ .prepare(`
434
+ select count(distinct short_url) as count
435
+ from link_occurrences
436
+ ${options.source ? "where source_kind = ?" : ""}
437
+ `)
438
+ .get(...(options.source ? [options.source] : [])) as { count: number };
439
+ const remaining = db
440
+ .prepare(`
441
+ select count(distinct o.short_url) as count
442
+ from link_occurrences o
443
+ left join url_expansions e on e.short_url = o.short_url
444
+ where (e.short_url is null or e.status in ('error', 'miss'))
445
+ ${options.source ? "and o.source_kind = ?" : ""}
446
+ `)
447
+ .get(...(options.source ? [options.source] : [])) as { count: number };
448
+
449
+ return {
450
+ occurrences,
451
+ uniqueUrls: Number(uniqueUrls.count),
452
+ entityExpansions,
453
+ ...expansionCounts,
454
+ remainingUnexpanded: Number(remaining.count),
455
+ };
456
+ }
457
+
458
+ function likePattern(value: string) {
459
+ return `%${value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_").toLowerCase()}%`;
460
+ }
461
+
462
+ function toProfile(
463
+ row: Record<string, unknown>,
464
+ prefix: string,
465
+ ): ProfileRecord | null {
466
+ if (!row[`${prefix}id`]) {
467
+ return null;
468
+ }
469
+ return {
470
+ id: String(row[`${prefix}id`]),
471
+ handle: String(row[`${prefix}handle`]),
472
+ displayName: String(row[`${prefix}display_name`]),
473
+ bio: String(row[`${prefix}bio`]),
474
+ followersCount: Number(row[`${prefix}followers_count`]),
475
+ followingCount: Number(row[`${prefix}following_count`]),
476
+ avatarHue: Number(row[`${prefix}avatar_hue`]),
477
+ avatarUrl: getString(row[`${prefix}avatar_url`]),
478
+ location: getString(row[`${prefix}location`]),
479
+ url: getString(row[`${prefix}url`]),
480
+ verifiedType: getString(row[`${prefix}verified_type`]),
481
+ entities: parseJsonField<Record<string, unknown>>(
482
+ row[`${prefix}entities_json`],
483
+ {},
484
+ ),
485
+ createdAt: String(row[`${prefix}created_at`]),
486
+ };
487
+ }
488
+
489
+ function toLinkedTweet(row: Record<string, unknown>): TimelineItem | null {
490
+ const author = toProfile(row, "linked_author_");
491
+ if (!row.linked_id || !author) {
492
+ return null;
493
+ }
494
+
495
+ return {
496
+ id: String(row.linked_id),
497
+ accountId: String(row.linked_account_id),
498
+ accountHandle: String(row.linked_account_handle ?? ""),
499
+ kind: String(row.linked_kind) as TimelineItem["kind"],
500
+ text: String(row.linked_text),
501
+ createdAt: String(row.linked_created_at),
502
+ isReplied: Boolean(row.linked_is_replied),
503
+ likeCount: Number(row.linked_like_count),
504
+ mediaCount: Number(row.linked_media_count),
505
+ bookmarked: Boolean(row.linked_bookmarked),
506
+ liked: Boolean(row.linked_liked),
507
+ author,
508
+ entities: parseJsonField<TweetEntities>(row.linked_entities_json, {}),
509
+ media: parseJsonField<TweetMediaItem[]>(row.linked_media_json, []),
510
+ };
511
+ }
512
+
513
+ function toLinkSearchItem(row: Record<string, unknown>): LinkSearchItem {
514
+ const occurrence: LinkOccurrenceItem = {
515
+ sourceKind: String(row.source_kind) as LinkOccurrenceItem["sourceKind"],
516
+ sourceId: String(row.source_id),
517
+ sourcePosition: Number(row.source_position),
518
+ shortUrl: String(row.short_url),
519
+ accountId: getString(row.account_id) ?? null,
520
+ conversationId: getString(row.conversation_id) ?? null,
521
+ direction: getString(row.direction) ?? null,
522
+ createdAt: String(row.created_at),
523
+ };
524
+ return {
525
+ occurrence,
526
+ expansion: {
527
+ shortUrl: String(row.short_url),
528
+ expandedUrl: String(row.expanded_url),
529
+ finalUrl: String(row.final_url),
530
+ status: String(row.status) as LinkIndexItem["status"],
531
+ expandedTweetId: getString(row.expanded_tweet_id) ?? null,
532
+ expandedHandle: getString(row.expanded_handle) ?? null,
533
+ title: getString(row.title) ?? null,
534
+ description: getString(row.description) ?? null,
535
+ error: getString(row.error) ?? null,
536
+ source: String(row.expansion_source),
537
+ updatedAt: String(row.updated_at),
538
+ },
539
+ sourceText: String(row.source_text),
540
+ sourceAuthor: toProfile(row, "source_author_"),
541
+ participant: toProfile(row, "participant_"),
542
+ linkedTweet: toLinkedTweet(row),
543
+ };
544
+ }
545
+
546
+ export function searchLinks(query: string, options: LinkSearchOptions = {}) {
547
+ const db = getNativeDb({ seedDemoData: false });
548
+ const conditions: string[] = [];
549
+ const params: unknown[] = [];
550
+ const searchFields = `
551
+ lower(
552
+ coalesce(e.short_url, '') || ' ' ||
553
+ coalesce(e.expanded_url, '') || ' ' ||
554
+ coalesce(e.final_url, '') || ' ' ||
555
+ coalesce(e.expanded_handle, '') || ' ' ||
556
+ coalesce(e.title, '') || ' ' ||
557
+ coalesce(e.description, '') || ' ' ||
558
+ coalesce(dm.text, '') || ' ' ||
559
+ coalesce(source_tweet.text, '') || ' ' ||
560
+ coalesce(linked.text, '') || ' ' ||
561
+ coalesce(linked_author.handle, '') || ' ' ||
562
+ coalesce(linked_author.display_name, '')
563
+ )
564
+ `;
565
+
566
+ for (const term of query.match(/[\p{L}\p{N}_:.@/-]+/gu) ?? []) {
567
+ conditions.push(`${searchFields} like ? escape '\\'`);
568
+ params.push(likePattern(term));
569
+ }
570
+ if (options.since) {
571
+ conditions.push("o.created_at >= ?");
572
+ params.push(options.since);
573
+ }
574
+ if (options.until) {
575
+ conditions.push("o.created_at < ?");
576
+ params.push(options.until);
577
+ }
578
+ if (options.account) {
579
+ conditions.push("(o.account_id = ? or account.handle = ?)");
580
+ params.push(options.account, options.account.replace(/^@/, ""));
581
+ }
582
+ if (options.source) {
583
+ conditions.push("o.source_kind = ?");
584
+ params.push(options.source);
585
+ }
586
+ if (options.direction) {
587
+ conditions.push("o.direction = ?");
588
+ params.push(options.direction);
589
+ }
590
+ if (options.participant) {
591
+ conditions.push(
592
+ "(participant.handle = ? or participant.display_name like ?)",
593
+ );
594
+ params.push(
595
+ options.participant.replace(/^@/, ""),
596
+ `%${options.participant}%`,
597
+ );
598
+ }
599
+ if (options.mediaType) {
600
+ conditions.push(
601
+ "(linked.media_json like ? or source_tweet.media_json like ?)",
602
+ );
603
+ params.push(
604
+ `%"type":"${options.mediaType}"%`,
605
+ `%"type":"${options.mediaType}"%`,
606
+ );
607
+ }
608
+
609
+ const limit =
610
+ options.limit && Number.isFinite(options.limit)
611
+ ? Math.max(1, Math.trunc(options.limit))
612
+ : 20;
613
+ params.push(limit);
614
+
615
+ const rows = db
616
+ .prepare(`
617
+ select
618
+ o.source_kind,
619
+ o.source_id,
620
+ o.source_position,
621
+ o.short_url,
622
+ o.account_id,
623
+ o.conversation_id,
624
+ o.direction,
625
+ o.created_at,
626
+ e.expanded_url,
627
+ e.final_url,
628
+ e.status,
629
+ e.expanded_tweet_id,
630
+ e.expanded_handle,
631
+ e.title,
632
+ e.description,
633
+ e.error,
634
+ e.source as expansion_source,
635
+ e.updated_at,
636
+ coalesce(dm.text, source_tweet.text, '') as source_text,
637
+ account.handle as account_handle,
638
+ source_author.id as source_author_id,
639
+ source_author.handle as source_author_handle,
640
+ source_author.display_name as source_author_display_name,
641
+ source_author.bio as source_author_bio,
642
+ source_author.followers_count as source_author_followers_count,
643
+ source_author.following_count as source_author_following_count,
644
+ source_author.avatar_hue as source_author_avatar_hue,
645
+ source_author.avatar_url as source_author_avatar_url,
646
+ source_author.location as source_author_location,
647
+ source_author.url as source_author_url,
648
+ source_author.verified_type as source_author_verified_type,
649
+ source_author.entities_json as source_author_entities_json,
650
+ source_author.created_at as source_author_created_at,
651
+ participant.id as participant_id,
652
+ participant.handle as participant_handle,
653
+ participant.display_name as participant_display_name,
654
+ participant.bio as participant_bio,
655
+ participant.followers_count as participant_followers_count,
656
+ participant.following_count as participant_following_count,
657
+ participant.avatar_hue as participant_avatar_hue,
658
+ participant.avatar_url as participant_avatar_url,
659
+ participant.location as participant_location,
660
+ participant.url as participant_url,
661
+ participant.verified_type as participant_verified_type,
662
+ participant.entities_json as participant_entities_json,
663
+ participant.created_at as participant_created_at,
664
+ linked.id as linked_id,
665
+ linked.account_id as linked_account_id,
666
+ linked_account.handle as linked_account_handle,
667
+ linked.kind as linked_kind,
668
+ linked.text as linked_text,
669
+ linked.created_at as linked_created_at,
670
+ linked.is_replied as linked_is_replied,
671
+ linked.like_count as linked_like_count,
672
+ linked.media_count as linked_media_count,
673
+ linked.bookmarked as linked_bookmarked,
674
+ linked.liked as linked_liked,
675
+ linked.entities_json as linked_entities_json,
676
+ linked.media_json as linked_media_json,
677
+ linked_author.id as linked_author_id,
678
+ linked_author.handle as linked_author_handle,
679
+ linked_author.display_name as linked_author_display_name,
680
+ linked_author.bio as linked_author_bio,
681
+ linked_author.followers_count as linked_author_followers_count,
682
+ linked_author.following_count as linked_author_following_count,
683
+ linked_author.avatar_hue as linked_author_avatar_hue,
684
+ linked_author.avatar_url as linked_author_avatar_url,
685
+ linked_author.location as linked_author_location,
686
+ linked_author.url as linked_author_url,
687
+ linked_author.verified_type as linked_author_verified_type,
688
+ linked_author.entities_json as linked_author_entities_json,
689
+ linked_author.created_at as linked_author_created_at
690
+ from link_occurrences o
691
+ join url_expansions e on e.short_url = o.short_url
692
+ left join accounts account on account.id = o.account_id
693
+ left join dm_messages dm
694
+ on o.source_kind = 'dm' and dm.id = o.source_id
695
+ left join dm_conversations conversation
696
+ on conversation.id = o.conversation_id
697
+ left join profiles participant
698
+ on participant.id = conversation.participant_profile_id
699
+ left join tweets source_tweet
700
+ on o.source_kind = 'tweet' and source_tweet.id = o.source_id
701
+ left join profiles source_author
702
+ on source_author.id = source_tweet.author_profile_id
703
+ left join tweets linked
704
+ on linked.id = e.expanded_tweet_id
705
+ left join accounts linked_account
706
+ on linked_account.id = linked.account_id
707
+ left join profiles linked_author
708
+ on linked_author.id = linked.author_profile_id
709
+ ${conditions.length > 0 ? `where ${conditions.join(" and ")}` : ""}
710
+ order by o.created_at desc
711
+ limit ?
712
+ `)
713
+ .all(...params) as Array<Record<string, unknown>>;
714
+
715
+ return rows.map(toLinkSearchItem);
716
+ }