birdclaw 0.5.1 → 0.7.0

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.
Files changed (116) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +75 -5
  3. package/package.json +8 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +812 -37
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +37 -7
  9. package/src/components/AvatarChip.tsx +9 -3
  10. package/src/components/DmWorkspace.tsx +18 -8
  11. package/src/components/LinkPreviewCard.tsx +40 -18
  12. package/src/components/MarkdownViewer.tsx +818 -0
  13. package/src/components/ProfileAnalysisStream.tsx +428 -0
  14. package/src/components/ProfilePreview.tsx +120 -9
  15. package/src/components/SavedTimelineView.tsx +30 -8
  16. package/src/components/SyncNowButton.tsx +60 -25
  17. package/src/components/ThemeSlider.tsx +55 -50
  18. package/src/components/TimelineCard.tsx +240 -92
  19. package/src/components/TimelineRouteFrame.tsx +38 -8
  20. package/src/components/TweetMediaGrid.tsx +87 -38
  21. package/src/components/TweetRichText.tsx +45 -17
  22. package/src/components/account-selection.ts +64 -0
  23. package/src/components/useTimelineRouteData.ts +97 -13
  24. package/src/lib/account-sync-job.ts +666 -0
  25. package/src/lib/actions-transport.ts +216 -146
  26. package/src/lib/api-client.ts +128 -53
  27. package/src/lib/archive-finder.ts +78 -63
  28. package/src/lib/archive-import.ts +1593 -1291
  29. package/src/lib/authored-live.ts +262 -204
  30. package/src/lib/avatar-cache.ts +208 -43
  31. package/src/lib/backup.ts +1536 -954
  32. package/src/lib/bird-actions.ts +139 -57
  33. package/src/lib/bird-command.ts +101 -28
  34. package/src/lib/bird.ts +582 -194
  35. package/src/lib/blocklist.ts +40 -23
  36. package/src/lib/blocks-write.ts +129 -80
  37. package/src/lib/blocks.ts +165 -97
  38. package/src/lib/bookmark-sync-job.ts +250 -160
  39. package/src/lib/config.ts +35 -2
  40. package/src/lib/conversation-surface.ts +79 -48
  41. package/src/lib/data-sources.ts +219 -0
  42. package/src/lib/db.ts +95 -4
  43. package/src/lib/dms-live.ts +720 -66
  44. package/src/lib/effect-runtime.ts +45 -0
  45. package/src/lib/follow-graph.ts +224 -180
  46. package/src/lib/geocoding.ts +296 -0
  47. package/src/lib/http-effect.ts +222 -0
  48. package/src/lib/inbox.ts +74 -43
  49. package/src/lib/link-index.ts +88 -76
  50. package/src/lib/link-insights.ts +24 -0
  51. package/src/lib/link-preview-metadata.ts +472 -52
  52. package/src/lib/location.ts +137 -0
  53. package/src/lib/media-fetch.ts +286 -213
  54. package/src/lib/mention-threads-live.ts +445 -288
  55. package/src/lib/mentions-live.ts +549 -354
  56. package/src/lib/moderation-target.ts +102 -65
  57. package/src/lib/moderation-write.ts +77 -18
  58. package/src/lib/mutes-write.ts +129 -80
  59. package/src/lib/mutes.ts +8 -1
  60. package/src/lib/network-map.ts +382 -0
  61. package/src/lib/openai.ts +84 -53
  62. package/src/lib/period-digest.ts +1317 -0
  63. package/src/lib/profile-affiliation-hydration.ts +93 -54
  64. package/src/lib/profile-analysis.ts +1272 -0
  65. package/src/lib/profile-bio-entities.ts +1 -1
  66. package/src/lib/profile-hydration.ts +124 -72
  67. package/src/lib/profile-replies.ts +60 -43
  68. package/src/lib/profile-resolver.ts +402 -294
  69. package/src/lib/queries.ts +983 -203
  70. package/src/lib/research.ts +165 -120
  71. package/src/lib/search-discussion.ts +1016 -0
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +325 -51
  75. package/src/lib/tweet-account-edges.ts +2 -0
  76. package/src/lib/tweet-lookup.ts +30 -19
  77. package/src/lib/tweet-render.ts +141 -1
  78. package/src/lib/tweet-search-live.ts +565 -0
  79. package/src/lib/types.ts +75 -3
  80. package/src/lib/ui.ts +31 -8
  81. package/src/lib/url-expansion.ts +226 -55
  82. package/src/lib/url-safety.ts +220 -0
  83. package/src/lib/web-sync.ts +222 -149
  84. package/src/lib/whois.ts +166 -147
  85. package/src/lib/x-web.ts +102 -71
  86. package/src/lib/xurl-rate-limits.ts +267 -0
  87. package/src/lib/xurl.ts +1185 -405
  88. package/src/routeTree.gen.ts +273 -0
  89. package/src/routes/__root.tsx +24 -5
  90. package/src/routes/api/action.tsx +127 -78
  91. package/src/routes/api/avatar.tsx +39 -30
  92. package/src/routes/api/blocks.tsx +26 -23
  93. package/src/routes/api/conversation.tsx +25 -14
  94. package/src/routes/api/data-sources.tsx +24 -0
  95. package/src/routes/api/inbox.tsx +27 -21
  96. package/src/routes/api/link-insights.tsx +31 -25
  97. package/src/routes/api/link-preview.tsx +25 -21
  98. package/src/routes/api/network-map.tsx +55 -0
  99. package/src/routes/api/period-digest.tsx +133 -0
  100. package/src/routes/api/profile-analysis.tsx +152 -0
  101. package/src/routes/api/profile-hydrate.tsx +31 -28
  102. package/src/routes/api/query.tsx +80 -55
  103. package/src/routes/api/search-discussion.tsx +169 -0
  104. package/src/routes/api/status.tsx +18 -10
  105. package/src/routes/api/sync.tsx +75 -29
  106. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  107. package/src/routes/data-sources.tsx +255 -0
  108. package/src/routes/discuss.tsx +419 -0
  109. package/src/routes/dms.tsx +95 -28
  110. package/src/routes/inbox.tsx +32 -19
  111. package/src/routes/network-map.tsx +1035 -0
  112. package/src/routes/profile-analyze.tsx +112 -0
  113. package/src/routes/profiles.$handle.tsx +228 -0
  114. package/src/routes/rate-limits.tsx +309 -0
  115. package/src/routes/today.tsx +455 -0
  116. package/src/styles.css +22 -0
@@ -1,13 +1,44 @@
1
+ import { Effect } from "effect";
1
2
  import type { Database } from "./sqlite";
2
- import { listHomeTimelineViaBird } from "./bird";
3
+ import { listHomeTimelineViaBirdEffect } from "./bird";
3
4
  import { getNativeDb } from "./db";
5
+ import { runEffectPromise } from "./effect-runtime";
4
6
  import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
5
7
  import { readSyncCache, writeSyncCache } from "./sync-cache";
6
- import type { XurlMentionsResponse } from "./types";
8
+ import type {
9
+ XurlMediaItem,
10
+ XurlMentionUser,
11
+ XurlMentionsResponse,
12
+ } from "./types";
7
13
  import { upsertTweetAccountEdge } from "./tweet-account-edges";
8
14
  import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
15
+ import { listHomeTimelineViaXurlEffect } from "./xurl";
9
16
 
10
17
  const DEFAULT_TIMELINE_CACHE_TTL_MS = 2 * 60_000;
18
+ const MAX_XURL_TIMELINE_PAGE_SIZE = 100;
19
+
20
+ export type HomeTimelineMode = "bird" | "xurl" | "auto";
21
+ export interface HomeTimelineProgress {
22
+ source: "bird" | "xurl" | "cache";
23
+ fetched: number;
24
+ total?: number;
25
+ page?: number;
26
+ maxPages?: number;
27
+ pageSize?: number;
28
+ done: boolean;
29
+ }
30
+ export interface SyncHomeTimelineOptions {
31
+ account?: string;
32
+ mode?: HomeTimelineMode;
33
+ limit?: number;
34
+ maxPages?: number;
35
+ startTime?: string;
36
+ following?: boolean;
37
+ refresh?: boolean;
38
+ cacheTtlMs?: number;
39
+ timeoutMs?: number;
40
+ onProgress?: (progress: HomeTimelineProgress) => void;
41
+ }
11
42
 
12
43
  function parseCacheTtlMs(value?: number) {
13
44
  if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
@@ -17,32 +48,130 @@ function parseCacheTtlMs(value?: number) {
17
48
  }
18
49
 
19
50
  function assertLimit(limit: number) {
20
- if (!Number.isFinite(limit) || limit < 1) {
51
+ if ((!Number.isFinite(limit) && limit !== Infinity) || limit < 1) {
21
52
  throw new Error("--limit must be at least 1");
22
53
  }
23
54
  }
24
55
 
56
+ function parseMode(mode: HomeTimelineMode | undefined) {
57
+ const parsed = mode ?? "bird";
58
+ if (parsed !== "bird" && parsed !== "xurl" && parsed !== "auto") {
59
+ throw new Error("--mode must be bird, xurl, or auto");
60
+ }
61
+ return parsed;
62
+ }
63
+
64
+ function parseMaxPages(maxPages: number | undefined) {
65
+ if (maxPages === undefined) return 1;
66
+ if (!Number.isFinite(maxPages) || maxPages < 1) {
67
+ throw new Error("--max-pages must be at least 1");
68
+ }
69
+ return Math.floor(maxPages);
70
+ }
71
+
72
+ function parseStartTime(value: string | undefined) {
73
+ if (!value?.trim()) return undefined;
74
+ const time = new Date(value).getTime();
75
+ if (!Number.isFinite(time)) {
76
+ throw new Error("--start-time must be a valid date");
77
+ }
78
+ return { iso: new Date(time).toISOString(), time };
79
+ }
80
+
81
+ function reachedStartTimeBoundary(
82
+ payload: XurlMentionsResponse,
83
+ startTimeMs: number | undefined,
84
+ ) {
85
+ if (startTimeMs === undefined) return false;
86
+ return payload.data.some((tweet) => {
87
+ const createdAt = new Date(tweet.created_at).getTime();
88
+ return Number.isFinite(createdAt) && createdAt <= startTimeMs;
89
+ });
90
+ }
91
+
92
+ function getReferencedTweetId(
93
+ tweet: XurlMentionsResponse["data"][number],
94
+ type: "replied_to" | "quoted",
95
+ ) {
96
+ return tweet.referenced_tweets?.find((reference) => reference.type === type)
97
+ ?.id;
98
+ }
99
+
100
+ function mergeTimelinePayloads(
101
+ payloads: XurlMentionsResponse[],
102
+ limit: number,
103
+ ) {
104
+ const data: XurlMentionsResponse["data"] = [];
105
+ const usersById = new Map<string, XurlMentionUser>();
106
+ const mediaByKey = new Map<string, XurlMediaItem>();
107
+ let meta: XurlMentionsResponse["meta"] | undefined;
108
+
109
+ for (const payload of payloads) {
110
+ meta = payload.meta;
111
+ for (const tweet of payload.data) {
112
+ if (data.some((existing) => existing.id === tweet.id)) continue;
113
+ data.push(tweet);
114
+ if (data.length >= limit) break;
115
+ }
116
+ for (const user of payload.includes?.users ?? []) {
117
+ usersById.set(user.id, user);
118
+ }
119
+ for (const media of payload.includes?.media ?? []) {
120
+ mediaByKey.set(media.media_key, media);
121
+ }
122
+ if (data.length >= limit) break;
123
+ }
124
+
125
+ return {
126
+ data,
127
+ includes: {
128
+ users: [...usersById.values()],
129
+ media: [...mediaByKey.values()],
130
+ },
131
+ meta,
132
+ } satisfies XurlMentionsResponse;
133
+ }
134
+
25
135
  function resolveAccount(db: Database, accountId?: string) {
26
136
  const row = accountId
27
- ? (db.prepare("select id from accounts where id = ?").get(accountId) as
28
- | { id: string }
137
+ ? (db
138
+ .prepare(
139
+ "select id, handle, external_user_id, is_default as isDefault from accounts where id = ?",
140
+ )
141
+ .get(accountId) as
142
+ | ({ id: string; handle: string; external_user_id: string | null } & {
143
+ isDefault: number;
144
+ })
29
145
  | undefined)
30
146
  : (db
31
147
  .prepare(
32
148
  `
33
- select id
149
+ select id, handle, external_user_id, is_default as isDefault
34
150
  from accounts
35
151
  order by is_default desc, created_at asc
36
152
  limit 1
37
153
  `,
38
154
  )
39
- .get() as { id: string } | undefined);
155
+ .get() as
156
+ | ({ id: string; handle: string; external_user_id: string | null } & {
157
+ isDefault: number;
158
+ })
159
+ | undefined);
40
160
 
41
161
  if (!row) {
42
162
  throw new Error(`Unknown account: ${accountId ?? "default"}`);
43
163
  }
44
164
 
45
- return row.id;
165
+ return {
166
+ accountId: row.id,
167
+ isDefault: row.isDefault === 1,
168
+ username: row.handle.replace(/^@/, ""),
169
+ externalUserId:
170
+ typeof row.external_user_id === "string" &&
171
+ row.external_user_id.trim().length > 0
172
+ ? row.external_user_id.trim()
173
+ : undefined,
174
+ };
46
175
  }
47
176
 
48
177
  function replaceTweetFts(db: Database, tweetId: string, text: string) {
@@ -57,6 +186,7 @@ function mergeHomeTimelineIntoLocalStore(
57
186
  db: Database,
58
187
  accountId: string,
59
188
  payload: XurlMentionsResponse,
189
+ source: "bird" | "xurl",
60
190
  ) {
61
191
  const usersById = new Map(
62
192
  (payload.includes?.users ?? []).map((user) => [user.id, user]),
@@ -67,13 +197,15 @@ function mergeHomeTimelineIntoLocalStore(
67
197
  id, account_id, author_profile_id, kind, text, created_at,
68
198
  is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
69
199
  entities_json, media_json, quoted_tweet_id
70
- ) values (?, ?, ?, 'home', ?, ?, 0, null, ?, ?, 0, 0, ?, ?, null)
200
+ ) values (?, ?, ?, 'home', ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?)
71
201
  on conflict(id) do update set
72
202
  account_id = tweets.account_id,
73
203
  author_profile_id = excluded.author_profile_id,
74
204
  kind = tweets.kind,
75
205
  text = excluded.text,
76
206
  created_at = excluded.created_at,
207
+ is_replied = max(tweets.is_replied, excluded.is_replied),
208
+ reply_to_id = coalesce(tweets.reply_to_id, excluded.reply_to_id),
77
209
  like_count = excluded.like_count,
78
210
  media_count = max(tweets.media_count, excluded.media_count),
79
211
  entities_json = excluded.entities_json,
@@ -82,7 +214,8 @@ function mergeHomeTimelineIntoLocalStore(
82
214
  else tweets.media_json
83
215
  end,
84
216
  bookmarked = tweets.bookmarked,
85
- liked = tweets.liked
217
+ liked = tweets.liked,
218
+ quoted_tweet_id = coalesce(tweets.quoted_tweet_id, excluded.quoted_tweet_id)
86
219
  `,
87
220
  );
88
221
 
@@ -99,22 +232,27 @@ function mergeHomeTimelineIntoLocalStore(
99
232
  const profile = usersById.has(tweet.author_id)
100
233
  ? upsertProfileFromXUser(db, author)
101
234
  : ensureStubProfileForXUser(db, tweet.author_id);
235
+ const replyToId = getReferencedTweetId(tweet, "replied_to") ?? null;
236
+ const quotedTweetId = getReferencedTweetId(tweet, "quoted") ?? null;
102
237
  upsertTweet.run(
103
238
  tweet.id,
104
239
  accountId,
105
240
  profile.profile.id,
106
241
  tweet.text,
107
242
  tweet.created_at,
243
+ 0,
244
+ replyToId,
108
245
  Number(tweet.public_metrics?.like_count ?? 0),
109
246
  countTweetMedia(tweet),
110
247
  JSON.stringify(tweet.entities ?? {}),
111
248
  buildMediaJsonFromIncludes(tweet, payload.includes?.media),
249
+ quotedTweetId,
112
250
  );
113
251
  upsertTweetAccountEdge(db, {
114
252
  accountId,
115
253
  tweetId: tweet.id,
116
254
  kind: "home",
117
- source: "bird",
255
+ source,
118
256
  seenAt,
119
257
  rawJson: JSON.stringify(tweet),
120
258
  });
@@ -123,55 +261,191 @@ function mergeHomeTimelineIntoLocalStore(
123
261
  })();
124
262
  }
125
263
 
126
- export async function syncHomeTimeline({
264
+ export function syncHomeTimelineEffect({
127
265
  account,
128
- limit = 100,
266
+ mode,
267
+ limit,
268
+ maxPages,
269
+ startTime,
129
270
  following = true,
130
271
  refresh = false,
131
272
  cacheTtlMs,
132
- }: {
133
- account?: string;
134
- limit?: number;
135
- following?: boolean;
136
- refresh?: boolean;
137
- cacheTtlMs?: number;
138
- }) {
139
- assertLimit(limit);
140
- const db = getNativeDb();
141
- const accountId = resolveAccount(db, account);
142
- const cacheKey = `timeline:bird:${accountId}:${following ? "following" : "for-you"}:${String(limit)}`;
143
- const ttlMs = parseCacheTtlMs(cacheTtlMs);
144
- const cached = readSyncCache<XurlMentionsResponse>(cacheKey, db);
145
- const cacheAgeMs = cached
146
- ? Date.now() - new Date(cached.updatedAt).getTime()
147
- : Number.POSITIVE_INFINITY;
148
-
149
- if (!refresh && cached && cacheAgeMs <= ttlMs) {
273
+ timeoutMs,
274
+ onProgress,
275
+ }: SyncHomeTimelineOptions = {}): Effect.Effect<
276
+ {
277
+ ok: true;
278
+ source: "bird" | "xurl" | "cache";
279
+ kind: "timeline";
280
+ accountId: string;
281
+ feed: "following" | "for-you";
282
+ count: number;
283
+ payload: XurlMentionsResponse;
284
+ },
285
+ unknown
286
+ > {
287
+ return Effect.gen(function* () {
288
+ const parsedStartTime = yield* Effect.try({
289
+ try: () => parseStartTime(startTime),
290
+ catch: (error) => error,
291
+ });
292
+ const parsedMode = parseMode(mode);
293
+ const finiteFallbackLimit = limit ?? (parsedStartTime ? 300 : 100);
294
+ const effectiveLimit =
295
+ limit ??
296
+ (parsedStartTime && (parsedMode === "xurl" || parsedMode === "auto")
297
+ ? Infinity
298
+ : finiteFallbackLimit);
299
+ assertLimit(effectiveLimit);
300
+ const parsedMaxPages =
301
+ maxPages === undefined && parsedStartTime
302
+ ? Infinity
303
+ : parseMaxPages(maxPages);
304
+ const db = getNativeDb();
305
+ const resolvedAccount = resolveAccount(db, account);
306
+ const accountId = resolvedAccount.accountId;
307
+ const effectiveMode =
308
+ parsedMode === "auto" &&
309
+ account !== undefined &&
310
+ !resolvedAccount.isDefault
311
+ ? "xurl"
312
+ : parsedMode;
313
+ const cacheKey = `timeline:${effectiveMode}:${accountId}:${following ? "following" : "for-you"}:${Number.isFinite(effectiveLimit) ? String(effectiveLimit) : "all"}:${Number.isFinite(parsedMaxPages) ? String(parsedMaxPages) : "all-pages"}:${parsedStartTime?.iso ?? "no-start"}`;
314
+ const ttlMs = parseCacheTtlMs(cacheTtlMs);
315
+ const cached = readSyncCache<XurlMentionsResponse>(cacheKey, db);
316
+ const cacheAgeMs = cached
317
+ ? Date.now() - new Date(cached.updatedAt).getTime()
318
+ : Number.POSITIVE_INFINITY;
319
+
320
+ if (!refresh && cached && cacheAgeMs <= ttlMs) {
321
+ yield* Effect.sync(() =>
322
+ onProgress?.({
323
+ source: "cache",
324
+ fetched: cached.value.data.length,
325
+ total: Number.isFinite(effectiveLimit) ? effectiveLimit : undefined,
326
+ done: true,
327
+ }),
328
+ );
329
+ return {
330
+ ok: true,
331
+ source: "cache",
332
+ kind: "timeline",
333
+ accountId,
334
+ feed: following ? "following" : "for-you",
335
+ count: cached.value.data.length,
336
+ payload: cached.value,
337
+ } as const;
338
+ }
339
+
340
+ const fetchViaXurl = Effect.gen(function* () {
341
+ if (!following) {
342
+ return yield* Effect.fail(
343
+ new Error("xurl home timeline mode does not support --for-you"),
344
+ );
345
+ }
346
+ const pages: XurlMentionsResponse[] = [];
347
+ let nextToken: string | undefined;
348
+ for (let page = 0; page < parsedMaxPages; page += 1) {
349
+ const fetchedCount = pages.reduce(
350
+ (sum, item) => sum + item.data.length,
351
+ 0,
352
+ );
353
+ const remaining = Number.isFinite(effectiveLimit)
354
+ ? Math.max(1, effectiveLimit - fetchedCount)
355
+ : Infinity;
356
+ const pageSize = Math.min(
357
+ MAX_XURL_TIMELINE_PAGE_SIZE,
358
+ Math.max(5, remaining),
359
+ );
360
+ const pagePayload = yield* listHomeTimelineViaXurlEffect({
361
+ maxResults: pageSize,
362
+ userId: resolvedAccount.externalUserId,
363
+ username: resolvedAccount.username,
364
+ paginationToken: nextToken,
365
+ timeoutMs,
366
+ });
367
+ pages.push(pagePayload);
368
+ nextToken =
369
+ typeof pagePayload.meta?.next_token === "string"
370
+ ? pagePayload.meta.next_token
371
+ : undefined;
372
+ const totalFetched = fetchedCount + pagePayload.data.length;
373
+ const done =
374
+ !nextToken ||
375
+ (Number.isFinite(parsedMaxPages) && page + 1 >= parsedMaxPages) ||
376
+ (Number.isFinite(effectiveLimit) && totalFetched >= effectiveLimit) ||
377
+ reachedStartTimeBoundary(pagePayload, parsedStartTime?.time);
378
+ yield* Effect.sync(() =>
379
+ onProgress?.({
380
+ source: "xurl",
381
+ fetched: totalFetched,
382
+ total: Number.isFinite(effectiveLimit) ? effectiveLimit : undefined,
383
+ page: page + 1,
384
+ maxPages: Number.isFinite(parsedMaxPages)
385
+ ? parsedMaxPages
386
+ : undefined,
387
+ pageSize,
388
+ done,
389
+ }),
390
+ );
391
+ if (done) {
392
+ break;
393
+ }
394
+ }
395
+ return mergeTimelinePayloads(pages, effectiveLimit);
396
+ });
397
+ const fetchViaBird = listHomeTimelineViaBirdEffect({
398
+ maxResults: finiteFallbackLimit,
399
+ following,
400
+ });
401
+ let source: "bird" | "xurl";
402
+ let payload: XurlMentionsResponse;
403
+ if (effectiveMode === "xurl") {
404
+ payload = yield* fetchViaXurl;
405
+ source = "xurl";
406
+ } else if (effectiveMode === "auto") {
407
+ const fetched = yield* fetchViaXurl.pipe(
408
+ Effect.map((value) => ({ source: "xurl" as const, value })),
409
+ Effect.catchAll(() =>
410
+ fetchViaBird.pipe(
411
+ Effect.map((value) => ({ source: "bird" as const, value })),
412
+ ),
413
+ ),
414
+ );
415
+ payload = fetched.value;
416
+ source = fetched.source;
417
+ } else {
418
+ payload = yield* listHomeTimelineViaBirdEffect({
419
+ maxResults: finiteFallbackLimit,
420
+ following,
421
+ });
422
+ source = "bird";
423
+ }
424
+ if (source === "bird") {
425
+ yield* Effect.sync(() =>
426
+ onProgress?.({
427
+ source: "bird",
428
+ fetched: payload.data.length,
429
+ total: finiteFallbackLimit,
430
+ done: true,
431
+ }),
432
+ );
433
+ }
434
+ mergeHomeTimelineIntoLocalStore(db, accountId, payload, source);
435
+ writeSyncCache(cacheKey, payload, db);
436
+
150
437
  return {
151
438
  ok: true,
152
- source: "cache",
439
+ source,
153
440
  kind: "timeline",
154
441
  accountId,
155
442
  feed: following ? "following" : "for-you",
156
- count: cached.value.data.length,
157
- payload: cached.value,
158
- };
159
- }
160
-
161
- const payload = await listHomeTimelineViaBird({
162
- maxResults: limit,
163
- following,
443
+ count: payload.data.length,
444
+ payload,
445
+ } as const;
164
446
  });
165
- mergeHomeTimelineIntoLocalStore(db, accountId, payload);
166
- writeSyncCache(cacheKey, payload, db);
447
+ }
167
448
 
168
- return {
169
- ok: true,
170
- source: "bird",
171
- kind: "timeline",
172
- accountId,
173
- feed: following ? "following" : "for-you",
174
- count: payload.data.length,
175
- payload,
176
- };
449
+ export function syncHomeTimeline(options: SyncHomeTimelineOptions = {}) {
450
+ return runEffectPromise(syncHomeTimelineEffect(options));
177
451
  }
@@ -4,6 +4,8 @@ export type TweetAccountEdgeKind =
4
4
  | "home"
5
5
  | "mention"
6
6
  | "authored"
7
+ | "search"
8
+ | "profile"
7
9
  | "thread_context";
8
10
 
9
11
  export function upsertTweetAccountEdge(
@@ -1,6 +1,8 @@
1
- import { lookupTweetsByIdsViaBird } from "./bird";
1
+ import { Effect } from "effect";
2
+ import { lookupTweetsByIdsViaBirdEffect } from "./bird";
3
+ import { runEffectPromise } from "./effect-runtime";
2
4
  import type { XurlTweetsResponse } from "./types";
3
- import { lookupTweetsByIds as lookupTweetsByIdsViaXurl } from "./xurl";
5
+ import { lookupTweetsByIdsEffect as lookupTweetsByIdsViaXurlEffect } from "./xurl";
4
6
 
5
7
  export type TweetLookupMode = "auto" | "xurl" | "bird";
6
8
 
@@ -8,28 +10,37 @@ function errorMessage(error: unknown) {
8
10
  return error instanceof Error ? error.message : String(error);
9
11
  }
10
12
 
11
- export async function lookupTweetsByIds(
13
+ export function lookupTweetsByIdsEffect(
12
14
  ids: string[],
13
15
  mode: TweetLookupMode = "auto",
14
- ): Promise<XurlTweetsResponse> {
16
+ ): Effect.Effect<XurlTweetsResponse, unknown> {
15
17
  if (mode === "bird") {
16
- return lookupTweetsByIdsViaBird(ids);
18
+ return lookupTweetsByIdsViaBirdEffect(ids);
17
19
  }
18
20
  if (mode === "xurl") {
19
- return lookupTweetsByIdsViaXurl(ids);
21
+ return lookupTweetsByIdsViaXurlEffect(ids);
20
22
  }
21
23
 
22
- try {
23
- return await lookupTweetsByIdsViaXurl(ids);
24
- } catch (xurlError) {
25
- try {
26
- return await lookupTweetsByIdsViaBird(ids);
27
- } catch (birdError) {
28
- throw new Error(
29
- `Tweet lookup failed via xurl and bird: xurl: ${errorMessage(
30
- xurlError,
31
- )}; bird: ${errorMessage(birdError)}`,
32
- );
33
- }
34
- }
24
+ return lookupTweetsByIdsViaXurlEffect(ids).pipe(
25
+ Effect.catchAll((xurlError) =>
26
+ lookupTweetsByIdsViaBirdEffect(ids).pipe(
27
+ Effect.catchAll((birdError) =>
28
+ Effect.fail(
29
+ new Error(
30
+ `Tweet lookup failed via xurl and bird: xurl: ${errorMessage(
31
+ xurlError,
32
+ )}; bird: ${errorMessage(birdError)}`,
33
+ ),
34
+ ),
35
+ ),
36
+ ),
37
+ ),
38
+ );
39
+ }
40
+
41
+ export function lookupTweetsByIds(
42
+ ids: string[],
43
+ mode: TweetLookupMode = "auto",
44
+ ): Promise<XurlTweetsResponse> {
45
+ return runEffectPromise(lookupTweetsByIdsEffect(ids, mode));
35
46
  }