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,7 +1,12 @@
1
1
  import type { Database } from "./sqlite";
2
- import { listMentionsViaBird } from "./bird";
2
+ import { Effect } from "effect";
3
+ import {
4
+ getAuthenticatedBirdAccountEffect,
5
+ listMentionsViaBirdEffect,
6
+ } from "./bird";
3
7
  import type { MentionsDataSource } from "./config";
4
8
  import { getNativeDb } from "./db";
9
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
5
10
  import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
6
11
  import { serializeMentionItemsAsXurlCompatible } from "./mentions-export";
7
12
  import { listTimelineItems } from "./queries";
@@ -22,6 +27,38 @@ export const DEFAULT_MENTIONS_CACHE_TTL_MS = 2 * 60_000;
22
27
  const MIN_XURL_MENTIONS_LIMIT = 5;
23
28
  const MAX_XURL_MENTIONS_LIMIT = 100;
24
29
  type MentionSyncMode = Exclude<MentionsDataSource, "birdclaw">;
30
+ type MentionLiveSource = Exclude<MentionSyncMode, "auto">;
31
+ export interface MentionsProgress {
32
+ source: "bird" | "xurl" | "cache";
33
+ fetched: number;
34
+ total?: number;
35
+ page?: number;
36
+ maxPages?: number;
37
+ pageSize?: number;
38
+ done: boolean;
39
+ }
40
+ export interface SyncMentionsOptions {
41
+ account?: string;
42
+ mode?: string;
43
+ limit?: number;
44
+ maxPages?: number;
45
+ refresh?: boolean;
46
+ cacheTtlMs?: number;
47
+ sinceId?: string;
48
+ startTime?: string;
49
+ onProgress?: (progress: MentionsProgress) => void;
50
+ }
51
+ interface ExportMentionsViaCachedLiveSourceOptions {
52
+ mode: MentionSyncMode;
53
+ account?: string;
54
+ search?: string;
55
+ replyFilter?: ReplyFilter;
56
+ limit?: number;
57
+ all?: boolean;
58
+ maxPages?: number;
59
+ refresh?: boolean;
60
+ cacheTtlMs?: number;
61
+ }
25
62
  type MentionScanBoundary =
26
63
  | { kind: "auto" }
27
64
  | { kind: "since"; sinceId: string }
@@ -29,7 +66,7 @@ type MentionScanBoundary =
29
66
  | { kind: "unbounded" };
30
67
  interface MentionScanShape {
31
68
  endpoint: "mentions";
32
- mode: MentionSyncMode;
69
+ mode: MentionLiveSource;
33
70
  accountId: string;
34
71
  pageSize: number;
35
72
  boundary: MentionScanBoundary;
@@ -44,6 +81,17 @@ interface MentionHighWaterValue {
44
81
  sinceId: string;
45
82
  }
46
83
 
84
+ function toError(error: unknown) {
85
+ return error instanceof Error ? error : new Error(String(error));
86
+ }
87
+
88
+ function trySync<T>(try_: () => T) {
89
+ return Effect.try({
90
+ try: try_,
91
+ catch: toError,
92
+ });
93
+ }
94
+
47
95
  function getMentionsFetchModeKey({
48
96
  scope,
49
97
  mode,
@@ -133,7 +181,7 @@ function getMentionHighWaterKey({
133
181
  mode,
134
182
  accountId,
135
183
  }: {
136
- mode: MentionSyncMode;
184
+ mode: MentionLiveSource;
137
185
  accountId: string;
138
186
  }) {
139
187
  return `mentions:sync:high-water:v1:mode=${mode}:account=${encodeCacheKeyPart(accountId)}`;
@@ -234,9 +282,9 @@ function parseMaxPages(value?: number) {
234
282
  }
235
283
 
236
284
  function parseSyncMode(value?: string): MentionSyncMode {
237
- const mode = value ?? "xurl";
238
- if (mode !== "bird" && mode !== "xurl") {
239
- throw new Error("--mode must be bird or xurl");
285
+ const mode = value ?? "auto";
286
+ if (mode !== "auto" && mode !== "bird" && mode !== "xurl") {
287
+ throw new Error("--mode must be auto, bird, or xurl");
240
288
  }
241
289
  return mode;
242
290
  }
@@ -358,7 +406,7 @@ function getNewestMentionId(payload: XurlMentionsResponse) {
358
406
 
359
407
  function readMentionHighWaterId(
360
408
  db: Database,
361
- mode: MentionSyncMode,
409
+ mode: MentionLiveSource,
362
410
  accountId: string,
363
411
  ) {
364
412
  const cached = readSyncCache<MentionHighWaterValue>(
@@ -372,7 +420,7 @@ function readMentionHighWaterId(
372
420
 
373
421
  function writeMentionHighWaterId(
374
422
  db: Database,
375
- mode: MentionSyncMode,
423
+ mode: MentionLiveSource,
376
424
  accountId: string,
377
425
  sinceId: string | undefined,
378
426
  ) {
@@ -385,18 +433,24 @@ function writeMentionHighWaterId(
385
433
  function resolveAccount(db: Database, accountId?: string) {
386
434
  const row = accountId
387
435
  ? (db
388
- .prepare("select id, handle from accounts where id = ?")
389
- .get(accountId) as { id: string; handle: string } | undefined)
436
+ .prepare(
437
+ "select id, handle, external_user_id from accounts where id = ?",
438
+ )
439
+ .get(accountId) as
440
+ | { id: string; handle: string; external_user_id: string | null }
441
+ | undefined)
390
442
  : (db
391
443
  .prepare(
392
444
  `
393
- select id, handle
445
+ select id, handle, external_user_id
394
446
  from accounts
395
447
  order by is_default desc, created_at asc
396
448
  limit 1
397
449
  `,
398
450
  )
399
- .get() as { id: string; handle: string } | undefined);
451
+ .get() as
452
+ | { id: string; handle: string; external_user_id: string | null }
453
+ | undefined);
400
454
 
401
455
  if (!row) {
402
456
  throw new Error(`Unknown account: ${accountId ?? "default"}`);
@@ -405,9 +459,45 @@ function resolveAccount(db: Database, accountId?: string) {
405
459
  return {
406
460
  accountId: row.id,
407
461
  username: row.handle.replace(/^@/, ""),
462
+ externalUserId:
463
+ typeof row.external_user_id === "string" &&
464
+ row.external_user_id.trim().length > 0
465
+ ? row.external_user_id.trim()
466
+ : undefined,
408
467
  };
409
468
  }
410
469
 
470
+ function verifyBirdAccountMatchesEffect({
471
+ accountId,
472
+ username,
473
+ externalUserId,
474
+ }: ReturnType<typeof resolveAccount>) {
475
+ return Effect.gen(function* () {
476
+ const authenticated = yield* getAuthenticatedBirdAccountEffect();
477
+ if (
478
+ externalUserId &&
479
+ authenticated.id &&
480
+ authenticated.id === externalUserId
481
+ ) {
482
+ return;
483
+ }
484
+ if (externalUserId && authenticated.id) {
485
+ return yield* Effect.fail(
486
+ new Error(
487
+ `bird is authenticated as user ${authenticated.id}; refusing to sync into ${accountId} (${externalUserId})`,
488
+ ),
489
+ );
490
+ }
491
+ if (authenticated.username.toLowerCase() !== username.toLowerCase()) {
492
+ return yield* Effect.fail(
493
+ new Error(
494
+ `bird is authenticated as @${authenticated.username}; refusing to sync into ${accountId} (@${username})`,
495
+ ),
496
+ );
497
+ }
498
+ });
499
+ }
500
+
411
501
  function findNewestArchiveMentionId(db: Database, accountId: string) {
412
502
  const row = db
413
503
  .prepare(
@@ -655,7 +745,7 @@ function mergeMentionPayloads(
655
745
  };
656
746
  }
657
747
 
658
- async function fetchMentionsViaXurl({
748
+ function fetchMentionsViaXurlEffect({
659
749
  resolvedAccount,
660
750
  limit,
661
751
  all,
@@ -663,6 +753,7 @@ async function fetchMentionsViaXurl({
663
753
  sinceId,
664
754
  startPaginationToken,
665
755
  startTime,
756
+ onProgress,
666
757
  }: {
667
758
  resolvedAccount: ReturnType<typeof resolveAccount>;
668
759
  limit: number;
@@ -671,44 +762,71 @@ async function fetchMentionsViaXurl({
671
762
  sinceId?: string;
672
763
  startPaginationToken?: string;
673
764
  startTime?: string;
765
+ onProgress?: (progress: MentionsProgress) => void;
674
766
  }) {
675
- const [accountUser] = await lookupUsersByHandles([resolvedAccount.username]);
676
- if (!accountUser?.id) {
677
- throw new Error(
678
- `Could not resolve Twitter user id for @${resolvedAccount.username}`,
679
- );
680
- }
767
+ return Effect.gen(function* () {
768
+ const accountUserId =
769
+ resolvedAccount.externalUserId ??
770
+ (yield* tryPromise(() =>
771
+ lookupUsersByHandles([resolvedAccount.username]),
772
+ ).pipe(Effect.map((users) => users[0]?.id)));
773
+ if (!accountUserId) {
774
+ return yield* Effect.fail(
775
+ new Error(
776
+ `Could not resolve Twitter user id for @${resolvedAccount.username}`,
777
+ ),
778
+ );
779
+ }
681
780
 
682
- const pages: XurlMentionsResponse[] = [];
683
- let nextToken: string | undefined = startPaginationToken;
684
- let pageCount = 0;
685
- do {
686
- const payload = await listMentionsViaXurl({
687
- maxResults: limit,
688
- username: resolvedAccount.username,
689
- userId: String(accountUser.id),
690
- paginationToken: nextToken,
691
- ...(sinceId ? { sinceId } : {}),
692
- ...(startTime ? { startTime } : {}),
693
- });
694
- pages.push(payload);
695
- const metaNextToken =
696
- typeof payload.meta?.next_token === "string"
697
- ? payload.meta.next_token
698
- : undefined;
699
- nextToken = metaNextToken;
700
- pageCount += 1;
701
- } while (
702
- all &&
703
- nextToken &&
704
- (parsedMaxPages === null || pageCount < parsedMaxPages)
705
- );
781
+ const pages: XurlMentionsResponse[] = [];
782
+ let nextToken: string | undefined = startPaginationToken;
783
+ let pageCount = 0;
784
+ do {
785
+ const payload = yield* tryPromise(() =>
786
+ listMentionsViaXurl({
787
+ maxResults: limit,
788
+ username: resolvedAccount.username,
789
+ userId: String(accountUserId),
790
+ paginationToken: nextToken,
791
+ ...(sinceId ? { sinceId } : {}),
792
+ ...(startTime ? { startTime } : {}),
793
+ }),
794
+ );
795
+ pages.push(payload);
796
+ const metaNextToken =
797
+ typeof payload.meta?.next_token === "string"
798
+ ? payload.meta.next_token
799
+ : undefined;
800
+ nextToken = metaNextToken;
801
+ pageCount += 1;
802
+ const fetched = pages.reduce((sum, item) => sum + item.data.length, 0);
803
+ const done =
804
+ !all ||
805
+ !nextToken ||
806
+ (parsedMaxPages !== null && pageCount >= parsedMaxPages);
807
+ yield* Effect.sync(() =>
808
+ onProgress?.({
809
+ source: "xurl",
810
+ fetched,
811
+ total: parsedMaxPages === null ? undefined : parsedMaxPages * limit,
812
+ page: pageCount,
813
+ maxPages: parsedMaxPages ?? undefined,
814
+ pageSize: limit,
815
+ done,
816
+ }),
817
+ );
818
+ } while (
819
+ all &&
820
+ nextToken &&
821
+ (parsedMaxPages === null || pageCount < parsedMaxPages)
822
+ );
706
823
 
707
- return mergeMentionPayloads(pages);
824
+ return mergeMentionPayloads(pages);
825
+ });
708
826
  }
709
827
 
710
- async function fetchMentionsViaBird({ limit }: { limit: number }) {
711
- return listMentionsViaBird({ maxResults: limit });
828
+ function fetchMentionsViaBirdEffect({ limit }: { limit: number }) {
829
+ return listMentionsViaBirdEffect({ maxResults: limit });
712
830
  }
713
831
 
714
832
  function isMaxPagesPartial({
@@ -725,7 +843,7 @@ function isMaxPagesPartial({
725
843
  );
726
844
  }
727
845
 
728
- export async function syncMentions({
846
+ export function syncMentionsEffect({
729
847
  account,
730
848
  mode,
731
849
  limit = 20,
@@ -734,222 +852,290 @@ export async function syncMentions({
734
852
  cacheTtlMs,
735
853
  sinceId,
736
854
  startTime,
737
- }: {
738
- account?: string;
739
- mode?: string;
740
- limit?: number;
741
- maxPages?: number;
742
- refresh?: boolean;
743
- cacheTtlMs?: number;
744
- sinceId?: string;
745
- startTime?: string;
746
- }) {
747
- const parsedMode = parseSyncMode(mode);
748
- const explicitSinceId = sinceId?.trim() || undefined;
749
- const explicitStartTime = startTime?.trim() || undefined;
750
- if (parsedMode === "bird" && (explicitSinceId || explicitStartTime)) {
751
- throw new Error("bird mode does not support --since-id or --start-time");
752
- }
753
- if (parsedMode === "xurl") {
754
- assertXurlLimit(limit);
755
- } else {
756
- assertBirdLimit(limit);
757
- }
758
- const parsedMaxPages = parseMaxPages(maxPages);
759
- const fetchAll = parsedMode === "xurl" && parsedMaxPages !== null;
760
- const db = getNativeDb();
761
- const resolvedAccount = resolveAccount(db, account);
762
- const cursorShape: MentionScanShape = {
763
- endpoint: "mentions",
764
- mode: parsedMode,
765
- accountId: resolvedAccount.accountId,
766
- pageSize: limit,
767
- boundary: getMentionCursorBoundary({
768
- explicitSinceId,
769
- explicitStartTime,
770
- }),
771
- };
772
- const cursorKey = getMentionCursorKey(cursorShape);
773
- const legacyCursorKeys = getLegacyMentionCursorKeys(cursorShape);
774
- const cursor =
775
- parsedMode === "xurl"
776
- ? readMentionCursor({
777
- db,
778
- shape: cursorShape,
779
- cursorKey,
780
- legacyCursorKeys,
781
- })
782
- : undefined;
783
- const startPaginationToken = cursor?.token;
784
- const cursorSinceId =
785
- cursor?.boundary?.kind === "since" ? cursor.boundary.sinceId : undefined;
786
- const cursorStartTime =
787
- cursor?.boundary?.kind === "start" ? cursor.boundary.startTime : undefined;
788
- const committedSinceId =
789
- parsedMode === "xurl" &&
790
- cursorShape.boundary.kind === "auto" &&
791
- !startPaginationToken
792
- ? readMentionHighWaterId(db, parsedMode, resolvedAccount.accountId)
793
- : undefined;
794
- const seededSinceId =
795
- parsedMode === "xurl" &&
796
- !explicitSinceId &&
797
- !explicitStartTime &&
798
- !startPaginationToken
799
- ? (committedSinceId ??
800
- findNewestArchiveMentionId(db, resolvedAccount.accountId))
801
- : undefined;
802
- const resolvedSinceId = startPaginationToken
803
- ? cursorSinceId
804
- : (explicitSinceId ?? seededSinceId);
805
- const resolvedStartTime = startPaginationToken
806
- ? cursorStartTime
807
- : !resolvedSinceId
808
- ? explicitStartTime
809
- : undefined;
810
- const resultShape: MentionScanShape = {
811
- endpoint: "mentions",
812
- mode: parsedMode,
813
- accountId: resolvedAccount.accountId,
814
- pageSize: limit,
815
- boundary: getMentionRequestBoundary({
816
- sinceId: resolvedSinceId,
817
- startTime: resolvedStartTime,
818
- }),
819
- };
820
- const resultCacheKey = getMentionResultCacheKey({
821
- shape: resultShape,
822
- all: fetchAll,
823
- maxPages: parsedMaxPages,
824
- });
825
- const ttlMs = parseCacheTtlMs(cacheTtlMs);
826
- const cached = startPaginationToken
827
- ? null
828
- : readSyncCache<XurlMentionsResponse>(resultCacheKey, db);
829
- const cachedPaginationToken = getCachedPaginationToken(cached);
830
- const cacheAgeMs = cached
831
- ? Date.now() - new Date(cached.updatedAt).getTime()
832
- : Number.POSITIVE_INFINITY;
833
-
834
- if (
835
- !startPaginationToken &&
836
- !cachedPaginationToken &&
837
- !refresh &&
838
- cached &&
839
- cacheAgeMs <= ttlMs
840
- ) {
841
- mergeMentionsIntoLocalStore(
842
- db,
843
- resolvedAccount.accountId,
844
- cached.value,
845
- parsedMode,
846
- );
847
- return {
848
- ok: true,
849
- source: "cache",
850
- kind: "mentions",
855
+ onProgress,
856
+ }: SyncMentionsOptions) {
857
+ return Effect.gen(function* () {
858
+ const parsedMode = yield* trySync(() => parseSyncMode(mode));
859
+ const primaryMode: MentionLiveSource =
860
+ parsedMode === "auto" ? "xurl" : parsedMode;
861
+ const explicitSinceId = sinceId?.trim() || undefined;
862
+ const explicitStartTime = startTime?.trim() || undefined;
863
+ if (primaryMode === "bird" && (explicitSinceId || explicitStartTime)) {
864
+ return yield* Effect.fail(
865
+ new Error("bird mode does not support --since-id or --start-time"),
866
+ );
867
+ }
868
+ if (primaryMode === "xurl") {
869
+ yield* trySync(() => assertXurlLimit(limit));
870
+ } else {
871
+ yield* trySync(() => assertBirdLimit(limit));
872
+ }
873
+ const parsedMaxPages = yield* trySync(() => parseMaxPages(maxPages));
874
+ const fetchAll =
875
+ primaryMode === "xurl" &&
876
+ (parsedMaxPages !== null ||
877
+ Boolean(explicitSinceId || explicitStartTime));
878
+ const db = yield* trySync(() => getNativeDb());
879
+ const resolvedAccount = yield* trySync(() => resolveAccount(db, account));
880
+ const cursorShape: MentionScanShape = {
881
+ endpoint: "mentions",
882
+ mode: primaryMode,
851
883
  accountId: resolvedAccount.accountId,
852
- count: cached.value.data.length,
853
- partial: isMaxPagesPartial({
854
- payload: cached.value,
855
- maxPages: parsedMaxPages,
884
+ pageSize: limit,
885
+ boundary: getMentionCursorBoundary({
886
+ explicitSinceId,
887
+ explicitStartTime,
856
888
  }),
857
- payload: cached.value,
858
889
  };
859
- }
860
-
861
- if (
862
- parsedMode === "xurl" &&
863
- !explicitSinceId &&
864
- !explicitStartTime &&
865
- !startPaginationToken &&
866
- !seededSinceId
867
- ) {
868
- console.error(
869
- "No local mention baseline found; syncing mentions from the newest page backwards.",
870
- );
871
- }
890
+ const cursorKey = getMentionCursorKey(cursorShape);
891
+ const legacyCursorKeys = getLegacyMentionCursorKeys(cursorShape);
892
+ const cursor =
893
+ primaryMode === "xurl"
894
+ ? yield* trySync(() =>
895
+ readMentionCursor({
896
+ db,
897
+ shape: cursorShape,
898
+ cursorKey,
899
+ legacyCursorKeys,
900
+ }),
901
+ )
902
+ : undefined;
903
+ const startPaginationToken = cursor?.token;
904
+ const cursorSinceId =
905
+ cursor?.boundary?.kind === "since" ? cursor.boundary.sinceId : undefined;
906
+ const cursorStartTime =
907
+ cursor?.boundary?.kind === "start"
908
+ ? cursor.boundary.startTime
909
+ : undefined;
910
+ const committedSinceId =
911
+ primaryMode === "xurl" &&
912
+ cursorShape.boundary.kind === "auto" &&
913
+ !startPaginationToken
914
+ ? yield* trySync(() =>
915
+ readMentionHighWaterId(db, primaryMode, resolvedAccount.accountId),
916
+ )
917
+ : undefined;
918
+ const seededSinceId =
919
+ primaryMode === "xurl" &&
920
+ !explicitSinceId &&
921
+ !explicitStartTime &&
922
+ !startPaginationToken
923
+ ? (committedSinceId ??
924
+ (yield* trySync(() =>
925
+ findNewestArchiveMentionId(db, resolvedAccount.accountId),
926
+ )))
927
+ : undefined;
928
+ const resolvedSinceId = startPaginationToken
929
+ ? cursorSinceId
930
+ : (explicitSinceId ?? seededSinceId);
931
+ const resolvedStartTime = startPaginationToken
932
+ ? cursorStartTime
933
+ : !resolvedSinceId
934
+ ? explicitStartTime
935
+ : undefined;
936
+ const resultShape: MentionScanShape = {
937
+ endpoint: "mentions",
938
+ mode: primaryMode,
939
+ accountId: resolvedAccount.accountId,
940
+ pageSize: limit,
941
+ boundary: getMentionRequestBoundary({
942
+ sinceId: resolvedSinceId,
943
+ startTime: resolvedStartTime,
944
+ }),
945
+ };
946
+ const resultCacheKey = getMentionResultCacheKey({
947
+ shape: resultShape,
948
+ all: fetchAll,
949
+ maxPages: parsedMaxPages,
950
+ });
951
+ const ttlMs = parseCacheTtlMs(cacheTtlMs);
952
+ const cached = startPaginationToken
953
+ ? null
954
+ : yield* trySync(() =>
955
+ readSyncCache<XurlMentionsResponse>(resultCacheKey, db),
956
+ );
957
+ const cachedPaginationToken = getCachedPaginationToken(cached);
958
+ const cacheAgeMs = cached
959
+ ? Date.now() - new Date(cached.updatedAt).getTime()
960
+ : Number.POSITIVE_INFINITY;
872
961
 
873
- const payload =
874
- parsedMode === "bird"
875
- ? await fetchMentionsViaBird({ limit })
876
- : await fetchMentionsViaXurl({
877
- resolvedAccount,
878
- limit,
879
- all: fetchAll,
880
- parsedMaxPages,
881
- sinceId: resolvedSinceId,
882
- startPaginationToken,
883
- startTime: resolvedStartTime,
884
- });
885
- mergeMentionsIntoLocalStore(
886
- db,
887
- resolvedAccount.accountId,
888
- payload,
889
- parsedMode,
890
- );
891
- const payloadPaginationToken = getCachedPaginationToken({ value: payload });
892
- if (parsedMode === "xurl") {
893
- if (payloadPaginationToken) {
894
- writeSyncCache(
895
- cursorKey,
896
- addMentionCursorState(
897
- payload,
898
- getMentionRequestBoundary({
899
- sinceId: resolvedSinceId,
900
- startTime: resolvedStartTime,
901
- }),
902
- maxNumericTweetId(resolvedSinceId, getNewestMentionId(payload)),
962
+ if (
963
+ !startPaginationToken &&
964
+ !cachedPaginationToken &&
965
+ !refresh &&
966
+ cached &&
967
+ cacheAgeMs <= ttlMs
968
+ ) {
969
+ yield* trySync(() =>
970
+ mergeMentionsIntoLocalStore(
971
+ db,
972
+ resolvedAccount.accountId,
973
+ cached.value,
974
+ primaryMode,
903
975
  ),
904
- db,
905
976
  );
906
- deleteSyncCache(resultCacheKey, db);
907
- deleteSyncCache(
908
- getMentionResultCacheKey({
909
- shape: cursorShape,
910
- all: fetchAll,
977
+ yield* Effect.sync(() =>
978
+ onProgress?.({
979
+ source: "cache",
980
+ fetched: cached.value.data.length,
981
+ total: parsedMaxPages === null ? undefined : parsedMaxPages * limit,
982
+ done: true,
983
+ }),
984
+ );
985
+ return {
986
+ ok: true,
987
+ source: "cache",
988
+ kind: "mentions",
989
+ accountId: resolvedAccount.accountId,
990
+ count: cached.value.data.length,
991
+ partial: isMaxPagesPartial({
992
+ payload: cached.value,
911
993
  maxPages: parsedMaxPages,
912
994
  }),
913
- db,
995
+ payload: cached.value,
996
+ };
997
+ }
998
+
999
+ if (
1000
+ primaryMode === "xurl" &&
1001
+ !explicitSinceId &&
1002
+ !explicitStartTime &&
1003
+ !startPaginationToken &&
1004
+ !seededSinceId
1005
+ ) {
1006
+ console.error(
1007
+ "No local mention baseline found; syncing mentions from the newest page backwards.",
914
1008
  );
915
- for (const legacyKey of cursor?.legacyKeys ?? []) {
916
- deleteSyncCache(legacyKey, db);
917
- }
918
- } else {
919
- deleteSyncCache(cursorKey, db);
920
- for (const legacyKey of legacyCursorKeys) {
921
- deleteSyncCache(legacyKey, db);
922
- }
923
- if (cursorShape.boundary.kind === "auto") {
924
- writeMentionHighWaterId(
925
- db,
926
- parsedMode,
927
- resolvedAccount.accountId,
928
- maxNumericTweetId(
929
- resolvedSinceId,
930
- cursor?.pendingNewestId,
931
- getNewestMentionId(payload),
932
- ),
933
- );
1009
+ }
1010
+
1011
+ let source: MentionLiveSource = primaryMode;
1012
+ const canFallbackToBird =
1013
+ primaryMode === "xurl" &&
1014
+ parsedMode === "auto" &&
1015
+ !explicitSinceId &&
1016
+ !explicitStartTime &&
1017
+ !startPaginationToken;
1018
+ const payload =
1019
+ primaryMode === "bird"
1020
+ ? yield* fetchMentionsViaBirdEffect({ limit })
1021
+ : yield* fetchMentionsViaXurlEffect({
1022
+ resolvedAccount,
1023
+ limit,
1024
+ all: fetchAll,
1025
+ parsedMaxPages,
1026
+ sinceId: resolvedSinceId,
1027
+ startPaginationToken,
1028
+ startTime: resolvedStartTime,
1029
+ onProgress,
1030
+ }).pipe(
1031
+ Effect.catchAll((error) => {
1032
+ if (!canFallbackToBird) return Effect.fail(error);
1033
+ source = "bird";
1034
+ return verifyBirdAccountMatchesEffect(resolvedAccount).pipe(
1035
+ Effect.flatMap(() => fetchMentionsViaBirdEffect({ limit })),
1036
+ );
1037
+ }),
1038
+ );
1039
+ if (source === "bird") {
1040
+ yield* Effect.sync(() =>
1041
+ onProgress?.({
1042
+ source: "bird",
1043
+ fetched: payload.data.length,
1044
+ total: limit,
1045
+ done: true,
1046
+ }),
1047
+ );
1048
+ }
1049
+ yield* trySync(() =>
1050
+ mergeMentionsIntoLocalStore(
1051
+ db,
1052
+ resolvedAccount.accountId,
1053
+ payload,
1054
+ source,
1055
+ ),
1056
+ );
1057
+ const payloadPaginationToken = getCachedPaginationToken({ value: payload });
1058
+ if (source === "xurl") {
1059
+ if (payloadPaginationToken) {
1060
+ yield* trySync(() => {
1061
+ writeSyncCache(
1062
+ cursorKey,
1063
+ addMentionCursorState(
1064
+ payload,
1065
+ getMentionRequestBoundary({
1066
+ sinceId: resolvedSinceId,
1067
+ startTime: resolvedStartTime,
1068
+ }),
1069
+ maxNumericTweetId(resolvedSinceId, getNewestMentionId(payload)),
1070
+ ),
1071
+ db,
1072
+ );
1073
+ deleteSyncCache(resultCacheKey, db);
1074
+ deleteSyncCache(
1075
+ getMentionResultCacheKey({
1076
+ shape: cursorShape,
1077
+ all: fetchAll,
1078
+ maxPages: parsedMaxPages,
1079
+ }),
1080
+ db,
1081
+ );
1082
+ for (const legacyKey of cursor?.legacyKeys ?? []) {
1083
+ deleteSyncCache(legacyKey, db);
1084
+ }
1085
+ });
1086
+ } else {
1087
+ yield* trySync(() => {
1088
+ deleteSyncCache(cursorKey, db);
1089
+ for (const legacyKey of legacyCursorKeys) {
1090
+ deleteSyncCache(legacyKey, db);
1091
+ }
1092
+ if (cursorShape.boundary.kind === "auto") {
1093
+ writeMentionHighWaterId(
1094
+ db,
1095
+ source,
1096
+ resolvedAccount.accountId,
1097
+ maxNumericTweetId(
1098
+ resolvedSinceId,
1099
+ cursor?.pendingNewestId,
1100
+ getNewestMentionId(payload),
1101
+ ),
1102
+ );
1103
+ }
1104
+ });
934
1105
  }
935
1106
  }
936
- }
937
- if (!payloadPaginationToken && !startPaginationToken) {
938
- writeSyncCache(resultCacheKey, payload, db);
939
- }
1107
+ if (!payloadPaginationToken && !startPaginationToken) {
1108
+ const writeCacheKey =
1109
+ source === primaryMode
1110
+ ? resultCacheKey
1111
+ : getMentionResultCacheKey({
1112
+ shape: {
1113
+ ...resultShape,
1114
+ mode: source,
1115
+ },
1116
+ all: false,
1117
+ maxPages: null,
1118
+ });
1119
+ yield* trySync(() => writeSyncCache(writeCacheKey, payload, db));
1120
+ }
940
1121
 
941
- return {
942
- ok: true,
943
- source: parsedMode,
944
- kind: "mentions",
945
- accountId: resolvedAccount.accountId,
946
- count: payload.data.length,
947
- partial: isMaxPagesPartial({ payload, maxPages: parsedMaxPages }),
948
- payload,
949
- };
1122
+ return {
1123
+ ok: true,
1124
+ source,
1125
+ kind: "mentions",
1126
+ accountId: resolvedAccount.accountId,
1127
+ count: payload.data.length,
1128
+ partial: isMaxPagesPartial({ payload, maxPages: parsedMaxPages }),
1129
+ payload,
1130
+ };
1131
+ });
1132
+ }
1133
+
1134
+ export function syncMentions(options: SyncMentionsOptions) {
1135
+ return runEffectPromise(syncMentionsEffect(options));
950
1136
  }
951
1137
 
952
- async function exportMentionsViaCachedLiveSource({
1138
+ function exportMentionsViaCachedLiveSourceEffect({
953
1139
  mode,
954
1140
  account,
955
1141
  search,
@@ -959,90 +1145,37 @@ async function exportMentionsViaCachedLiveSource({
959
1145
  maxPages,
960
1146
  refresh = false,
961
1147
  cacheTtlMs,
962
- }: {
963
- mode: MentionsDataSource;
964
- account?: string;
965
- search?: string;
966
- replyFilter?: ReplyFilter;
967
- limit?: number;
968
- all?: boolean;
969
- maxPages?: number;
970
- refresh?: boolean;
971
- cacheTtlMs?: number;
972
- }) {
973
- if (mode === "xurl") {
974
- assertXurlLimit(limit);
975
- } else {
976
- assertBirdLimit(limit);
977
- }
978
- const parsedMaxPages = parseMaxPages(maxPages);
979
- const fetchAll = mode === "xurl" && (all || parsedMaxPages !== null);
980
-
981
- const db = getNativeDb();
982
- const resolvedAccount = resolveAccount(db, account);
983
- const cacheKey = getMentionsFetchModeKey({
984
- scope: "export",
985
- mode,
986
- accountId: resolvedAccount.accountId,
987
- pageSize: limit,
988
- all: fetchAll,
989
- maxPages: parsedMaxPages,
990
- sinceId: null,
991
- startTime: null,
992
- });
993
- const ttlMs = parseCacheTtlMs(cacheTtlMs);
994
- const cached = readSyncCache<XurlMentionsResponse>(cacheKey, db);
995
- const cacheAgeMs = cached
996
- ? Date.now() - new Date(cached.updatedAt).getTime()
997
- : Number.POSITIVE_INFINITY;
998
-
999
- if (!refresh && cached && cacheAgeMs <= ttlMs) {
1000
- if (
1001
- shouldReturnFilteredLocalPayload({
1002
- search,
1003
- replyFilter,
1004
- })
1005
- ) {
1006
- return readLocalXurlCompatiblePayload({
1007
- accountId: resolvedAccount.accountId,
1008
- search,
1009
- replyFilter,
1010
- limit: fetchAll ? cached.value.data.length : limit,
1011
- });
1012
- }
1013
- return cached.value;
1014
- }
1015
-
1016
- try {
1017
- const payload =
1018
- mode === "bird"
1019
- ? await listMentionsViaBird({ maxResults: limit })
1020
- : await fetchMentionsViaXurl({
1021
- resolvedAccount,
1022
- limit,
1023
- all: fetchAll,
1024
- parsedMaxPages,
1025
- });
1026
- mergeMentionsIntoLocalStore(db, resolvedAccount.accountId, payload, mode);
1027
- writeSyncCache(cacheKey, payload, db);
1028
-
1029
- if (
1030
- shouldReturnFilteredLocalPayload({
1031
- search,
1032
- replyFilter,
1033
- })
1034
- ) {
1035
- return readLocalXurlCompatiblePayload({
1036
- accountId: resolvedAccount.accountId,
1037
- search,
1038
- replyFilter,
1039
- limit: fetchAll ? payload.data.length : limit,
1040
- });
1148
+ }: ExportMentionsViaCachedLiveSourceOptions) {
1149
+ return Effect.gen(function* () {
1150
+ const primaryMode: MentionLiveSource = mode === "auto" ? "xurl" : mode;
1151
+ if (primaryMode === "xurl") {
1152
+ yield* trySync(() => assertXurlLimit(limit));
1153
+ } else {
1154
+ yield* trySync(() => assertBirdLimit(limit));
1041
1155
  }
1042
-
1043
- return payload;
1044
- } catch (error) {
1045
- if (!refresh && cached) {
1156
+ const parsedMaxPages = yield* trySync(() => parseMaxPages(maxPages));
1157
+ const fetchAll = primaryMode === "xurl" && (all || parsedMaxPages !== null);
1158
+
1159
+ const db = yield* trySync(() => getNativeDb());
1160
+ const resolvedAccount = yield* trySync(() => resolveAccount(db, account));
1161
+ const cacheKey = getMentionsFetchModeKey({
1162
+ scope: "export",
1163
+ mode,
1164
+ accountId: resolvedAccount.accountId,
1165
+ pageSize: limit,
1166
+ all: fetchAll,
1167
+ maxPages: parsedMaxPages,
1168
+ sinceId: null,
1169
+ startTime: null,
1170
+ });
1171
+ const ttlMs = parseCacheTtlMs(cacheTtlMs);
1172
+ const cached = yield* trySync(() =>
1173
+ readSyncCache<XurlMentionsResponse>(cacheKey, db),
1174
+ );
1175
+ const cacheAgeMs = cached
1176
+ ? Date.now() - new Date(cached.updatedAt).getTime()
1177
+ : Number.POSITIVE_INFINITY;
1178
+ const readFilteredOrRaw = (payload: XurlMentionsResponse) => {
1046
1179
  if (
1047
1180
  shouldReturnFilteredLocalPayload({
1048
1181
  search,
@@ -1053,35 +1186,97 @@ async function exportMentionsViaCachedLiveSource({
1053
1186
  accountId: resolvedAccount.accountId,
1054
1187
  search,
1055
1188
  replyFilter,
1056
- limit: fetchAll ? cached.value.data.length : limit,
1189
+ limit: fetchAll ? payload.data.length : limit,
1057
1190
  });
1058
1191
  }
1059
- return cached.value;
1192
+ return payload;
1193
+ };
1194
+
1195
+ if (!refresh && cached && cacheAgeMs <= ttlMs) {
1196
+ return yield* trySync(() => readFilteredOrRaw(cached.value));
1060
1197
  }
1061
- throw error;
1062
- }
1198
+
1199
+ let source: MentionLiveSource = primaryMode;
1200
+ const liveResult = yield* (
1201
+ primaryMode === "bird"
1202
+ ? fetchMentionsViaBirdEffect({ limit })
1203
+ : fetchMentionsViaXurlEffect({
1204
+ resolvedAccount,
1205
+ limit,
1206
+ all: fetchAll,
1207
+ parsedMaxPages,
1208
+ })
1209
+ ).pipe(
1210
+ Effect.catchAll((error) => {
1211
+ if (mode !== "auto" || fetchAll) return Effect.fail(error);
1212
+ source = "bird";
1213
+ return verifyBirdAccountMatchesEffect(resolvedAccount).pipe(
1214
+ Effect.flatMap(() => fetchMentionsViaBirdEffect({ limit })),
1215
+ );
1216
+ }),
1217
+ Effect.flatMap((payload) =>
1218
+ trySync(() => {
1219
+ mergeMentionsIntoLocalStore(
1220
+ db,
1221
+ resolvedAccount.accountId,
1222
+ payload,
1223
+ source,
1224
+ );
1225
+ writeSyncCache(cacheKey, payload, db);
1226
+ return readFilteredOrRaw(payload);
1227
+ }),
1228
+ ),
1229
+ Effect.map((payload) => ({ ok: true as const, payload })),
1230
+ Effect.catchAll((error) => {
1231
+ if (!refresh && cached) {
1232
+ return Effect.succeed({ ok: false as const });
1233
+ }
1234
+ return Effect.fail(error);
1235
+ }),
1236
+ );
1237
+
1238
+ if (!liveResult.ok) {
1239
+ if (!cached) {
1240
+ return yield* Effect.fail(
1241
+ new Error("Mention export failed without cache"),
1242
+ );
1243
+ }
1244
+ return yield* trySync(() => readFilteredOrRaw(cached.value));
1245
+ }
1246
+
1247
+ return liveResult.payload;
1248
+ });
1063
1249
  }
1064
1250
 
1065
- export async function exportMentionsViaCachedXurl(
1066
- options: Omit<
1067
- Parameters<typeof exportMentionsViaCachedLiveSource>[0],
1068
- "mode"
1069
- >,
1251
+ export function exportMentionsViaCachedXurl(
1252
+ options: Omit<ExportMentionsViaCachedLiveSourceOptions, "mode">,
1070
1253
  ) {
1071
- return exportMentionsViaCachedLiveSource({
1072
- ...options,
1073
- mode: "xurl",
1074
- });
1254
+ return runEffectPromise(
1255
+ exportMentionsViaCachedLiveSourceEffect({
1256
+ ...options,
1257
+ mode: "xurl",
1258
+ }),
1259
+ );
1075
1260
  }
1076
1261
 
1077
- export async function exportMentionsViaCachedBird(
1078
- options: Omit<
1079
- Parameters<typeof exportMentionsViaCachedLiveSource>[0],
1080
- "mode"
1081
- >,
1262
+ export function exportMentionsViaCachedBird(
1263
+ options: Omit<ExportMentionsViaCachedLiveSourceOptions, "mode">,
1082
1264
  ) {
1083
- return exportMentionsViaCachedLiveSource({
1084
- ...options,
1085
- mode: "bird",
1086
- });
1265
+ return runEffectPromise(
1266
+ exportMentionsViaCachedLiveSourceEffect({
1267
+ ...options,
1268
+ mode: "bird",
1269
+ }),
1270
+ );
1271
+ }
1272
+
1273
+ export function exportMentionsViaCachedAuto(
1274
+ options: Omit<ExportMentionsViaCachedLiveSourceOptions, "mode">,
1275
+ ) {
1276
+ return runEffectPromise(
1277
+ exportMentionsViaCachedLiveSourceEffect({
1278
+ ...options,
1279
+ mode: "auto",
1280
+ }),
1281
+ );
1087
1282
  }