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,5 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
+ import { Effect } from "effect";
2
3
  import { addBlock } from "./blocks";
4
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
3
5
 
4
6
  export interface BlocklistImportItem {
5
7
  query: string;
@@ -76,14 +78,31 @@ export function parseBlocklistText(text: string) {
76
78
  return items;
77
79
  }
78
80
 
79
- export async function importBlocklist(accountId: string, filePath: string) {
80
- const text = await readFile(filePath, "utf8");
81
- const queries = parseBlocklistText(text);
82
- const items: BlocklistImportItem[] = [];
81
+ export function importBlocklistEffect(accountId: string, filePath: string) {
82
+ return Effect.gen(function* () {
83
+ const text = yield* tryPromise(() => readFile(filePath, "utf8"));
84
+ const queries = parseBlocklistText(text);
85
+ const items: BlocklistImportItem[] = [];
83
86
 
84
- for (const query of queries) {
85
- try {
86
- const result = await addBlock(accountId, query);
87
+ for (const query of queries) {
88
+ const blocked = yield* tryPromise(() => addBlock(accountId, query)).pipe(
89
+ Effect.map((result) => ({ ok: true as const, result })),
90
+ Effect.catchAll((error) =>
91
+ Effect.succeed({ ok: false as const, error }),
92
+ ),
93
+ );
94
+ if (!blocked.ok) {
95
+ items.push({
96
+ query,
97
+ ok: false,
98
+ error:
99
+ blocked.error instanceof Error
100
+ ? blocked.error.message
101
+ : "block failed",
102
+ });
103
+ continue;
104
+ }
105
+ const { result } = blocked;
87
106
  if (!result.ok) {
88
107
  items.push({
89
108
  query,
@@ -98,22 +117,20 @@ export async function importBlocklist(accountId: string, filePath: string) {
98
117
  blockedAt: result.blockedAt,
99
118
  handle: result.profile.handle,
100
119
  });
101
- } catch (error) {
102
- items.push({
103
- query,
104
- ok: false,
105
- error: error instanceof Error ? error.message : "block failed",
106
- });
107
120
  }
108
- }
109
121
 
110
- return {
111
- ok: items.every((item) => item.ok),
112
- accountId,
113
- path: filePath,
114
- requestedCount: queries.length,
115
- blockedCount: items.filter((item) => item.ok).length,
116
- failedCount: items.filter((item) => !item.ok).length,
117
- items,
118
- } satisfies BlocklistImportResult;
122
+ return {
123
+ ok: items.every((item) => item.ok),
124
+ accountId,
125
+ path: filePath,
126
+ requestedCount: queries.length,
127
+ blockedCount: items.filter((item) => item.ok).length,
128
+ failedCount: items.filter((item) => !item.ok).length,
129
+ items,
130
+ } satisfies BlocklistImportResult;
131
+ });
132
+ }
133
+
134
+ export function importBlocklist(accountId: string, filePath: string) {
135
+ return runEffectPromise(importBlocklistEffect(accountId, filePath));
119
136
  }
@@ -1,118 +1,167 @@
1
+ import { Effect } from "effect";
1
2
  import { runModerationAction } from "./actions-transport";
3
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
2
4
  import {
3
5
  deleteModerationRow,
4
6
  type ModerationActionOptions,
5
- resolveModerationTarget,
7
+ resolveModerationTargetEffect,
6
8
  writeModerationRow,
7
9
  } from "./moderation-write";
8
10
 
9
- export async function addBlock(
11
+ function trySync<T>(try_: () => T) {
12
+ return Effect.try({
13
+ try: try_,
14
+ catch: (error) =>
15
+ error instanceof Error ? error : new Error(String(error)),
16
+ });
17
+ }
18
+
19
+ export function addBlockEffect(
10
20
  accountId: string,
11
21
  query: string,
12
22
  options: ModerationActionOptions = {},
13
23
  ) {
14
- const { db, resolved, resolvedAccountId, actionQuery } =
15
- await resolveModerationTarget({
16
- accountId,
17
- query,
18
- selfActionError: "Cannot block the current account",
19
- });
20
- const transport = await runModerationAction({
21
- action: "block",
22
- query: actionQuery,
23
- targetUserId: resolved.externalUserId ?? undefined,
24
- transport: options.transport,
25
- });
24
+ return Effect.gen(function* () {
25
+ const { db, resolved, resolvedAccountId, accountIdentity, actionQuery } =
26
+ yield* resolveModerationTargetEffect({
27
+ accountId,
28
+ query,
29
+ selfActionError: "Cannot block the current account",
30
+ });
31
+ const transport = yield* tryPromise(() =>
32
+ runModerationAction({
33
+ action: "block",
34
+ query: actionQuery,
35
+ targetUserId: resolved.externalUserId ?? undefined,
36
+ transport: options.transport,
37
+ expectedAccount: accountIdentity,
38
+ }),
39
+ );
40
+
41
+ if (!transport.ok) {
42
+ return {
43
+ ok: false,
44
+ action: "block",
45
+ accountId: resolvedAccountId,
46
+ profile: resolved.profile,
47
+ transport,
48
+ };
49
+ }
50
+
51
+ const blockedAt = new Date().toISOString();
52
+ yield* trySync(() =>
53
+ writeModerationRow(
54
+ db,
55
+ "blocks",
56
+ resolvedAccountId,
57
+ resolved.profile.id,
58
+ blockedAt,
59
+ ),
60
+ );
26
61
 
27
- if (!transport.ok) {
28
62
  return {
29
- ok: false,
63
+ ok: true,
30
64
  action: "block",
31
65
  accountId: resolvedAccountId,
66
+ blockedAt,
32
67
  profile: resolved.profile,
33
68
  transport,
34
69
  };
35
- }
36
-
37
- const blockedAt = new Date().toISOString();
38
- writeModerationRow(
39
- db,
40
- "blocks",
41
- resolvedAccountId,
42
- resolved.profile.id,
43
- blockedAt,
44
- );
70
+ });
71
+ }
45
72
 
46
- return {
47
- ok: true,
48
- action: "block",
49
- accountId: resolvedAccountId,
50
- blockedAt,
51
- profile: resolved.profile,
52
- transport,
53
- };
73
+ export function addBlock(
74
+ accountId: string,
75
+ query: string,
76
+ options: ModerationActionOptions = {},
77
+ ) {
78
+ return runEffectPromise(addBlockEffect(accountId, query, options));
54
79
  }
55
80
 
56
- export async function recordBlock(accountId: string, query: string) {
57
- const { db, resolved, resolvedAccountId } = await resolveModerationTarget({
58
- accountId,
59
- query,
60
- selfActionError: "Cannot block the current account",
61
- });
81
+ export function recordBlockEffect(accountId: string, query: string) {
82
+ return Effect.gen(function* () {
83
+ const { db, resolved, resolvedAccountId } =
84
+ yield* resolveModerationTargetEffect({
85
+ accountId,
86
+ query,
87
+ selfActionError: "Cannot block the current account",
88
+ });
62
89
 
63
- const blockedAt = new Date().toISOString();
64
- writeModerationRow(
65
- db,
66
- "blocks",
67
- resolvedAccountId,
68
- resolved.profile.id,
69
- blockedAt,
70
- );
90
+ const blockedAt = new Date().toISOString();
91
+ yield* trySync(() =>
92
+ writeModerationRow(
93
+ db,
94
+ "blocks",
95
+ resolvedAccountId,
96
+ resolved.profile.id,
97
+ blockedAt,
98
+ ),
99
+ );
71
100
 
72
- return {
73
- ok: true,
74
- action: "record-block",
75
- accountId: resolvedAccountId,
76
- blockedAt,
77
- profile: resolved.profile,
78
- };
101
+ return {
102
+ ok: true,
103
+ action: "record-block",
104
+ accountId: resolvedAccountId,
105
+ blockedAt,
106
+ profile: resolved.profile,
107
+ };
108
+ });
109
+ }
110
+
111
+ export function recordBlock(accountId: string, query: string) {
112
+ return runEffectPromise(recordBlockEffect(accountId, query));
79
113
  }
80
114
 
81
- export async function removeBlock(
115
+ export function removeBlockEffect(
82
116
  accountId: string,
83
117
  query: string,
84
118
  options: ModerationActionOptions = {},
85
119
  ) {
86
- const { db, resolved, resolvedAccountId, actionQuery } =
87
- await resolveModerationTarget({
88
- accountId,
89
- query,
90
- selfActionError: "Cannot block the current account",
91
- });
92
- const transport = await runModerationAction({
93
- action: "unblock",
94
- query: actionQuery,
95
- targetUserId: resolved.externalUserId ?? undefined,
96
- transport: options.transport,
97
- });
120
+ return Effect.gen(function* () {
121
+ const { db, resolved, resolvedAccountId, accountIdentity, actionQuery } =
122
+ yield* resolveModerationTargetEffect({
123
+ accountId,
124
+ query,
125
+ selfActionError: "Cannot block the current account",
126
+ });
127
+ const transport = yield* tryPromise(() =>
128
+ runModerationAction({
129
+ action: "unblock",
130
+ query: actionQuery,
131
+ targetUserId: resolved.externalUserId ?? undefined,
132
+ transport: options.transport,
133
+ expectedAccount: accountIdentity,
134
+ }),
135
+ );
136
+
137
+ if (!transport.ok) {
138
+ return {
139
+ ok: false,
140
+ action: "unblock",
141
+ accountId: resolvedAccountId,
142
+ profile: resolved.profile,
143
+ transport,
144
+ };
145
+ }
146
+
147
+ yield* trySync(() =>
148
+ deleteModerationRow(db, "blocks", resolvedAccountId, resolved.profile.id),
149
+ );
98
150
 
99
- if (!transport.ok) {
100
151
  return {
101
- ok: false,
152
+ ok: true,
102
153
  action: "unblock",
103
154
  accountId: resolvedAccountId,
104
155
  profile: resolved.profile,
105
156
  transport,
106
157
  };
107
- }
108
-
109
- deleteModerationRow(db, "blocks", resolvedAccountId, resolved.profile.id);
158
+ });
159
+ }
110
160
 
111
- return {
112
- ok: true,
113
- action: "unblock",
114
- accountId: resolvedAccountId,
115
- profile: resolved.profile,
116
- transport,
117
- };
161
+ export function removeBlock(
162
+ accountId: string,
163
+ query: string,
164
+ options: ModerationActionOptions = {},
165
+ ) {
166
+ return runEffectPromise(removeBlockEffect(accountId, query, options));
118
167
  }
package/src/lib/blocks.ts CHANGED
@@ -1,5 +1,7 @@
1
+ import { Effect } from "effect";
1
2
  import type { Database } from "./sqlite";
2
3
  import { getNativeDb } from "./db";
4
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
3
5
  import {
4
6
  getAccountHandle,
5
7
  getDefaultAccountId,
@@ -7,14 +9,32 @@ import {
7
9
  } from "./moderation-target";
8
10
  import type { BlockItem, BlockListResponse, BlockSearchItem } from "./types";
9
11
  import { upsertProfileFromXUser } from "./x-profile";
10
- import { listBlockedUsers, lookupAuthenticatedUser } from "./xurl";
12
+ import { listBlockedUsers, lookupAuthenticatedUserFresh } from "./xurl";
11
13
 
12
- export { addBlock, recordBlock, removeBlock } from "./blocks-write";
14
+ export {
15
+ addBlock,
16
+ addBlockEffect,
17
+ recordBlock,
18
+ recordBlockEffect,
19
+ removeBlock,
20
+ removeBlockEffect,
21
+ } from "./blocks-write";
13
22
 
14
23
  function remoteBlockSyncDisabled() {
15
24
  return process.env.BIRDCLAW_DISABLE_LIVE_WRITES === "1";
16
25
  }
17
26
 
27
+ function toError(error: unknown) {
28
+ return error instanceof Error ? error : new Error(String(error));
29
+ }
30
+
31
+ function trySync<T>(try_: () => T) {
32
+ return Effect.try({
33
+ try: try_,
34
+ catch: toError,
35
+ });
36
+ }
37
+
18
38
  function upsertRemoteBlock(
19
39
  db: Database,
20
40
  accountId: string,
@@ -206,116 +226,164 @@ export function getBlocksResponse({
206
226
  };
207
227
  }
208
228
 
209
- export async function syncBlocks(accountId: string) {
210
- const db = getNativeDb();
211
- const resolvedAccountId = accountId || getDefaultAccountId(db);
212
- const blockedAt = new Date().toISOString();
213
- const remoteProfileIds: string[] = [];
214
-
215
- if (remoteBlockSyncDisabled()) {
216
- return {
217
- ok: true,
218
- accountId: resolvedAccountId,
219
- synced: false,
220
- syncedCount: 0,
221
- transport: {
222
- ok: true,
223
- output: "remote block sync disabled in test mode",
224
- },
225
- };
226
- }
227
-
228
- try {
229
- const me = await lookupAuthenticatedUser();
230
- const sourceUserId =
231
- typeof me?.id === "string" && me.id.length > 0 ? me.id : null;
232
- const sourceUsername =
233
- typeof me?.username === "string" ? me.username.replace(/^@/, "") : "";
229
+ export function syncBlocksEffect(accountId: string) {
230
+ return Effect.gen(function* () {
231
+ const db = yield* trySync(() => getNativeDb());
232
+ const resolvedAccountId = accountId || getDefaultAccountId(db);
234
233
  const accountHandle = getAccountHandle(db, resolvedAccountId);
234
+ if (!accountHandle) {
235
+ return yield* Effect.fail(
236
+ new Error(`Unknown account: ${resolvedAccountId}`),
237
+ );
238
+ }
239
+ const account = yield* trySync(
240
+ () =>
241
+ db
242
+ .prepare("select handle, external_user_id from accounts where id = ?")
243
+ .get(resolvedAccountId) as
244
+ | { handle: string; external_user_id: string | null }
245
+ | undefined,
246
+ );
247
+ const blockedAt = new Date().toISOString();
248
+ const remoteProfileIds: string[] = [];
235
249
 
236
- if (!sourceUserId) {
250
+ if (remoteBlockSyncDisabled()) {
237
251
  return {
238
- ok: false,
252
+ ok: true,
239
253
  accountId: resolvedAccountId,
240
254
  synced: false,
241
255
  syncedCount: 0,
242
256
  transport: {
243
- ok: false,
244
- output:
245
- "xurl block sync unavailable without an authenticated account",
257
+ ok: true,
258
+ output: "remote block sync disabled in test mode",
246
259
  },
247
260
  };
248
261
  }
249
262
 
250
- if (accountHandle && sourceUsername && accountHandle !== sourceUsername) {
251
- return {
252
- ok: false,
253
- accountId: resolvedAccountId,
254
- synced: false,
255
- syncedCount: 0,
256
- transport: {
263
+ return yield* Effect.gen(function* () {
264
+ const me = yield* tryPromise(() => lookupAuthenticatedUserFresh()).pipe(
265
+ Effect.mapError(toError),
266
+ );
267
+ const sourceUserId =
268
+ typeof me?.id === "string" && me.id.length > 0 ? me.id : null;
269
+ const sourceUsername =
270
+ typeof me?.username === "string" ? me.username.replace(/^@/, "") : "";
271
+ const accountExternalUserId = account?.external_user_id?.trim() ?? "";
272
+ if (!sourceUserId) {
273
+ return {
257
274
  ok: false,
258
- output: `xurl is authenticated as @${sourceUsername}, not @${accountHandle}`,
259
- },
260
- };
261
- }
275
+ accountId: resolvedAccountId,
276
+ synced: false,
277
+ syncedCount: 0,
278
+ transport: {
279
+ ok: false,
280
+ output:
281
+ "xurl block sync unavailable without an authenticated account",
282
+ },
283
+ };
284
+ }
285
+
286
+ if (accountExternalUserId && sourceUserId !== accountExternalUserId) {
287
+ return {
288
+ ok: false,
289
+ accountId: resolvedAccountId,
290
+ synced: false,
291
+ syncedCount: 0,
292
+ transport: {
293
+ ok: false,
294
+ output: `xurl is authenticated as user ${sourceUserId}, not account ${resolvedAccountId}`,
295
+ },
296
+ };
297
+ }
262
298
 
263
- let nextToken: string | null = null;
264
- let pageCount = 0;
299
+ if (
300
+ !accountExternalUserId &&
301
+ (!sourceUsername || accountHandle !== sourceUsername)
302
+ ) {
303
+ return {
304
+ ok: false,
305
+ accountId: resolvedAccountId,
306
+ synced: false,
307
+ syncedCount: 0,
308
+ transport: {
309
+ ok: false,
310
+ output: sourceUsername
311
+ ? `xurl is authenticated as @${sourceUsername}, not @${accountHandle}`
312
+ : "xurl authenticated username unavailable",
313
+ },
314
+ };
315
+ }
265
316
 
266
- do {
267
- const page = await listBlockedUsers(sourceUserId, nextToken ?? undefined);
268
- const mergeRemotePage = db.transaction(() => {
269
- for (const user of page.items) {
270
- const resolved = upsertProfileFromXUser(db, user);
271
- remoteProfileIds.push(resolved.profile.id);
272
- upsertRemoteBlock(
273
- db,
274
- resolvedAccountId,
275
- resolved.profile.id,
276
- blockedAt,
277
- );
278
- }
279
- });
280
- mergeRemotePage();
281
- nextToken = page.nextToken;
282
- pageCount += 1;
283
- } while (nextToken && pageCount < 20);
317
+ let nextToken: string | null = null;
318
+ let pageCount = 0;
319
+ let completed = false;
284
320
 
285
- const pruneMergedBlocks = db.transaction(() => {
286
- pruneRemoteBlocks(db, resolvedAccountId, remoteProfileIds);
287
- });
288
- pruneMergedBlocks();
321
+ do {
322
+ const page = yield* tryPromise(() =>
323
+ listBlockedUsers(sourceUserId, nextToken ?? undefined),
324
+ ).pipe(Effect.mapError(toError));
325
+ yield* trySync(() => {
326
+ const mergeRemotePage = db.transaction(() => {
327
+ for (const user of page.items) {
328
+ const resolved = upsertProfileFromXUser(db, user);
329
+ remoteProfileIds.push(resolved.profile.id);
330
+ upsertRemoteBlock(
331
+ db,
332
+ resolvedAccountId,
333
+ resolved.profile.id,
334
+ blockedAt,
335
+ );
336
+ }
337
+ });
338
+ mergeRemotePage();
339
+ });
340
+ nextToken = page.nextToken;
341
+ pageCount += 1;
342
+ } while (nextToken && pageCount < 20);
289
343
 
290
- return {
291
- ok: true,
292
- accountId: resolvedAccountId,
293
- synced: true,
294
- syncedCount: remoteProfileIds.length,
295
- transport: {
344
+ completed = !nextToken;
345
+ if (completed) {
346
+ yield* trySync(() => {
347
+ const pruneMergedBlocks = db.transaction(() => {
348
+ pruneRemoteBlocks(db, resolvedAccountId, remoteProfileIds);
349
+ });
350
+ pruneMergedBlocks();
351
+ });
352
+ }
353
+
354
+ return {
296
355
  ok: true,
297
- output: `synced ${remoteProfileIds.length} remote blocks`,
298
- },
299
- };
300
- } catch (error) {
301
- return {
302
- ok: false,
303
- accountId: resolvedAccountId,
304
- synced: remoteProfileIds.length > 0,
305
- syncedCount: remoteProfileIds.length,
306
- transport: {
307
- ok: false,
308
- output:
309
- remoteProfileIds.length > 0
310
- ? `partial block sync after ${remoteProfileIds.length} profiles: ${
311
- error instanceof Error
312
- ? error.message
313
- : "xurl block sync failed"
314
- }`
315
- : error instanceof Error
316
- ? error.message
317
- : "xurl block sync failed",
318
- },
319
- };
320
- }
356
+ accountId: resolvedAccountId,
357
+ synced: true,
358
+ syncedCount: remoteProfileIds.length,
359
+ partial: !completed,
360
+ transport: {
361
+ ok: true,
362
+ output: completed
363
+ ? `synced ${remoteProfileIds.length} remote blocks`
364
+ : `synced ${remoteProfileIds.length} remote blocks (partial; skipped pruning)`,
365
+ },
366
+ };
367
+ }).pipe(
368
+ Effect.catchAll((error) =>
369
+ Effect.succeed({
370
+ ok: false,
371
+ accountId: resolvedAccountId,
372
+ synced: remoteProfileIds.length > 0,
373
+ syncedCount: remoteProfileIds.length,
374
+ transport: {
375
+ ok: false,
376
+ output:
377
+ remoteProfileIds.length > 0
378
+ ? `partial block sync after ${remoteProfileIds.length} profiles: ${error.message}`
379
+ : error.message,
380
+ },
381
+ }),
382
+ ),
383
+ );
384
+ });
385
+ }
386
+
387
+ export function syncBlocks(accountId: string) {
388
+ return runEffectPromise(syncBlocksEffect(accountId));
321
389
  }