birdclaw 0.5.1 → 0.6.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 (89) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +50 -5
  3. package/package.json +3 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +376 -13
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +27 -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 +452 -0
  13. package/src/components/SyncNowButton.tsx +57 -25
  14. package/src/components/ThemeSlider.tsx +55 -50
  15. package/src/components/TimelineCard.tsx +225 -93
  16. package/src/components/TimelineRouteFrame.tsx +22 -8
  17. package/src/components/TweetMediaGrid.tsx +87 -38
  18. package/src/components/TweetRichText.tsx +15 -11
  19. package/src/components/account-selection.ts +64 -0
  20. package/src/components/useTimelineRouteData.ts +23 -7
  21. package/src/lib/account-sync-job.ts +654 -0
  22. package/src/lib/actions-transport.ts +216 -146
  23. package/src/lib/api-client.ts +128 -53
  24. package/src/lib/archive-finder.ts +78 -63
  25. package/src/lib/archive-import.ts +1364 -1300
  26. package/src/lib/authored-live.ts +261 -204
  27. package/src/lib/avatar-cache.ts +159 -44
  28. package/src/lib/backup.ts +1532 -951
  29. package/src/lib/bird-actions.ts +139 -57
  30. package/src/lib/bird-command.ts +101 -28
  31. package/src/lib/bird.ts +549 -194
  32. package/src/lib/blocklist.ts +40 -23
  33. package/src/lib/blocks-write.ts +129 -80
  34. package/src/lib/blocks.ts +165 -97
  35. package/src/lib/bookmark-sync-job.ts +250 -160
  36. package/src/lib/conversation-surface.ts +79 -48
  37. package/src/lib/db.ts +33 -3
  38. package/src/lib/dms-live.ts +720 -66
  39. package/src/lib/effect-runtime.ts +45 -0
  40. package/src/lib/follow-graph.ts +224 -180
  41. package/src/lib/http-effect.ts +222 -0
  42. package/src/lib/inbox.ts +74 -43
  43. package/src/lib/link-index.ts +88 -76
  44. package/src/lib/link-insights.ts +24 -0
  45. package/src/lib/link-preview-metadata.ts +472 -52
  46. package/src/lib/media-fetch.ts +286 -213
  47. package/src/lib/mention-threads-live.ts +352 -288
  48. package/src/lib/mentions-live.ts +390 -342
  49. package/src/lib/moderation-target.ts +102 -65
  50. package/src/lib/moderation-write.ts +77 -18
  51. package/src/lib/mutes-write.ts +129 -80
  52. package/src/lib/mutes.ts +8 -1
  53. package/src/lib/openai.ts +84 -53
  54. package/src/lib/period-digest.ts +953 -0
  55. package/src/lib/profile-affiliation-hydration.ts +93 -54
  56. package/src/lib/profile-hydration.ts +124 -72
  57. package/src/lib/profile-replies.ts +60 -43
  58. package/src/lib/profile-resolver.ts +402 -294
  59. package/src/lib/queries.ts +969 -199
  60. package/src/lib/research.ts +165 -120
  61. package/src/lib/sqlite.ts +1 -0
  62. package/src/lib/timeline-collections-live.ts +204 -167
  63. package/src/lib/timeline-live.ts +60 -39
  64. package/src/lib/tweet-lookup.ts +30 -19
  65. package/src/lib/types.ts +38 -1
  66. package/src/lib/ui.ts +30 -7
  67. package/src/lib/url-expansion.ts +226 -55
  68. package/src/lib/url-safety.ts +220 -0
  69. package/src/lib/web-sync.ts +216 -148
  70. package/src/lib/whois.ts +166 -147
  71. package/src/lib/x-web.ts +102 -71
  72. package/src/lib/xurl.ts +681 -411
  73. package/src/routeTree.gen.ts +42 -0
  74. package/src/routes/__root.tsx +25 -5
  75. package/src/routes/api/action.tsx +127 -78
  76. package/src/routes/api/avatar.tsx +39 -30
  77. package/src/routes/api/blocks.tsx +26 -23
  78. package/src/routes/api/conversation.tsx +25 -14
  79. package/src/routes/api/inbox.tsx +27 -21
  80. package/src/routes/api/link-insights.tsx +31 -25
  81. package/src/routes/api/link-preview.tsx +25 -21
  82. package/src/routes/api/period-digest.tsx +123 -0
  83. package/src/routes/api/profile-hydrate.tsx +31 -28
  84. package/src/routes/api/query.tsx +79 -55
  85. package/src/routes/api/status.tsx +18 -10
  86. package/src/routes/api/sync.tsx +75 -29
  87. package/src/routes/dms.tsx +95 -28
  88. package/src/routes/inbox.tsx +32 -19
  89. package/src/routes/today.tsx +441 -0
@@ -1,6 +1,11 @@
1
1
  import type { Database } from "./sqlite";
2
- import { listBookmarkedTweetsViaBird, listLikedTweetsViaBird } from "./bird";
2
+ import { Effect } from "effect";
3
+ import {
4
+ listBookmarkedTweetsViaBirdEffect,
5
+ listLikedTweetsViaBirdEffect,
6
+ } from "./bird";
3
7
  import { getNativeDb } from "./db";
8
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
4
9
  import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
5
10
  import { readSyncCache, writeSyncCache } from "./sync-cache";
6
11
  import type {
@@ -18,12 +23,34 @@ import {
18
23
 
19
24
  export type TimelineCollectionKind = "likes" | "bookmarks";
20
25
  export type TimelineCollectionMode = "auto" | "xurl" | "bird";
26
+ export interface SyncTimelineCollectionOptions {
27
+ kind: TimelineCollectionKind;
28
+ account?: string;
29
+ mode?: TimelineCollectionMode;
30
+ limit?: number;
31
+ all?: boolean;
32
+ maxPages?: number;
33
+ refresh?: boolean;
34
+ cacheTtlMs?: number;
35
+ earlyStop?: boolean;
36
+ }
21
37
 
22
38
  const DEFAULT_COLLECTION_CACHE_TTL_MS = 2 * 60_000;
23
39
  const DEFAULT_EARLY_STOP_MAX_PAGES = 10;
24
40
  const MIN_XURL_LIMIT = 5;
25
41
  const MAX_XURL_LIMIT = 100;
26
42
 
43
+ function toError(error: unknown) {
44
+ return error instanceof Error ? error : new Error(String(error));
45
+ }
46
+
47
+ function trySync<T>(try_: () => T) {
48
+ return Effect.try({
49
+ try: try_,
50
+ catch: toError,
51
+ });
52
+ }
53
+
27
54
  function parseCacheTtlMs(value?: number) {
28
55
  if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
29
56
  return DEFAULT_COLLECTION_CACHE_TTL_MS;
@@ -300,7 +327,7 @@ function mergeTimelineCollectionIntoLocalStore(
300
327
  })();
301
328
  }
302
329
 
303
- async function fetchXurlCollection({
330
+ function fetchXurlCollectionEffect({
304
331
  db,
305
332
  kind,
306
333
  accountId,
@@ -321,80 +348,84 @@ async function fetchXurlCollection({
321
348
  maxPages: number | null;
322
349
  earlyStop: boolean;
323
350
  }) {
324
- let resolvedUserId = userId;
325
- if (!resolvedUserId) {
326
- const [accountUser] = await lookupUsersByHandles([username]);
327
- if (!accountUser?.id) {
328
- throw new Error(`Could not resolve Twitter user id for @${username}`);
351
+ return Effect.gen(function* () {
352
+ let resolvedUserId = userId;
353
+ if (!resolvedUserId) {
354
+ const [accountUser] = yield* tryPromise(() =>
355
+ lookupUsersByHandles([username]),
356
+ );
357
+ if (!accountUser?.id) {
358
+ return yield* Effect.fail(
359
+ new Error(`Could not resolve Twitter user id for @${username}`),
360
+ );
361
+ }
362
+ resolvedUserId = String(accountUser.id);
329
363
  }
330
- resolvedUserId = String(accountUser.id);
331
- }
332
364
 
333
- const pages: XurlMentionsResponse[] = [];
334
- let nextToken: string | undefined;
335
- let pageCount = 0;
336
- let saturatedAtPage: number | undefined;
337
- do {
338
- const payload =
339
- kind === "likes"
340
- ? await listLikedTweetsViaXurl({
341
- maxResults: limit,
342
- username,
343
- userId: resolvedUserId,
344
- paginationToken: nextToken,
345
- })
346
- : await listBookmarkedTweetsViaXurl({
347
- maxResults: limit,
348
- username,
349
- userId: resolvedUserId,
350
- isPaginatedWalk: all,
351
- paginationToken: nextToken,
352
- });
353
- pageCount += 1;
354
- if (earlyStop) {
355
- const tweetIds = payload.data.map((tweet) => tweet.id);
356
- const { existingTweetIds, uniqueTweetCount } = getCollectionPageDedupe(
357
- db,
358
- accountId,
359
- kind,
360
- tweetIds,
365
+ const pages: XurlMentionsResponse[] = [];
366
+ let nextToken: string | undefined;
367
+ let pageCount = 0;
368
+ let saturatedAtPage: number | undefined;
369
+ do {
370
+ const payload = yield* tryPromise(() =>
371
+ kind === "likes"
372
+ ? listLikedTweetsViaXurl({
373
+ maxResults: limit,
374
+ username,
375
+ userId: resolvedUserId,
376
+ paginationToken: nextToken,
377
+ })
378
+ : listBookmarkedTweetsViaXurl({
379
+ maxResults: limit,
380
+ username,
381
+ userId: resolvedUserId,
382
+ isPaginatedWalk: all,
383
+ paginationToken: nextToken,
384
+ }),
361
385
  );
362
- if (tweetIds.length > 0 && existingTweetIds.size === uniqueTweetCount) {
363
- saturatedAtPage = pageCount;
364
- console.error(
365
- `${kind} saturated at page ${pageCount} (100% existing rows)`,
386
+ pageCount += 1;
387
+ if (earlyStop) {
388
+ const tweetIds = payload.data.map((tweet) => tweet.id);
389
+ const { existingTweetIds, uniqueTweetCount } = yield* trySync(() =>
390
+ getCollectionPageDedupe(db, accountId, kind, tweetIds),
366
391
  );
367
- break;
392
+ if (tweetIds.length > 0 && existingTweetIds.size === uniqueTweetCount) {
393
+ saturatedAtPage = pageCount;
394
+ console.error(
395
+ `${kind} saturated at page ${pageCount} (100% existing rows)`,
396
+ );
397
+ break;
398
+ }
399
+ pages.push(filterExistingCollectionTweets(payload, existingTweetIds));
400
+ } else {
401
+ pages.push(payload);
368
402
  }
369
- pages.push(filterExistingCollectionTweets(payload, existingTweetIds));
370
- } else {
371
- pages.push(payload);
372
- }
373
- nextToken =
374
- typeof payload.meta?.next_token === "string"
375
- ? payload.meta.next_token
376
- : undefined;
377
- } while (
378
- (all || earlyStop) &&
379
- nextToken &&
380
- (maxPages === null || pageCount < maxPages)
381
- );
403
+ nextToken =
404
+ typeof payload.meta?.next_token === "string"
405
+ ? payload.meta.next_token
406
+ : undefined;
407
+ } while (
408
+ (all || earlyStop) &&
409
+ nextToken &&
410
+ (maxPages === null || pageCount < maxPages)
411
+ );
382
412
 
383
- const merged = mergePayloads(pages);
384
- // A saturated page may expose another token, but our walk is complete.
385
- const saturationMeta =
386
- saturatedAtPage === undefined
387
- ? {}
388
- : { saturated_at_page: saturatedAtPage, next_token: null };
389
- merged.meta = {
390
- ...merged.meta,
391
- page_count: pageCount,
392
- ...saturationMeta,
393
- };
394
- return merged;
413
+ const merged = mergePayloads(pages);
414
+ // A saturated page may expose another token, but our walk is complete.
415
+ const saturationMeta =
416
+ saturatedAtPage === undefined
417
+ ? {}
418
+ : { saturated_at_page: saturatedAtPage, next_token: null };
419
+ merged.meta = {
420
+ ...merged.meta,
421
+ page_count: pageCount,
422
+ ...saturationMeta,
423
+ };
424
+ return merged;
425
+ });
395
426
  }
396
427
 
397
- async function fetchBirdCollection({
428
+ function fetchBirdCollectionEffect({
398
429
  kind,
399
430
  limit,
400
431
  all,
@@ -406,19 +437,19 @@ async function fetchBirdCollection({
406
437
  maxPages: number | null;
407
438
  }) {
408
439
  return kind === "likes"
409
- ? listLikedTweetsViaBird({
440
+ ? listLikedTweetsViaBirdEffect({
410
441
  maxResults: limit,
411
442
  all,
412
443
  maxPages: maxPages ?? undefined,
413
444
  })
414
- : listBookmarkedTweetsViaBird({
445
+ : listBookmarkedTweetsViaBirdEffect({
415
446
  maxResults: limit,
416
447
  all,
417
448
  maxPages: maxPages ?? undefined,
418
449
  });
419
450
  }
420
451
 
421
- export async function syncTimelineCollection({
452
+ export function syncTimelineCollectionEffect({
422
453
  kind,
423
454
  account,
424
455
  mode = "auto",
@@ -428,72 +459,64 @@ export async function syncTimelineCollection({
428
459
  refresh = false,
429
460
  cacheTtlMs,
430
461
  earlyStop = false,
431
- }: {
432
- kind: TimelineCollectionKind;
433
- account?: string;
434
- mode?: TimelineCollectionMode;
435
- limit?: number;
436
- all?: boolean;
437
- maxPages?: number;
438
- refresh?: boolean;
439
- cacheTtlMs?: number;
440
- earlyStop?: boolean;
441
- }) {
442
- assertLimit(limit);
443
- const parsedMaxPages = parseMaxPages(maxPages);
444
- const shouldApplyEarlyStopCap =
445
- earlyStop && !all && parsedMaxPages === null && mode !== "bird";
446
- const xurlMaxPages = shouldApplyEarlyStopCap
447
- ? DEFAULT_EARLY_STOP_MAX_PAGES
448
- : parsedMaxPages;
449
- if (mode === "xurl" || mode === "auto") {
450
- assertXurlLimit(limit);
451
- }
452
-
453
- const db = getNativeDb();
454
- const resolvedAccount = resolveAccount(db, account);
455
- const cacheMaxPages = mode === "bird" ? parsedMaxPages : xurlMaxPages;
456
- const cacheKey = `${kind}:${mode}:${resolvedAccount.accountId}:${String(limit)}:${all ? "all" : "single"}:${cacheMaxPages === null ? "all-pages" : String(cacheMaxPages)}${earlyStop ? ":early-stop" : ""}`;
457
- const ttlMs = parseCacheTtlMs(cacheTtlMs);
458
- const cached = readSyncCache<XurlMentionsResponse>(cacheKey, db);
459
- const cacheAgeMs = cached
460
- ? Date.now() - new Date(cached.updatedAt).getTime()
461
- : Number.POSITIVE_INFINITY;
462
-
463
- if (!refresh && cached && cacheAgeMs <= ttlMs) {
464
- const saturatedAtPage = readSaturatedAtPage(cached.value);
465
- return {
466
- ok: true,
467
- source: "cache",
468
- kind,
469
- accountId: resolvedAccount.accountId,
470
- count: cached.value.data.length,
471
- payload: cached.value,
472
- ...(saturatedAtPage === undefined
473
- ? {}
474
- : { saturated_at_page: saturatedAtPage }),
475
- };
476
- }
462
+ }: SyncTimelineCollectionOptions) {
463
+ return Effect.gen(function* () {
464
+ yield* trySync(() => assertLimit(limit));
465
+ const parsedMaxPages = yield* trySync(() => parseMaxPages(maxPages));
466
+ const shouldApplyEarlyStopCap =
467
+ earlyStop && !all && parsedMaxPages === null && mode !== "bird";
468
+ const xurlMaxPages = shouldApplyEarlyStopCap
469
+ ? DEFAULT_EARLY_STOP_MAX_PAGES
470
+ : parsedMaxPages;
471
+ if (mode === "xurl" || mode === "auto") {
472
+ yield* trySync(() => assertXurlLimit(limit));
473
+ }
477
474
 
478
- if (shouldApplyEarlyStopCap) {
479
- console.error(
480
- `${kind} early-stop capped at ${DEFAULT_EARLY_STOP_MAX_PAGES} pages by default; pass --max-pages or --all to override`,
475
+ const db = yield* trySync(() => getNativeDb());
476
+ const resolvedAccount = yield* trySync(() => resolveAccount(db, account));
477
+ const cacheMaxPages = mode === "bird" ? parsedMaxPages : xurlMaxPages;
478
+ const cacheKey = `${kind}:${mode}:${resolvedAccount.accountId}:${String(limit)}:${all ? "all" : "single"}:${cacheMaxPages === null ? "all-pages" : String(cacheMaxPages)}${earlyStop ? ":early-stop" : ""}`;
479
+ const ttlMs = parseCacheTtlMs(cacheTtlMs);
480
+ const cached = yield* trySync(() =>
481
+ readSyncCache<XurlMentionsResponse>(cacheKey, db),
481
482
  );
482
- }
483
+ const cacheAgeMs = cached
484
+ ? Date.now() - new Date(cached.updatedAt).getTime()
485
+ : Number.POSITIVE_INFINITY;
486
+
487
+ if (!refresh && cached && cacheAgeMs <= ttlMs) {
488
+ const saturatedAtPage = readSaturatedAtPage(cached.value);
489
+ return {
490
+ ok: true,
491
+ source: "cache",
492
+ kind,
493
+ accountId: resolvedAccount.accountId,
494
+ count: cached.value.data.length,
495
+ payload: cached.value,
496
+ ...(saturatedAtPage === undefined
497
+ ? {}
498
+ : { saturated_at_page: saturatedAtPage }),
499
+ };
500
+ }
483
501
 
484
- let source: "xurl" | "bird";
485
- let payload: XurlMentionsResponse;
486
- if (mode === "bird") {
487
- payload = await fetchBirdCollection({
488
- kind,
489
- limit,
490
- all,
491
- maxPages: parsedMaxPages,
492
- });
493
- source = "bird";
494
- } else {
495
- try {
496
- payload = await fetchXurlCollection({
502
+ if (shouldApplyEarlyStopCap) {
503
+ console.error(
504
+ `${kind} early-stop capped at ${DEFAULT_EARLY_STOP_MAX_PAGES} pages by default; pass --max-pages or --all to override`,
505
+ );
506
+ }
507
+
508
+ let source: "xurl" | "bird";
509
+ let payload: XurlMentionsResponse;
510
+ if (mode === "bird") {
511
+ payload = yield* fetchBirdCollectionEffect({
512
+ kind,
513
+ limit,
514
+ all,
515
+ maxPages: parsedMaxPages,
516
+ });
517
+ source = "bird";
518
+ } else {
519
+ const xurlPayload = yield* fetchXurlCollectionEffect({
497
520
  db,
498
521
  kind,
499
522
  accountId: resolvedAccount.accountId,
@@ -503,41 +526,55 @@ export async function syncTimelineCollection({
503
526
  all,
504
527
  maxPages: xurlMaxPages,
505
528
  earlyStop,
506
- });
507
- source = "xurl";
508
- } catch (error) {
509
- if (mode === "xurl") {
510
- throw error;
529
+ }).pipe(
530
+ Effect.map((value) => ({ ok: true as const, value })),
531
+ Effect.catchAll((error) => {
532
+ if (mode === "xurl") {
533
+ return Effect.fail(error);
534
+ }
535
+ return Effect.succeed({ ok: false as const });
536
+ }),
537
+ );
538
+ if (xurlPayload.ok) {
539
+ payload = xurlPayload.value;
540
+ source = "xurl";
541
+ } else {
542
+ payload = yield* fetchBirdCollectionEffect({
543
+ kind,
544
+ limit,
545
+ all,
546
+ maxPages: parsedMaxPages,
547
+ });
548
+ source = "bird";
511
549
  }
512
- payload = await fetchBirdCollection({
513
- kind,
514
- limit,
515
- all,
516
- maxPages: parsedMaxPages,
517
- });
518
- source = "bird";
519
550
  }
520
- }
521
551
 
522
- mergeTimelineCollectionIntoLocalStore(
523
- db,
524
- resolvedAccount.accountId,
525
- kind,
526
- payload,
527
- source,
528
- );
529
- writeSyncCache(cacheKey, payload, db);
530
- const saturatedAtPage = readSaturatedAtPage(payload);
552
+ yield* trySync(() =>
553
+ mergeTimelineCollectionIntoLocalStore(
554
+ db,
555
+ resolvedAccount.accountId,
556
+ kind,
557
+ payload,
558
+ source,
559
+ ),
560
+ );
561
+ yield* trySync(() => writeSyncCache(cacheKey, payload, db));
562
+ const saturatedAtPage = readSaturatedAtPage(payload);
531
563
 
532
- return {
533
- ok: true,
534
- source,
535
- kind,
536
- accountId: resolvedAccount.accountId,
537
- count: payload.data.length,
538
- payload,
539
- ...(saturatedAtPage === undefined
540
- ? {}
541
- : { saturated_at_page: saturatedAtPage }),
542
- };
564
+ return {
565
+ ok: true,
566
+ source,
567
+ kind,
568
+ accountId: resolvedAccount.accountId,
569
+ count: payload.data.length,
570
+ payload,
571
+ ...(saturatedAtPage === undefined
572
+ ? {}
573
+ : { saturated_at_page: saturatedAtPage }),
574
+ };
575
+ });
576
+ }
577
+
578
+ export function syncTimelineCollection(options: SyncTimelineCollectionOptions) {
579
+ return runEffectPromise(syncTimelineCollectionEffect(options));
543
580
  }
@@ -1,6 +1,8 @@
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
8
  import type { XurlMentionsResponse } from "./types";
@@ -9,6 +11,14 @@ import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
9
11
 
10
12
  const DEFAULT_TIMELINE_CACHE_TTL_MS = 2 * 60_000;
11
13
 
14
+ export interface SyncHomeTimelineOptions {
15
+ account?: string;
16
+ limit?: number;
17
+ following?: boolean;
18
+ refresh?: boolean;
19
+ cacheTtlMs?: number;
20
+ }
21
+
12
22
  function parseCacheTtlMs(value?: number) {
13
23
  if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
14
24
  return DEFAULT_TIMELINE_CACHE_TTL_MS;
@@ -123,55 +133,66 @@ function mergeHomeTimelineIntoLocalStore(
123
133
  })();
124
134
  }
125
135
 
126
- export async function syncHomeTimeline({
136
+ export function syncHomeTimelineEffect({
127
137
  account,
128
138
  limit = 100,
129
139
  following = true,
130
140
  refresh = false,
131
141
  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;
142
+ }: SyncHomeTimelineOptions = {}): Effect.Effect<
143
+ {
144
+ ok: true;
145
+ source: "bird" | "cache";
146
+ kind: "timeline";
147
+ accountId: string;
148
+ feed: "following" | "for-you";
149
+ count: number;
150
+ payload: XurlMentionsResponse;
151
+ },
152
+ unknown
153
+ > {
154
+ return Effect.gen(function* () {
155
+ assertLimit(limit);
156
+ const db = getNativeDb();
157
+ const accountId = resolveAccount(db, account);
158
+ const cacheKey = `timeline:bird:${accountId}:${following ? "following" : "for-you"}:${String(limit)}`;
159
+ const ttlMs = parseCacheTtlMs(cacheTtlMs);
160
+ const cached = readSyncCache<XurlMentionsResponse>(cacheKey, db);
161
+ const cacheAgeMs = cached
162
+ ? Date.now() - new Date(cached.updatedAt).getTime()
163
+ : Number.POSITIVE_INFINITY;
164
+
165
+ if (!refresh && cached && cacheAgeMs <= ttlMs) {
166
+ return {
167
+ ok: true,
168
+ source: "cache",
169
+ kind: "timeline",
170
+ accountId,
171
+ feed: following ? "following" : "for-you",
172
+ count: cached.value.data.length,
173
+ payload: cached.value,
174
+ } as const;
175
+ }
176
+
177
+ const payload = yield* listHomeTimelineViaBirdEffect({
178
+ maxResults: limit,
179
+ following,
180
+ });
181
+ mergeHomeTimelineIntoLocalStore(db, accountId, payload);
182
+ writeSyncCache(cacheKey, payload, db);
148
183
 
149
- if (!refresh && cached && cacheAgeMs <= ttlMs) {
150
184
  return {
151
185
  ok: true,
152
- source: "cache",
186
+ source: "bird",
153
187
  kind: "timeline",
154
188
  accountId,
155
189
  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,
190
+ count: payload.data.length,
191
+ payload,
192
+ } as const;
164
193
  });
165
- mergeHomeTimelineIntoLocalStore(db, accountId, payload);
166
- writeSyncCache(cacheKey, payload, db);
194
+ }
167
195
 
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
- };
196
+ export function syncHomeTimeline(options: SyncHomeTimelineOptions = {}) {
197
+ return runEffectPromise(syncHomeTimelineEffect(options));
177
198
  }
@@ -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
  }