birdclaw 0.8.1 → 0.8.2

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.
@@ -1,5 +1,11 @@
1
1
  import { Data, Effect } from "effect";
2
2
  import { z } from "zod";
3
+ import {
4
+ deleteClientCache,
5
+ deleteClientCacheByPrefix,
6
+ loadClientCache,
7
+ readClientCache,
8
+ } from "./client-cache";
3
9
  import { runEffectPromise } from "./effect-runtime";
4
10
  import type {
5
11
  DmConversationItem,
@@ -105,6 +111,15 @@ const webSyncJobSchema = z
105
111
 
106
112
  const actionResponseSchema = jsonRecordSchema;
107
113
  const SYNC_POLL_INTERVAL_MS = 500;
114
+ const STATUS_CACHE_KEY = "api:/status";
115
+ const QUERY_CACHE_PREFIX = "api:/query:";
116
+ const STATUS_CACHE_MAX_AGE_MS = 60_000;
117
+ const QUERY_CACHE_MAX_AGE_MS = 5 * 60_000;
118
+
119
+ interface ClientFetchCacheOptions {
120
+ force?: boolean;
121
+ maxAgeMs?: number;
122
+ }
108
123
 
109
124
  export class ApiFetchError extends Data.TaggedError("ApiFetchError")<{
110
125
  readonly message: string;
@@ -148,6 +163,55 @@ function runApiEffect<T, E>(effect: Effect.Effect<T, E>) {
148
163
  return runEffectPromise(effect);
149
164
  }
150
165
 
166
+ function splitSignal(init?: RequestInit) {
167
+ if (!init) return { requestInit: undefined, signal: undefined };
168
+ const { signal, ...requestInit } = init;
169
+ return { requestInit, signal: signal ?? undefined };
170
+ }
171
+
172
+ function waitForSignal<T>(promise: Promise<T>, signal?: AbortSignal) {
173
+ if (!signal) return promise;
174
+ if (signal.aborted) {
175
+ return Promise.reject(
176
+ new DOMException("The operation was aborted.", "AbortError"),
177
+ );
178
+ }
179
+
180
+ return new Promise<T>((resolve, reject) => {
181
+ const onAbort = () => {
182
+ reject(new DOMException("The operation was aborted.", "AbortError"));
183
+ };
184
+ signal.addEventListener("abort", onAbort, { once: true });
185
+ promise.then(
186
+ (value) => {
187
+ signal.removeEventListener("abort", onAbort);
188
+ resolve(value);
189
+ },
190
+ (error: unknown) => {
191
+ signal.removeEventListener("abort", onAbort);
192
+ reject(error);
193
+ },
194
+ );
195
+ });
196
+ }
197
+
198
+ function queryCacheKey(input: RequestInfo | URL) {
199
+ const raw =
200
+ typeof input === "string"
201
+ ? input
202
+ : input instanceof URL
203
+ ? input.toString()
204
+ : input.url;
205
+ const base =
206
+ typeof window === "undefined"
207
+ ? "http://birdclaw.local"
208
+ : window.location.origin;
209
+ const url = new URL(raw, base);
210
+ url.searchParams.delete("refresh");
211
+ url.searchParams.sort();
212
+ return `${QUERY_CACHE_PREFIX}${url.pathname}?${url.searchParams.toString()}`;
213
+ }
214
+
151
215
  export function fetchJsonEffect<T>(
152
216
  input: RequestInfo | URL,
153
217
  init: RequestInit | undefined,
@@ -191,8 +255,27 @@ export function fetchJson<T>(
191
255
  return runApiEffect(fetchJsonEffect(input, init, schema, fallbackMessage));
192
256
  }
193
257
 
194
- export function fetchQueryEnvelope(init?: RequestInit) {
195
- return runApiEffect(fetchQueryEnvelopeEffect(init));
258
+ export function readCachedQueryEnvelope() {
259
+ return readClientCache<QueryEnvelope>(
260
+ STATUS_CACHE_KEY,
261
+ STATUS_CACHE_MAX_AGE_MS,
262
+ );
263
+ }
264
+
265
+ export function fetchQueryEnvelope(
266
+ init?: RequestInit,
267
+ {
268
+ force = false,
269
+ maxAgeMs = STATUS_CACHE_MAX_AGE_MS,
270
+ }: ClientFetchCacheOptions = {},
271
+ ) {
272
+ const { requestInit, signal } = splitSignal(init);
273
+ const request = loadClientCache(
274
+ STATUS_CACHE_KEY,
275
+ () => runApiEffect(fetchQueryEnvelopeEffect(requestInit)),
276
+ { force, maxAgeMs },
277
+ );
278
+ return waitForSignal(request, signal);
196
279
  }
197
280
 
198
281
  export function fetchQueryEnvelopeEffect(init?: RequestInit) {
@@ -211,6 +294,38 @@ export function fetchQueryResponse(
211
294
  return runApiEffect(fetchQueryResponseEffect(input, init));
212
295
  }
213
296
 
297
+ export function readCachedQueryResponse(input: RequestInfo | URL) {
298
+ return readClientCache<QueryResponse>(
299
+ queryCacheKey(input),
300
+ QUERY_CACHE_MAX_AGE_MS,
301
+ );
302
+ }
303
+
304
+ export function fetchCachedQueryResponse(
305
+ input: RequestInfo | URL,
306
+ init?: RequestInit,
307
+ {
308
+ force = false,
309
+ maxAgeMs = QUERY_CACHE_MAX_AGE_MS,
310
+ }: ClientFetchCacheOptions = {},
311
+ ) {
312
+ const { requestInit, signal } = splitSignal(init);
313
+ const request = loadClientCache(
314
+ queryCacheKey(input),
315
+ () => runApiEffect(fetchQueryResponseEffect(input, requestInit)),
316
+ { force, maxAgeMs },
317
+ );
318
+ return waitForSignal(request, signal);
319
+ }
320
+
321
+ export function invalidateCachedQueryResponse(input: RequestInfo | URL) {
322
+ deleteClientCache(queryCacheKey(input));
323
+ }
324
+
325
+ export function invalidateCachedQueryResponses() {
326
+ deleteClientCacheByPrefix(QUERY_CACHE_PREFIX);
327
+ }
328
+
214
329
  export function fetchQueryResponseEffect(
215
330
  input: RequestInfo | URL,
216
331
  init?: RequestInit,
@@ -8,11 +8,16 @@ import { runEffectPromise, tryPromise } from "./effect-runtime";
8
8
  import type { ArchiveCandidate } from "./types";
9
9
 
10
10
  const execAsync = promisify(exec);
11
+ const ARCHIVE_DISCOVERY_CACHE_MS = 5 * 60_000;
11
12
  const ARCHIVE_NAME_PATTERNS = [
12
13
  /^twitter-.*\.zip$/i,
13
14
  /^x-.*\.zip$/i,
14
15
  /archive.*\.zip$/i,
15
16
  ];
17
+ let archiveDiscoveryCache:
18
+ | { value: ArchiveCandidate[]; updatedAt: number }
19
+ | undefined;
20
+ let archiveDiscoveryInFlight: Promise<ArchiveCandidate[]> | undefined;
16
21
 
17
22
  function formatFileSize(bytes: number): string {
18
23
  const units = ["B", "KB", "MB", "GB"];
@@ -142,6 +147,39 @@ export function findArchivesEffect(): Effect.Effect<
142
147
  });
143
148
  }
144
149
 
150
+ export function findArchivesCachedEffect(): Effect.Effect<
151
+ ArchiveCandidate[],
152
+ unknown
153
+ > {
154
+ return Effect.suspend(() => {
155
+ const cached = archiveDiscoveryCache;
156
+ if (cached && Date.now() - cached.updatedAt < ARCHIVE_DISCOVERY_CACHE_MS) {
157
+ return Effect.succeed(cached.value);
158
+ }
159
+ if (archiveDiscoveryInFlight) {
160
+ return Effect.promise(() => archiveDiscoveryInFlight!);
161
+ }
162
+
163
+ const request = runEffectPromise(findArchivesEffect())
164
+ .then((value) => {
165
+ archiveDiscoveryCache = { value, updatedAt: Date.now() };
166
+ return value;
167
+ })
168
+ .finally(() => {
169
+ if (archiveDiscoveryInFlight === request) {
170
+ archiveDiscoveryInFlight = undefined;
171
+ }
172
+ });
173
+ archiveDiscoveryInFlight = request;
174
+ return Effect.promise(() => request);
175
+ });
176
+ }
177
+
145
178
  export function findArchives(): Promise<ArchiveCandidate[]> {
146
179
  return runEffectPromise(findArchivesEffect());
147
180
  }
181
+
182
+ export function clearArchiveFinderCacheForTests() {
183
+ archiveDiscoveryCache = undefined;
184
+ archiveDiscoveryInFlight = undefined;
185
+ }
@@ -3,6 +3,7 @@ import { Effect } from "effect";
3
3
  import { getNativeDb } from "./db";
4
4
  import { runEffectPromise, tryPromise } from "./effect-runtime";
5
5
  import { readSyncCache, writeSyncCache } from "./sync-cache";
6
+ import { tweetEntitiesFromXurl } from "./tweet-render";
6
7
  import type {
7
8
  TweetEntities,
8
9
  TweetMediaItem,
@@ -523,68 +524,7 @@ function toMentionData(tweet: XurlUserTweet, fallbackAuthorId: string) {
523
524
  }
524
525
 
525
526
  function toLocalEntities(tweet: XurlMentionData): TweetEntities {
526
- const raw = tweet.entities;
527
- if (!raw || typeof raw !== "object") {
528
- return {};
529
- }
530
-
531
- const entities = raw as Record<string, unknown>;
532
- const rawMentions = Array.isArray(entities.mentions) ? entities.mentions : [];
533
- const rawUrls = Array.isArray(entities.urls) ? entities.urls : [];
534
- const rawHashtags = Array.isArray(entities.hashtags) ? entities.hashtags : [];
535
-
536
- return {
537
- ...(rawMentions.length
538
- ? {
539
- mentions: rawMentions.map((mention) => {
540
- const value =
541
- mention && typeof mention === "object"
542
- ? (mention as Record<string, unknown>)
543
- : {};
544
- return {
545
- username: String(value.username ?? ""),
546
- id: typeof value.id === "string" ? String(value.id) : undefined,
547
- start: Number(value.start ?? 0),
548
- end: Number(value.end ?? 0),
549
- };
550
- }),
551
- }
552
- : {}),
553
- ...(rawUrls.length
554
- ? {
555
- urls: rawUrls.map((url) => {
556
- const value =
557
- url && typeof url === "object"
558
- ? (url as Record<string, unknown>)
559
- : {};
560
- return {
561
- url: String(value.url ?? ""),
562
- expandedUrl: String(value.expanded_url ?? value.url ?? ""),
563
- displayUrl: String(
564
- value.display_url ?? value.expanded_url ?? value.url ?? "",
565
- ),
566
- start: Number(value.start ?? 0),
567
- end: Number(value.end ?? 0),
568
- };
569
- }),
570
- }
571
- : {}),
572
- ...(rawHashtags.length
573
- ? {
574
- hashtags: rawHashtags.map((hashtag) => {
575
- const value =
576
- hashtag && typeof hashtag === "object"
577
- ? (hashtag as Record<string, unknown>)
578
- : {};
579
- return {
580
- tag: String(value.tag ?? ""),
581
- start: Number(value.start ?? 0),
582
- end: Number(value.end ?? 0),
583
- };
584
- }),
585
- }
586
- : {}),
587
- };
527
+ return tweetEntitiesFromXurl(tweet.entities);
588
528
  }
589
529
 
590
530
  function toMediaType(type: string): TweetMediaItem["type"] {
package/src/lib/bird.ts CHANGED
@@ -29,6 +29,12 @@ interface BirdTweetAuthor {
29
29
  name?: string;
30
30
  }
31
31
 
32
+ interface BirdTweetArticle {
33
+ title?: string;
34
+ previewText?: string;
35
+ coverImageUrl?: string;
36
+ }
37
+
32
38
  interface BirdTweetItem {
33
39
  id: string;
34
40
  text: string;
@@ -45,6 +51,7 @@ interface BirdTweetItem {
45
51
  author?: BirdTweetAuthor;
46
52
  authorId?: string;
47
53
  media?: BirdTweetMedia[];
54
+ article?: BirdTweetArticle | null;
48
55
  }
49
56
 
50
57
  export interface BirdDmUser {
@@ -385,6 +392,29 @@ function toMediaEntities(media: BirdTweetMedia[] | undefined) {
385
392
  };
386
393
  }
387
394
 
395
+ function toTweetEntities(item: BirdTweetItem) {
396
+ const mediaEntities = toMediaEntities(item.media);
397
+ const title = item.article?.title?.trim();
398
+ if (!title) return mediaEntities;
399
+ const handle = item.author?.username?.replace(/^@/, "");
400
+ const url = handle
401
+ ? `https://x.com/${handle}/status/${item.id}`
402
+ : `https://x.com/i/status/${item.id}`;
403
+ return {
404
+ ...mediaEntities,
405
+ article: {
406
+ title,
407
+ url,
408
+ ...(item.article?.previewText?.trim()
409
+ ? { previewText: item.article.previewText.trim() }
410
+ : {}),
411
+ ...(item.article?.coverImageUrl?.trim()
412
+ ? { coverImageUrl: item.article.coverImageUrl.trim() }
413
+ : {}),
414
+ },
415
+ };
416
+ }
417
+
388
418
  function toReferencedTweets(item: BirdTweetItem) {
389
419
  const references: XurlReferencedTweet[] = [];
390
420
  if (typeof item.inReplyToStatusId === "string" && item.inReplyToStatusId) {
@@ -434,7 +464,7 @@ function normalizeBirdTweets(items: BirdTweetItem[]): XurlMentionsResponse {
434
464
  text: item.text,
435
465
  created_at: toIsoTimestamp(item.createdAt),
436
466
  conversation_id: item.conversationId ?? item.id,
437
- entities: toMediaEntities(item.media),
467
+ entities: toTweetEntities(item),
438
468
  referenced_tweets: toReferencedTweets(item),
439
469
  public_metrics: {
440
470
  reply_count: Number(item.replyCount ?? 0),
@@ -1104,6 +1134,7 @@ export const __test__ = {
1104
1134
  getBirdTweetItems,
1105
1135
  getBirdTweetItem,
1106
1136
  toMediaEntities,
1137
+ toTweetEntities,
1107
1138
  toReferencedTweets,
1108
1139
  normalizeBirdTweets,
1109
1140
  };
@@ -0,0 +1,109 @@
1
+ const DEFAULT_MAX_ENTRIES = 100;
2
+
3
+ interface ClientCacheEntry<T> {
4
+ value: T;
5
+ updatedAt: number;
6
+ }
7
+
8
+ interface LoadClientCacheOptions {
9
+ force?: boolean;
10
+ maxAgeMs?: number;
11
+ }
12
+
13
+ const values = new Map<string, ClientCacheEntry<unknown>>();
14
+ const pending = new Map<string, Promise<unknown>>();
15
+ const revisions = new Map<string, number>();
16
+ let generation = 0;
17
+
18
+ function cacheToken(key: string) {
19
+ return `${String(generation)}:${String(revisions.get(key) ?? 0)}`;
20
+ }
21
+
22
+ function invalidateKey(key: string) {
23
+ revisions.set(key, (revisions.get(key) ?? 0) + 1);
24
+ }
25
+
26
+ function pruneCache() {
27
+ while (values.size > DEFAULT_MAX_ENTRIES) {
28
+ const oldestKey = values.keys().next().value as string | undefined;
29
+ if (!oldestKey) return;
30
+ values.delete(oldestKey);
31
+ }
32
+ }
33
+
34
+ export function readClientCache<T>(
35
+ key: string,
36
+ maxAgeMs = Number.POSITIVE_INFINITY,
37
+ ) {
38
+ const entry = values.get(key) as ClientCacheEntry<T> | undefined;
39
+ if (!entry) return undefined;
40
+ if (Date.now() - entry.updatedAt > maxAgeMs) {
41
+ values.delete(key);
42
+ return undefined;
43
+ }
44
+ return entry.value;
45
+ }
46
+
47
+ export function writeClientCache<T>(key: string, value: T) {
48
+ values.delete(key);
49
+ values.set(key, { value, updatedAt: Date.now() });
50
+ pruneCache();
51
+ return value;
52
+ }
53
+
54
+ export function loadClientCache<T>(
55
+ key: string,
56
+ load: () => Promise<T>,
57
+ {
58
+ force = false,
59
+ maxAgeMs = Number.POSITIVE_INFINITY,
60
+ }: LoadClientCacheOptions = {},
61
+ ) {
62
+ if (force) {
63
+ invalidateKey(key);
64
+ values.delete(key);
65
+ pending.delete(key);
66
+ } else {
67
+ const cached = readClientCache<T>(key, maxAgeMs);
68
+ if (cached !== undefined) return Promise.resolve(cached);
69
+ }
70
+
71
+ const active = pending.get(key) as Promise<T> | undefined;
72
+ if (active) return active;
73
+
74
+ const token = cacheToken(key);
75
+ let request: Promise<T>;
76
+ request = load()
77
+ .then((value) => {
78
+ if (cacheToken(key) === token) writeClientCache(key, value);
79
+ return value;
80
+ })
81
+ .finally(() => {
82
+ if (pending.get(key) === request) pending.delete(key);
83
+ });
84
+ pending.set(key, request);
85
+ return request;
86
+ }
87
+
88
+ export function deleteClientCache(key: string) {
89
+ invalidateKey(key);
90
+ values.delete(key);
91
+ pending.delete(key);
92
+ }
93
+
94
+ export function deleteClientCacheByPrefix(prefix: string) {
95
+ const keys = new Set([...values.keys(), ...pending.keys()]);
96
+ for (const key of keys) {
97
+ if (!key.startsWith(prefix)) continue;
98
+ invalidateKey(key);
99
+ values.delete(key);
100
+ pending.delete(key);
101
+ }
102
+ }
103
+
104
+ export function clearClientCache() {
105
+ generation += 1;
106
+ values.clear();
107
+ pending.clear();
108
+ revisions.clear();
109
+ }
package/src/lib/db.ts CHANGED
@@ -1,4 +1,7 @@
1
- import NativeSqliteDatabase, { type Database } from "./sqlite";
1
+ import NativeSqliteDatabase, {
2
+ type Database,
3
+ SQLITE_BUSY_TIMEOUT_MS,
4
+ } from "./sqlite";
2
5
  import { Kysely, SqliteDialect } from "kysely";
3
6
  import { ensureBirdclawDirs, getBirdclawPaths } from "./config";
4
7
  import { seedDemoData } from "./seed";
@@ -326,7 +329,7 @@ export interface InitDatabaseOptions {
326
329
 
327
330
  const BASE_SCHEMA_SQL = `
328
331
  pragma journal_mode = wal;
329
- pragma busy_timeout = 5000;
332
+ pragma busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS};
330
333
  pragma foreign_keys = on;
331
334
 
332
335
  create table if not exists accounts (
@@ -948,6 +951,34 @@ function ensureFollowGraphTables(db: Database) {
948
951
  }
949
952
 
950
953
  function backfillTweetCollections(db: Database) {
954
+ const missingKinds = (
955
+ [
956
+ ["likes", "liked"],
957
+ ["bookmarks", "bookmarked"],
958
+ ] as const
959
+ ).filter(([, column]) =>
960
+ db
961
+ .prepare(
962
+ `
963
+ select 1
964
+ from tweets as tweet
965
+ where tweet.${column} = 1
966
+ and not exists (
967
+ select 1
968
+ from tweet_collections as collection
969
+ where collection.account_id = tweet.account_id
970
+ and collection.tweet_id = tweet.id
971
+ and collection.kind = ?
972
+ )
973
+ limit 1
974
+ `,
975
+ )
976
+ .get(column === "liked" ? "likes" : "bookmarks"),
977
+ );
978
+ if (missingKinds.length === 0) {
979
+ return;
980
+ }
981
+
951
982
  const now = new Date().toISOString();
952
983
  const insert = db.prepare(`
953
984
  insert or ignore into tweet_collections (
@@ -963,12 +994,34 @@ function backfillTweetCollections(db: Database) {
963
994
  `);
964
995
 
965
996
  db.transaction(() => {
966
- insert.run("likes", now, "likes");
967
- insert.run("bookmarks", now, "bookmarks");
997
+ for (const [kind] of missingKinds) {
998
+ insert.run(kind, now, kind);
999
+ }
968
1000
  })();
969
1001
  }
970
1002
 
971
1003
  function backfillTweetAccountEdges(db: Database) {
1004
+ const missing = db
1005
+ .prepare(
1006
+ `
1007
+ select 1
1008
+ from tweets as tweet
1009
+ where tweet.kind in ('home', 'mention')
1010
+ and not exists (
1011
+ select 1
1012
+ from tweet_account_edges as edge
1013
+ where edge.account_id = tweet.account_id
1014
+ and edge.tweet_id = tweet.id
1015
+ and edge.kind = tweet.kind
1016
+ )
1017
+ limit 1
1018
+ `,
1019
+ )
1020
+ .get();
1021
+ if (!missing) {
1022
+ return;
1023
+ }
1024
+
972
1025
  const now = new Date().toISOString();
973
1026
  db.prepare(`
974
1027
  insert or ignore into tweet_account_edges (
@@ -4,6 +4,7 @@ import { listThreadViaBirdEffect } from "./bird";
4
4
  import { getNativeDb } from "./db";
5
5
  import { runEffectPromise } from "./effect-runtime";
6
6
  import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
7
+ import { tweetEntitiesFromXurl } from "./tweet-render";
7
8
  import type {
8
9
  XurlMentionData,
9
10
  XurlMentionsResponse,
@@ -415,7 +416,7 @@ function mergeMentionThreadIntoLocalStore({
415
416
  replyToId ?? null,
416
417
  Number(tweet.public_metrics?.like_count ?? 0),
417
418
  countTweetMedia(tweet),
418
- JSON.stringify(tweet.entities ?? {}),
419
+ JSON.stringify(tweetEntitiesFromXurl(tweet.entities)),
419
420
  buildMediaJsonFromIncludes(tweet, payload.includes?.media),
420
421
  );
421
422
  if (writeThreadContextEdges) {
@@ -11,6 +11,7 @@ import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
11
11
  import { serializeMentionItemsAsXurlCompatible } from "./mentions-export";
12
12
  import { listTimelineItems } from "./queries";
13
13
  import { deleteSyncCache, readSyncCache, writeSyncCache } from "./sync-cache";
14
+ import { tweetEntitiesFromXurl } from "./tweet-render";
14
15
  import type {
15
16
  ReplyFilter,
16
17
  TweetEntities,
@@ -521,52 +522,7 @@ function findNewestArchiveMentionId(db: Database, accountId: string) {
521
522
  }
522
523
 
523
524
  function toLocalEntities(tweet: XurlMentionData): TweetEntities {
524
- const raw = tweet.entities;
525
- if (!raw || typeof raw !== "object") {
526
- return {};
527
- }
528
-
529
- const entities = raw as Record<string, unknown>;
530
- const rawMentions = Array.isArray(entities.mentions) ? entities.mentions : [];
531
- const rawUrls = Array.isArray(entities.urls) ? entities.urls : [];
532
-
533
- return {
534
- ...(rawMentions.length
535
- ? {
536
- mentions: rawMentions.map((mention) => {
537
- const value =
538
- mention && typeof mention === "object"
539
- ? (mention as Record<string, unknown>)
540
- : {};
541
- return {
542
- username: String(value.username ?? ""),
543
- id: typeof value.id === "string" ? String(value.id) : undefined,
544
- start: Number(value.start ?? 0),
545
- end: Number(value.end ?? 0),
546
- };
547
- }),
548
- }
549
- : {}),
550
- ...(rawUrls.length
551
- ? {
552
- urls: rawUrls.map((url) => {
553
- const value =
554
- url && typeof url === "object"
555
- ? (url as Record<string, unknown>)
556
- : {};
557
- return {
558
- url: String(value.url ?? ""),
559
- expandedUrl: String(value.expanded_url ?? value.url ?? ""),
560
- displayUrl: String(
561
- value.display_url ?? value.expanded_url ?? value.url ?? "",
562
- ),
563
- start: Number(value.start ?? 0),
564
- end: Number(value.end ?? 0),
565
- };
566
- }),
567
- }
568
- : {}),
569
- };
525
+ return tweetEntitiesFromXurl(tweet.entities);
570
526
  }
571
527
 
572
528
  function replaceTweetFts(db: Database, tweetId: string, text: string) {
@@ -407,7 +407,7 @@ function mergeXurlTweetsIntoLocalStore(
407
407
  replyToId,
408
408
  Number(tweet.public_metrics?.like_count ?? 0),
409
409
  countTweetMedia(tweet),
410
- JSON.stringify(tweet.entities ?? {}),
410
+ JSON.stringify(tweetEntitiesFromXurl(tweet.entities)),
411
411
  buildMediaJsonFromIncludes(tweet, payload.includes?.media),
412
412
  quotedTweetId,
413
413
  );
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { Effect } from "effect";
3
3
  import type { Database } from "./sqlite";
4
- import { findArchivesEffect } from "./archive-finder";
4
+ import { findArchivesCachedEffect } from "./archive-finder";
5
5
  import { getDb, getNativeDb } from "./db";
6
6
  import { runEffectPromise, tryPromise } from "./effect-runtime";
7
7
  import { fetchProfileAffiliations } from "./profile-affiliations";
@@ -701,10 +701,9 @@ function getAccountProfileMeta(
701
701
  | undefined;
702
702
  }
703
703
 
704
- export function getQueryEnvelopeEffect(): Effect.Effect<
705
- QueryEnvelope,
706
- unknown
707
- > {
704
+ export function getQueryEnvelopeEffect({
705
+ includeArchives = true,
706
+ }: { includeArchives?: boolean } = {}): Effect.Effect<QueryEnvelope, unknown> {
708
707
  return Effect.gen(function* () {
709
708
  const db = yield* trySync(() => getDb());
710
709
  const nativeDb = yield* trySync(() => getNativeDb());
@@ -736,7 +735,9 @@ export function getQueryEnvelopeEffect(): Effect.Effect<
736
735
  .orderBy("name", "asc")
737
736
  .execute(),
738
737
  ),
739
- archives: findArchivesEffect(),
738
+ archives: includeArchives
739
+ ? findArchivesCachedEffect()
740
+ : Effect.succeed([]),
740
741
  transport: getTransportStatusEffect(),
741
742
  });
742
743