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,8 +1,14 @@
1
+ import { Effect } from "effect";
2
+
1
3
  import { getNativeDb } from "./db";
2
- import { lookupProfileViaBird, lookupProfilesViaBird } from "./bird";
4
+ import {
5
+ lookupProfileViaBirdEffect,
6
+ lookupProfilesViaBirdEffect,
7
+ } from "./bird";
8
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
3
9
  import { readSyncCache, writeSyncCache } from "./sync-cache";
4
10
  import {
5
- hydrateProfileAffiliationOrganizations,
11
+ hydrateProfileAffiliationOrganizationsEffect,
6
12
  type ProfileAffiliationHydrationResult,
7
13
  } from "./profile-affiliation-hydration";
8
14
  import type { ProfileRecord, XurlMentionUser } from "./types";
@@ -22,6 +28,17 @@ interface CachedProfileLookup {
22
28
  error?: string;
23
29
  }
24
30
 
31
+ function toError(error: unknown) {
32
+ return error instanceof Error ? error : new Error(String(error));
33
+ }
34
+
35
+ function trySync<T>(try_: () => T) {
36
+ return Effect.try({
37
+ try: try_,
38
+ catch: toError,
39
+ });
40
+ }
41
+
25
42
  export interface ResolveProfilesOptions {
26
43
  refresh?: boolean;
27
44
  maxAgeMs?: number;
@@ -130,384 +147,467 @@ function writeProfileLookupCache(
130
147
  writeSyncCache(cacheKeyForUserId(externalUserId), value);
131
148
  }
132
149
 
133
- function updateConversationTitles(profile: ProfileRecord) {
134
- getNativeDb()
135
- .prepare(
136
- `
150
+ function updateConversationTitles(profile: ProfileRecord, db = getNativeDb()) {
151
+ db.prepare(
152
+ `
137
153
  update dm_conversations
138
154
  set title = ?
139
155
  where participant_profile_id = ?
140
156
  `,
141
- )
142
- .run(profile.displayName || profile.handle, profile.id);
157
+ ).run(profile.displayName || profile.handle, profile.id);
143
158
  }
144
159
 
145
- async function lookupViaXurl(externalUserId: string) {
146
- const [user] = await lookupUsersByIds([externalUserId]);
147
- return user ?? null;
160
+ function lookupViaXurlEffect(externalUserId: string) {
161
+ return Effect.gen(function* () {
162
+ const [user] = yield* tryPromise(() => lookupUsersByIds([externalUserId]));
163
+ return user ?? null;
164
+ });
148
165
  }
149
166
 
150
167
  function normalizeHandle(value: string) {
151
168
  return value.trim().replace(/^@/, "").toLowerCase();
152
169
  }
153
170
 
154
- async function fetchProfileUser(
171
+ function fetchProfileUserEffect(
155
172
  externalUserId: string,
156
173
  xurlFallback: boolean,
157
- ): Promise<CachedProfileLookup> {
158
- try {
159
- const birdUser = await lookupProfileViaBird(externalUserId);
160
- if (birdUser) {
161
- return { status: "hit", source: "bird", user: birdUser };
162
- }
163
- } catch (error) {
164
- if (!xurlFallback) {
174
+ ): Effect.Effect<CachedProfileLookup, never> {
175
+ return Effect.gen(function* () {
176
+ const birdResult = yield* lookupProfileViaBirdEffect(externalUserId).pipe(
177
+ Effect.map((user) => ({ ok: true as const, user })),
178
+ Effect.catchAll((error) => Effect.succeed({ ok: false as const, error })),
179
+ );
180
+ if (birdResult.ok) {
181
+ const birdUser = birdResult.user;
182
+ if (birdUser) {
183
+ return { status: "hit", source: "bird", user: birdUser };
184
+ }
185
+ } else if (!xurlFallback) {
165
186
  return {
166
187
  status: "error",
167
188
  source: "bird",
168
- error: error instanceof Error ? error.message : String(error),
189
+ error:
190
+ birdResult.error instanceof Error
191
+ ? birdResult.error.message
192
+ : String(birdResult.error),
169
193
  };
170
194
  }
171
- }
172
195
 
173
- if (!xurlFallback) {
174
- return { status: "miss", source: "bird" };
175
- }
196
+ if (!xurlFallback) {
197
+ return { status: "miss", source: "bird" };
198
+ }
176
199
 
177
- try {
178
- const xurlUser = await lookupViaXurl(externalUserId);
179
- return xurlUser
180
- ? { status: "hit", source: "xurl", user: xurlUser }
181
- : { status: "miss", source: "xurl" };
182
- } catch (error) {
200
+ const xurlResult = yield* lookupViaXurlEffect(externalUserId).pipe(
201
+ Effect.map((user) => ({ ok: true as const, user })),
202
+ Effect.catchAll((error) => Effect.succeed({ ok: false as const, error })),
203
+ );
204
+ if (xurlResult.ok) {
205
+ const xurlUser = xurlResult.user;
206
+ return xurlUser
207
+ ? { status: "hit", source: "xurl", user: xurlUser }
208
+ : { status: "miss", source: "xurl" };
209
+ }
183
210
  return {
184
211
  status: "error",
185
212
  source: "xurl",
186
- error: error instanceof Error ? error.message : String(error),
213
+ error:
214
+ xurlResult.error instanceof Error
215
+ ? xurlResult.error.message
216
+ : String(xurlResult.error),
187
217
  };
188
- }
218
+ });
189
219
  }
190
220
 
191
- async function fetchProfileUsers(
221
+ function fetchProfileUsersEffect(
192
222
  externalUserIds: string[],
193
223
  xurlFallback: boolean,
194
224
  ) {
195
- const uniqueIds = Array.from(new Set(externalUserIds));
196
- const results = new Map<string, CachedProfileLookup>();
197
- let unresolved = uniqueIds;
198
-
199
- try {
200
- const birdResults = await lookupProfilesViaBird(uniqueIds);
201
- for (const result of birdResults) {
202
- const externalUserId = result.target;
203
- if (result.user) {
204
- results.set(externalUserId, {
205
- status: "hit",
206
- source: "bird",
207
- user: result.user,
208
- });
209
- } else if (result.error && !xurlFallback) {
210
- results.set(externalUserId, {
211
- status: "error",
212
- source: "bird",
213
- error: result.error,
214
- });
225
+ return Effect.gen(function* () {
226
+ const uniqueIds = Array.from(new Set(externalUserIds));
227
+ const results = new Map<string, CachedProfileLookup>();
228
+ let unresolved = uniqueIds;
229
+
230
+ const birdResult = yield* lookupProfilesViaBirdEffect(uniqueIds).pipe(
231
+ Effect.map((items) => ({ ok: true as const, items })),
232
+ Effect.catchAll((error) => Effect.succeed({ ok: false as const, error })),
233
+ );
234
+ if (birdResult.ok) {
235
+ const birdResults = birdResult.items;
236
+ for (const result of birdResults) {
237
+ const externalUserId = result.target;
238
+ if (result.user) {
239
+ results.set(externalUserId, {
240
+ status: "hit",
241
+ source: "bird",
242
+ user: result.user,
243
+ });
244
+ } else if (result.error && !xurlFallback) {
245
+ results.set(externalUserId, {
246
+ status: "error",
247
+ source: "bird",
248
+ error: result.error,
249
+ });
250
+ }
215
251
  }
216
- }
217
- unresolved = uniqueIds.filter((id) => !results.has(id));
218
- } catch (error) {
219
- if (!xurlFallback) {
252
+ unresolved = uniqueIds.filter((id) => !results.has(id));
253
+ } else if (!xurlFallback) {
220
254
  for (const externalUserId of uniqueIds) {
221
255
  results.set(externalUserId, {
222
256
  status: "error",
223
257
  source: "bird",
224
- error: error instanceof Error ? error.message : String(error),
258
+ error:
259
+ birdResult.error instanceof Error
260
+ ? birdResult.error.message
261
+ : String(birdResult.error),
225
262
  });
226
263
  }
227
264
  return results;
228
265
  }
229
- }
230
266
 
231
- if (unresolved.length === 0) {
232
- return results;
233
- }
234
- if (!xurlFallback) {
235
- for (const externalUserId of unresolved) {
236
- results.set(externalUserId, { status: "miss", source: "bird" });
267
+ if (unresolved.length === 0) {
268
+ return results;
269
+ }
270
+ if (!xurlFallback) {
271
+ for (const externalUserId of unresolved) {
272
+ results.set(externalUserId, { status: "miss", source: "bird" });
273
+ }
274
+ return results;
237
275
  }
238
- return results;
239
- }
240
276
 
241
- try {
242
- const xurlUsers = await lookupUsersByIds(unresolved);
243
- const usersById = new Map(xurlUsers.map((user) => [String(user.id), user]));
244
- for (const externalUserId of unresolved) {
245
- const user = usersById.get(externalUserId);
246
- results.set(
247
- externalUserId,
248
- user
249
- ? { status: "hit", source: "xurl", user }
250
- : { status: "miss", source: "xurl" },
277
+ const xurlResult = yield* tryPromise(() =>
278
+ lookupUsersByIds(unresolved),
279
+ ).pipe(
280
+ Effect.map((users) => ({ ok: true as const, users })),
281
+ Effect.catchAll((error) => Effect.succeed({ ok: false as const, error })),
282
+ );
283
+ if (xurlResult.ok) {
284
+ const xurlUsers = xurlResult.users;
285
+ const usersById = new Map(
286
+ xurlUsers.map((user) => [String(user.id), user]),
251
287
  );
288
+ for (const externalUserId of unresolved) {
289
+ const user = usersById.get(externalUserId);
290
+ results.set(
291
+ externalUserId,
292
+ user
293
+ ? { status: "hit", source: "xurl", user }
294
+ : { status: "miss", source: "xurl" },
295
+ );
296
+ }
297
+ } else {
298
+ for (const externalUserId of unresolved) {
299
+ results.set(externalUserId, {
300
+ status: "error",
301
+ source: "xurl",
302
+ error:
303
+ xurlResult.error instanceof Error
304
+ ? xurlResult.error.message
305
+ : String(xurlResult.error),
306
+ });
307
+ }
252
308
  }
253
- } catch (error) {
254
- for (const externalUserId of unresolved) {
255
- results.set(externalUserId, {
256
- status: "error",
257
- source: "xurl",
258
- error: error instanceof Error ? error.message : String(error),
259
- });
260
- }
261
- }
262
309
 
263
- return results;
310
+ return results;
311
+ });
264
312
  }
265
313
 
266
- export async function resolveProfilesForIds(
314
+ export function resolveProfilesForIdsEffect(
267
315
  profileIds: string[],
268
316
  options: ResolveProfilesOptions = {},
269
- ): Promise<ProfileResolveResult[]> {
270
- const maxAgeMs = options.maxAgeMs ?? PROFILE_CACHE_TTL_MS;
271
- const negativeMaxAgeMs =
272
- options.negativeMaxAgeMs ?? PROFILE_NEGATIVE_CACHE_TTL_MS;
273
- const xurlFallback = options.xurlFallback ?? true;
274
- const ordered: Array<
275
- | { kind: "ready"; result: ProfileResolveResult }
276
- | { kind: "pending"; profileId: string; externalUserId: string }
277
- > = [];
278
-
279
- for (const profileId of Array.from(new Set(profileIds))) {
280
- const externalUserId = getExternalUserId(profileId);
281
- if (!externalUserId) {
282
- ordered.push({
283
- kind: "ready",
284
- result: {
285
- profileId,
286
- externalUserId: null,
287
- status: "miss",
288
- source: "local",
289
- },
290
- });
291
- continue;
292
- }
293
-
294
- const localProfile = getProfile(profileId);
295
- if (
296
- localProfile &&
297
- !options.refresh &&
298
- !isPlaceholderProfile(localProfile)
299
- ) {
300
- ordered.push({
301
- kind: "ready",
302
- result: {
303
- profileId,
304
- externalUserId,
305
- status: "hit",
306
- source: "local",
307
- profile: localProfile,
308
- },
309
- });
310
- continue;
311
- }
317
+ ): Effect.Effect<ProfileResolveResult[], unknown> {
318
+ return Effect.gen(function* () {
319
+ const db = yield* trySync(() => getNativeDb());
320
+ const maxAgeMs = options.maxAgeMs ?? PROFILE_CACHE_TTL_MS;
321
+ const negativeMaxAgeMs =
322
+ options.negativeMaxAgeMs ?? PROFILE_NEGATIVE_CACHE_TTL_MS;
323
+ const xurlFallback = options.xurlFallback ?? true;
324
+ const ordered: Array<
325
+ | { kind: "ready"; result: ProfileResolveResult }
326
+ | { kind: "pending"; profileId: string; externalUserId: string }
327
+ > = [];
328
+
329
+ for (const profileId of Array.from(new Set(profileIds))) {
330
+ const externalUserId = getExternalUserId(profileId);
331
+ if (!externalUserId) {
332
+ ordered.push({
333
+ kind: "ready",
334
+ result: {
335
+ profileId,
336
+ externalUserId: null,
337
+ status: "miss",
338
+ source: "local",
339
+ },
340
+ });
341
+ continue;
342
+ }
312
343
 
313
- const cached = readSyncCache<CachedProfileLookup>(
314
- cacheKeyForUserId(externalUserId),
315
- );
316
- if (cached && !options.refresh) {
317
- const maxAge =
318
- cached.value.status === "hit" ? maxAgeMs : negativeMaxAgeMs;
319
- if (isFresh(cached.updatedAt, maxAge)) {
320
- if (cached.value.status === "hit" && cached.value.user) {
321
- const resolved = upsertProfileFromXUser(
322
- getNativeDb(),
323
- cached.value.user,
324
- );
325
- updateConversationTitles(resolved.profile);
326
- ordered.push({
327
- kind: "ready",
328
- result: {
329
- profileId: resolved.profile.id,
330
- externalUserId,
331
- status: "hit",
332
- source: "cache",
333
- profile: resolved.profile,
334
- },
335
- });
336
- continue;
337
- }
344
+ const localProfile = yield* trySync(() => getProfile(profileId));
345
+ if (
346
+ localProfile &&
347
+ !options.refresh &&
348
+ !isPlaceholderProfile(localProfile)
349
+ ) {
338
350
  ordered.push({
339
351
  kind: "ready",
340
352
  result: {
341
353
  profileId,
342
354
  externalUserId,
343
- status: cached.value.status,
344
- source: "negative-cache",
345
- error: cached.value.error,
355
+ status: "hit",
356
+ source: "local",
357
+ profile: localProfile,
346
358
  },
347
359
  });
348
360
  continue;
349
361
  }
350
- }
351
362
 
352
- ordered.push({ kind: "pending", profileId, externalUserId });
353
- }
363
+ const cached = yield* trySync(() =>
364
+ readSyncCache<CachedProfileLookup>(cacheKeyForUserId(externalUserId)),
365
+ );
366
+ if (cached && !options.refresh) {
367
+ const maxAge =
368
+ cached.value.status === "hit" ? maxAgeMs : negativeMaxAgeMs;
369
+ if (isFresh(cached.updatedAt, maxAge)) {
370
+ if (cached.value.status === "hit" && cached.value.user) {
371
+ const resolved = yield* trySync(() => {
372
+ const resolved = upsertProfileFromXUser(db, cached.value.user!);
373
+ updateConversationTitles(resolved.profile, db);
374
+ return resolved;
375
+ });
376
+ ordered.push({
377
+ kind: "ready",
378
+ result: {
379
+ profileId: resolved.profile.id,
380
+ externalUserId,
381
+ status: "hit",
382
+ source: "cache",
383
+ profile: resolved.profile,
384
+ },
385
+ });
386
+ continue;
387
+ }
388
+ ordered.push({
389
+ kind: "ready",
390
+ result: {
391
+ profileId,
392
+ externalUserId,
393
+ status: cached.value.status,
394
+ source: "negative-cache",
395
+ error: cached.value.error,
396
+ },
397
+ });
398
+ continue;
399
+ }
400
+ }
354
401
 
355
- const pendingExternalIds = ordered.flatMap((item) =>
356
- item.kind === "pending" ? [item.externalUserId] : [],
357
- );
358
- const fetchedByExternalId =
359
- pendingExternalIds.length > 1
360
- ? await fetchProfileUsers(pendingExternalIds, xurlFallback)
361
- : new Map<string, CachedProfileLookup>();
362
-
363
- const results: ProfileResolveResult[] = [];
364
- for (const item of ordered) {
365
- if (item.kind === "ready") {
366
- results.push(item.result);
367
- continue;
402
+ ordered.push({ kind: "pending", profileId, externalUserId });
368
403
  }
369
- const fetched =
370
- fetchedByExternalId.get(item.externalUserId) ??
371
- (await fetchProfileUser(item.externalUserId, xurlFallback));
372
- writeProfileLookupCache(item.externalUserId, fetched);
373
- if (fetched.status === "hit" && fetched.user) {
374
- const resolved = upsertProfileFromXUser(getNativeDb(), fetched.user);
375
- const affiliationHydration = await hydrateProfileAffiliationOrganizations(
376
- getNativeDb(),
377
- resolved.profile.id,
404
+
405
+ const pendingExternalIds = ordered.flatMap((item) =>
406
+ item.kind === "pending" ? [item.externalUserId] : [],
407
+ );
408
+ const fetchedByExternalId =
409
+ pendingExternalIds.length > 1
410
+ ? yield* fetchProfileUsersEffect(pendingExternalIds, xurlFallback)
411
+ : new Map<string, CachedProfileLookup>();
412
+
413
+ const results: ProfileResolveResult[] = [];
414
+ for (const item of ordered) {
415
+ if (item.kind === "ready") {
416
+ results.push(item.result);
417
+ continue;
418
+ }
419
+ const fetched =
420
+ fetchedByExternalId.get(item.externalUserId) ??
421
+ (yield* fetchProfileUserEffect(item.externalUserId, xurlFallback));
422
+ yield* trySync(() =>
423
+ writeProfileLookupCache(item.externalUserId, fetched),
378
424
  );
379
- updateConversationTitles(resolved.profile);
425
+ if (fetched.status === "hit" && fetched.user) {
426
+ const resolved = yield* trySync(() =>
427
+ upsertProfileFromXUser(db, fetched.user!),
428
+ );
429
+ const affiliationHydration =
430
+ yield* hydrateProfileAffiliationOrganizationsEffect(
431
+ db,
432
+ resolved.profile.id,
433
+ );
434
+ yield* trySync(() => updateConversationTitles(resolved.profile, db));
435
+ results.push({
436
+ profileId: resolved.profile.id,
437
+ externalUserId: item.externalUserId,
438
+ status: "hit",
439
+ source: fetched.source,
440
+ profile: resolved.profile,
441
+ ...(affiliationHydration.checked > 0 ? { affiliationHydration } : {}),
442
+ });
443
+ continue;
444
+ }
380
445
  results.push({
381
- profileId: resolved.profile.id,
446
+ profileId: item.profileId,
382
447
  externalUserId: item.externalUserId,
383
- status: "hit",
448
+ status: fetched.status,
384
449
  source: fetched.source,
385
- profile: resolved.profile,
386
- ...(affiliationHydration.checked > 0 ? { affiliationHydration } : {}),
450
+ error: fetched.error,
387
451
  });
388
- continue;
389
452
  }
390
- results.push({
391
- profileId: item.profileId,
392
- externalUserId: item.externalUserId,
393
- status: fetched.status,
394
- source: fetched.source,
395
- error: fetched.error,
396
- });
397
- }
398
453
 
399
- return results;
454
+ return results;
455
+ });
456
+ }
457
+
458
+ export function resolveProfilesForIds(
459
+ profileIds: string[],
460
+ options: ResolveProfilesOptions = {},
461
+ ): Promise<ProfileResolveResult[]> {
462
+ return runEffectPromise(resolveProfilesForIdsEffect(profileIds, options));
400
463
  }
401
464
 
402
- export async function resolveProfilesForHandles(
465
+ export function resolveProfilesForHandlesEffect(
403
466
  handles: string[],
404
467
  options: Pick<ResolveProfilesOptions, "xurlFallback"> = {},
405
- ): Promise<HandleProfileResolveResult[]> {
406
- const xurlFallback = options.xurlFallback ?? true;
407
- const targets = Array.from(
408
- new Set(handles.map(normalizeHandle).filter((handle) => handle.length > 0)),
409
- );
410
- if (targets.length === 0) {
411
- return [];
412
- }
468
+ ): Effect.Effect<HandleProfileResolveResult[], unknown> {
469
+ return Effect.gen(function* () {
470
+ const db = yield* trySync(() => getNativeDb());
471
+ const xurlFallback = options.xurlFallback ?? true;
472
+ const targets = Array.from(
473
+ new Set(
474
+ handles.map(normalizeHandle).filter((handle) => handle.length > 0),
475
+ ),
476
+ );
477
+ if (targets.length === 0) {
478
+ return [];
479
+ }
413
480
 
414
- const results = new Map<string, HandleProfileResolveResult>();
415
- let unresolved = targets;
481
+ const results = new Map<string, HandleProfileResolveResult>();
482
+ let unresolved = targets;
416
483
 
417
- try {
418
- const birdResults = await lookupProfilesViaBird(targets);
419
- for (const item of birdResults) {
420
- const handle = normalizeHandle(item.target);
421
- if (item.user) {
422
- const resolved = upsertProfileFromXUser(getNativeDb(), item.user);
423
- updateConversationTitles(resolved.profile);
424
- results.set(handle, {
425
- handle,
426
- status: "hit",
427
- source: "bird",
428
- profile: resolved.profile,
429
- });
430
- } else if (item.error && !xurlFallback) {
431
- results.set(handle, {
432
- handle,
433
- status: "error",
434
- source: "bird",
435
- error: item.error,
436
- });
484
+ const birdResult = yield* lookupProfilesViaBirdEffect(targets).pipe(
485
+ Effect.map((items) => ({ ok: true as const, items })),
486
+ Effect.catchAll((error) => Effect.succeed({ ok: false as const, error })),
487
+ );
488
+ if (birdResult.ok) {
489
+ const birdResults = birdResult.items;
490
+ for (const item of birdResults) {
491
+ const handle = normalizeHandle(item.target);
492
+ if (item.user) {
493
+ const resolved = yield* trySync(() => {
494
+ const resolved = upsertProfileFromXUser(db, item.user!);
495
+ updateConversationTitles(resolved.profile, db);
496
+ return resolved;
497
+ });
498
+ results.set(handle, {
499
+ handle,
500
+ status: "hit",
501
+ source: "bird",
502
+ profile: resolved.profile,
503
+ });
504
+ } else if (item.error && !xurlFallback) {
505
+ results.set(handle, {
506
+ handle,
507
+ status: "error",
508
+ source: "bird",
509
+ error: item.error,
510
+ });
511
+ }
437
512
  }
438
- }
439
- unresolved = targets.filter((handle) => !results.has(handle));
440
- } catch (error) {
441
- if (!xurlFallback) {
513
+ unresolved = targets.filter((handle) => !results.has(handle));
514
+ } else if (!xurlFallback) {
442
515
  for (const handle of targets) {
443
516
  results.set(handle, {
444
517
  handle,
445
518
  status: "error",
446
519
  source: "bird",
447
- error: error instanceof Error ? error.message : String(error),
520
+ error:
521
+ birdResult.error instanceof Error
522
+ ? birdResult.error.message
523
+ : String(birdResult.error),
448
524
  });
449
525
  }
450
526
  unresolved = [];
451
527
  }
452
- }
453
528
 
454
- if (unresolved.length > 0 && xurlFallback) {
455
- try {
456
- const users = await lookupUsersByHandles(unresolved);
457
- const usersByHandle = new Map(
458
- users.map((user) => [
459
- normalizeHandle(String(user.username ?? "")),
460
- user,
461
- ]),
529
+ if (unresolved.length > 0 && xurlFallback) {
530
+ const xurlResult = yield* tryPromise(() =>
531
+ lookupUsersByHandles(unresolved),
532
+ ).pipe(
533
+ Effect.map((users) => ({ ok: true as const, users })),
534
+ Effect.catchAll((error) =>
535
+ Effect.succeed({ ok: false as const, error }),
536
+ ),
462
537
  );
463
- for (const handle of unresolved) {
464
- const user = usersByHandle.get(handle);
465
- if (user) {
466
- const resolved = upsertProfileFromXUser(getNativeDb(), user);
467
- updateConversationTitles(resolved.profile);
468
- results.set(handle, {
469
- handle,
470
- status: "hit",
471
- source: "xurl",
472
- profile: resolved.profile,
473
- });
474
- } else {
538
+ if (xurlResult.ok) {
539
+ const users = xurlResult.users;
540
+ const usersByHandle = new Map(
541
+ users.map((user) => [
542
+ normalizeHandle(String(user.username ?? "")),
543
+ user,
544
+ ]),
545
+ );
546
+ for (const handle of unresolved) {
547
+ const user = usersByHandle.get(handle);
548
+ if (user) {
549
+ const resolved = yield* trySync(() => {
550
+ const resolved = upsertProfileFromXUser(db, user);
551
+ updateConversationTitles(resolved.profile, db);
552
+ return resolved;
553
+ });
554
+ results.set(handle, {
555
+ handle,
556
+ status: "hit",
557
+ source: "xurl",
558
+ profile: resolved.profile,
559
+ });
560
+ } else {
561
+ results.set(handle, {
562
+ handle,
563
+ status: "miss",
564
+ source: "xurl",
565
+ });
566
+ }
567
+ }
568
+ } else {
569
+ for (const handle of unresolved) {
475
570
  results.set(handle, {
476
571
  handle,
477
- status: "miss",
572
+ status: "error",
478
573
  source: "xurl",
574
+ error:
575
+ xurlResult.error instanceof Error
576
+ ? xurlResult.error.message
577
+ : String(xurlResult.error),
479
578
  });
480
579
  }
481
580
  }
482
- } catch (error) {
483
- for (const handle of unresolved) {
484
- results.set(handle, {
485
- handle,
486
- status: "error",
487
- source: "xurl",
488
- error: error instanceof Error ? error.message : String(error),
489
- });
490
- }
491
581
  }
492
- }
493
582
 
494
- return targets.map(
495
- (handle) =>
496
- results.get(handle) ?? {
497
- handle,
498
- status: "miss",
499
- source: "bird",
500
- },
501
- );
583
+ return targets.map(
584
+ (handle) =>
585
+ results.get(handle) ?? {
586
+ handle,
587
+ status: "miss",
588
+ source: "bird",
589
+ },
590
+ );
591
+ });
592
+ }
593
+
594
+ export function resolveProfilesForHandles(
595
+ handles: string[],
596
+ options: Pick<ResolveProfilesOptions, "xurlFallback"> = {},
597
+ ): Promise<HandleProfileResolveResult[]> {
598
+ return runEffectPromise(resolveProfilesForHandlesEffect(handles, options));
502
599
  }
503
600
 
504
- export async function resolvePlaceholderProfiles(
601
+ export function resolvePlaceholderProfilesEffect(
505
602
  options: ResolveProfilesOptions & { limit?: number } = {},
506
603
  ) {
507
- const db = getNativeDb();
508
- const rows = db
509
- .prepare(
510
- `
604
+ return Effect.gen(function* () {
605
+ const db = yield* trySync(() => getNativeDb());
606
+ const rows = yield* trySync(
607
+ () =>
608
+ db
609
+ .prepare(
610
+ `
511
611
  select id
512
612
  from profiles
513
613
  where id like 'profile_user_%'
@@ -520,20 +620,28 @@ export async function resolvePlaceholderProfiles(
520
620
  order by id asc
521
621
  limit ?
522
622
  `,
523
- )
524
- .all(options.limit ?? 500) as Array<{ id: string }>;
623
+ )
624
+ .all(options.limit ?? 500) as Array<{ id: string }>,
625
+ );
525
626
 
526
- const results = await resolveProfilesForIds(
527
- rows.map((row) => row.id),
528
- options,
529
- );
530
- return {
531
- ok: true,
532
- requestedProfiles: rows.length,
533
- hydratedProfiles: results.filter((result) => result.status === "hit")
534
- .length,
535
- results,
536
- };
627
+ const results = yield* resolveProfilesForIdsEffect(
628
+ rows.map((row) => row.id),
629
+ options,
630
+ );
631
+ return {
632
+ ok: true,
633
+ requestedProfiles: rows.length,
634
+ hydratedProfiles: results.filter((result) => result.status === "hit")
635
+ .length,
636
+ results,
637
+ };
638
+ });
639
+ }
640
+
641
+ export function resolvePlaceholderProfiles(
642
+ options: ResolveProfilesOptions & { limit?: number } = {},
643
+ ) {
644
+ return runEffectPromise(resolvePlaceholderProfilesEffect(options));
537
645
  }
538
646
 
539
647
  export const __test__ = {