birdclaw 0.4.1 → 0.5.1

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 (85) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +113 -7
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +30 -28
  5. package/playwright.config.ts +1 -0
  6. package/public/birdclaw-mark.png +0 -0
  7. package/public/favicon.ico +0 -0
  8. package/public/logo192.png +0 -0
  9. package/public/logo512.png +0 -0
  10. package/public/manifest.json +2 -2
  11. package/scripts/browser-perf.mjs +399 -0
  12. package/scripts/build-docs-site.mjs +940 -0
  13. package/scripts/docs-site-assets.mjs +311 -0
  14. package/scripts/run-vitest.mjs +21 -0
  15. package/scripts/sanitize-node-options.mjs +23 -0
  16. package/scripts/start-test-server.mjs +29 -0
  17. package/src/cli.ts +496 -19
  18. package/src/components/AppNav.tsx +66 -29
  19. package/src/components/AvatarChip.tsx +10 -5
  20. package/src/components/BrandMark.tsx +67 -0
  21. package/src/components/ConversationThread.tsx +126 -0
  22. package/src/components/DmWorkspace.tsx +118 -105
  23. package/src/components/EmbeddedTweetCard.tsx +20 -14
  24. package/src/components/FeedState.tsx +147 -0
  25. package/src/components/InboxCard.tsx +104 -90
  26. package/src/components/LinkPreviewCard.tsx +270 -0
  27. package/src/components/ProfilePreview.tsx +8 -3
  28. package/src/components/SavedTimelineView.tsx +89 -71
  29. package/src/components/SyncNowButton.tsx +105 -0
  30. package/src/components/ThemeSlider.tsx +10 -59
  31. package/src/components/TimelineCard.tsx +326 -86
  32. package/src/components/TimelineRouteFrame.tsx +156 -0
  33. package/src/components/TweetMediaGrid.tsx +120 -23
  34. package/src/components/TweetRichText.tsx +19 -4
  35. package/src/components/useTimelineRouteData.ts +137 -0
  36. package/src/lib/api-client.ts +229 -0
  37. package/src/lib/archive-finder.ts +24 -20
  38. package/src/lib/archive-import.ts +1582 -67
  39. package/src/lib/authored-live.ts +1074 -0
  40. package/src/lib/backup.ts +316 -14
  41. package/src/lib/bird-actions.ts +1 -10
  42. package/src/lib/bird-command.ts +57 -0
  43. package/src/lib/bird.ts +89 -5
  44. package/src/lib/config.ts +1 -1
  45. package/src/lib/conversation-surface.ts +174 -0
  46. package/src/lib/db.ts +193 -4
  47. package/src/lib/follow-graph.ts +1053 -0
  48. package/src/lib/link-index.ts +11 -98
  49. package/src/lib/link-insights.ts +834 -0
  50. package/src/lib/link-preview-metadata.ts +334 -0
  51. package/src/lib/media-fetch.ts +787 -0
  52. package/src/lib/media-includes.ts +165 -0
  53. package/src/lib/mention-threads-live.ts +535 -43
  54. package/src/lib/mentions-live.ts +623 -19
  55. package/src/lib/moderation-target.ts +6 -0
  56. package/src/lib/profile-hydration.ts +1 -1
  57. package/src/lib/profile-resolver.ts +115 -1
  58. package/src/lib/queries.ts +326 -35
  59. package/src/lib/seed.ts +127 -8
  60. package/src/lib/timeline-collections-live.ts +145 -22
  61. package/src/lib/timeline-live.ts +10 -15
  62. package/src/lib/tweet-account-edges.ts +9 -2
  63. package/src/lib/tweet-render.ts +97 -0
  64. package/src/lib/types.ts +185 -2
  65. package/src/lib/ui.ts +383 -147
  66. package/src/lib/url-expansion-store.ts +110 -0
  67. package/src/lib/url-expansion.ts +20 -3
  68. package/src/lib/web-sync.ts +443 -0
  69. package/src/lib/x-profile.ts +7 -2
  70. package/src/lib/xurl.ts +296 -12
  71. package/src/routeTree.gen.ts +126 -0
  72. package/src/routes/__root.tsx +7 -3
  73. package/src/routes/api/conversation.tsx +34 -0
  74. package/src/routes/api/link-insights.tsx +79 -0
  75. package/src/routes/api/link-preview.tsx +43 -0
  76. package/src/routes/api/profile-hydrate.tsx +51 -0
  77. package/src/routes/api/sync.tsx +59 -0
  78. package/src/routes/blocks.tsx +111 -86
  79. package/src/routes/dms.tsx +172 -87
  80. package/src/routes/inbox.tsx +98 -86
  81. package/src/routes/index.tsx +22 -115
  82. package/src/routes/links.tsx +928 -0
  83. package/src/routes/mentions.tsx +22 -115
  84. package/src/styles.css +169 -43
  85. package/vite.config.ts +8 -0
@@ -0,0 +1,110 @@
1
+ import type { Database } from "./sqlite";
2
+ import type { LinkIndexItem } from "./types";
3
+
4
+ export interface UrlExpansionRecordInput {
5
+ url: string;
6
+ expandedUrl: string;
7
+ finalUrl: string;
8
+ status: "hit" | "miss" | "error";
9
+ title?: string | null;
10
+ description?: string | null;
11
+ imageUrl?: string | null;
12
+ siteName?: string | null;
13
+ error?: string;
14
+ source: string;
15
+ updatedAt: string;
16
+ }
17
+
18
+ function getTweetTarget(url: string) {
19
+ try {
20
+ const parsed = new URL(url);
21
+ const host = parsed.hostname.toLowerCase();
22
+ if (
23
+ host !== "x.com" &&
24
+ host !== "twitter.com" &&
25
+ host !== "mobile.twitter.com" &&
26
+ host !== "www.x.com" &&
27
+ host !== "www.twitter.com"
28
+ ) {
29
+ return {};
30
+ }
31
+
32
+ const parts = parsed.pathname.split("/").filter(Boolean);
33
+ const statusIndex = parts.findIndex(
34
+ (part) => part === "status" || part === "statuses",
35
+ );
36
+ if (statusIndex === -1 || !parts[statusIndex + 1]) {
37
+ return {};
38
+ }
39
+
40
+ const tweetId = parts[statusIndex + 1]?.match(/^\d+/)?.[0];
41
+ const handle =
42
+ parts[statusIndex - 1] && parts[statusIndex - 1] !== "i"
43
+ ? parts[statusIndex - 1]
44
+ : undefined;
45
+ return {
46
+ expandedTweetId: tweetId,
47
+ expandedHandle: handle,
48
+ };
49
+ } catch {
50
+ return {};
51
+ }
52
+ }
53
+
54
+ export function normalizeUrlExpansionForIndex(
55
+ item: UrlExpansionRecordInput,
56
+ ): LinkIndexItem {
57
+ const target = getTweetTarget(item.finalUrl);
58
+ return {
59
+ shortUrl: item.url,
60
+ expandedUrl: item.expandedUrl,
61
+ finalUrl: item.finalUrl,
62
+ status: item.status,
63
+ expandedTweetId: target.expandedTweetId ?? null,
64
+ expandedHandle: target.expandedHandle ?? null,
65
+ title: item.title ?? null,
66
+ description: item.description ?? null,
67
+ imageUrl: item.imageUrl ?? null,
68
+ siteName: item.siteName ?? null,
69
+ error: item.error ?? null,
70
+ source: item.source,
71
+ updatedAt: item.updatedAt,
72
+ };
73
+ }
74
+
75
+ export function upsertUrlExpansion(db: Database, item: LinkIndexItem) {
76
+ db.prepare(`
77
+ insert into url_expansions (
78
+ short_url, expanded_url, final_url, status, expanded_tweet_id,
79
+ expanded_handle, title, description, image_url, site_name, error, source,
80
+ updated_at
81
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
82
+ on conflict(short_url) do update set
83
+ expanded_url = excluded.expanded_url,
84
+ final_url = excluded.final_url,
85
+ status = excluded.status,
86
+ expanded_tweet_id = excluded.expanded_tweet_id,
87
+ expanded_handle = excluded.expanded_handle,
88
+ title = excluded.title,
89
+ description = excluded.description,
90
+ image_url = excluded.image_url,
91
+ site_name = excluded.site_name,
92
+ error = excluded.error,
93
+ source = excluded.source,
94
+ updated_at = excluded.updated_at
95
+ `).run(
96
+ item.shortUrl,
97
+ item.expandedUrl,
98
+ item.finalUrl,
99
+ item.status,
100
+ item.expandedTweetId,
101
+ item.expandedHandle,
102
+ item.title,
103
+ item.description,
104
+ item.imageUrl,
105
+ item.siteName,
106
+ item.error,
107
+ item.source,
108
+ item.updatedAt,
109
+ );
110
+ }
@@ -1,5 +1,10 @@
1
+ import { getNativeDb } from "./db";
1
2
  import { readSyncCache, writeSyncCache } from "./sync-cache";
2
3
  import type { UrlExpansionItem } from "./types";
4
+ import {
5
+ normalizeUrlExpansionForIndex,
6
+ upsertUrlExpansion,
7
+ } from "./url-expansion-store";
3
8
 
4
9
  const SUCCESS_CACHE_TTL_MS = 365 * 24 * 60 * 60 * 1000;
5
10
  const FAILURE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
@@ -66,6 +71,11 @@ function toExpansionItem(
66
71
  };
67
72
  }
68
73
 
74
+ function persistExpansion(item: UrlExpansionItem) {
75
+ const db = getNativeDb({ seedDemoData: false });
76
+ upsertUrlExpansion(db, normalizeUrlExpansionForIndex(item));
77
+ }
78
+
69
79
  async function fetchExpansion(
70
80
  url: string,
71
81
  fetchImpl: typeof fetch,
@@ -124,16 +134,23 @@ export async function expandUrls(
124
134
  ? (options.successMaxAgeMs ?? SUCCESS_CACHE_TTL_MS)
125
135
  : (options.failureMaxAgeMs ?? FAILURE_CACHE_TTL_MS);
126
136
  if (isFresh(cached.updatedAt, maxAge)) {
127
- results.push(
128
- toExpansionItem(url, cached.value, "cache", cached.updatedAt),
137
+ const item = toExpansionItem(
138
+ url,
139
+ cached.value,
140
+ "cache",
141
+ cached.updatedAt,
129
142
  );
143
+ persistExpansion(item);
144
+ results.push(item);
130
145
  continue;
131
146
  }
132
147
  }
133
148
 
134
149
  const value = await fetchExpansion(url, fetchImpl, timeoutMs);
135
150
  const updatedAt = writeSyncCache(cacheKeyForUrl(url), value);
136
- results.push(toExpansionItem(url, value, "network", updatedAt));
151
+ const item = toExpansionItem(url, value, "network", updatedAt);
152
+ persistExpansion(item);
153
+ results.push(item);
137
154
  }
138
155
 
139
156
  return results;
@@ -0,0 +1,443 @@
1
+ import { existsSync } from "node:fs";
2
+ import { maybeAutoSyncBackup } from "./backup";
3
+ import { getBirdclawPaths } from "./config";
4
+ import { syncDirectMessagesViaCachedBird } from "./dms-live";
5
+ import { syncMentionThreads } from "./mention-threads-live";
6
+ import { syncMentions } from "./mentions-live";
7
+ import NativeSqliteDatabase from "./sqlite";
8
+ import { syncTimelineCollection } from "./timeline-collections-live";
9
+ import { syncHomeTimeline } from "./timeline-live";
10
+
11
+ export type WebSyncKind =
12
+ | "timeline"
13
+ | "mentions"
14
+ | "likes"
15
+ | "bookmarks"
16
+ | "dms";
17
+
18
+ export interface WebSyncStep {
19
+ kind: WebSyncKind | "mention-threads";
20
+ label: string;
21
+ count: number;
22
+ source?: string;
23
+ partial?: boolean;
24
+ warnings?: string[];
25
+ }
26
+
27
+ export interface WebSyncResponse {
28
+ ok: boolean;
29
+ kind: WebSyncKind;
30
+ accountId?: string;
31
+ startedAt: string;
32
+ finishedAt?: string;
33
+ summary: string;
34
+ steps: WebSyncStep[];
35
+ inProgress?: boolean;
36
+ backup?: Awaited<ReturnType<typeof maybeAutoSyncBackup>>;
37
+ error?: string;
38
+ }
39
+
40
+ export type WebSyncJobStatus = "running" | "succeeded" | "failed";
41
+
42
+ export interface WebSyncJobSnapshot {
43
+ id: string;
44
+ kind: WebSyncKind;
45
+ accountId?: string;
46
+ status: WebSyncJobStatus;
47
+ startedAt: string;
48
+ finishedAt?: string;
49
+ summary: string;
50
+ inProgress: boolean;
51
+ result?: WebSyncResponse;
52
+ error?: string;
53
+ }
54
+
55
+ interface WebSyncPlan {
56
+ label: string;
57
+ accountAware: boolean;
58
+ run: (accountId: string | undefined) => Promise<WebSyncStep[]>;
59
+ }
60
+
61
+ const runningSyncs = new Map<string, WebSyncJobSnapshot>();
62
+ const webSyncJobs = new Map<string, WebSyncJobSnapshot>();
63
+ const webSyncJobKeys = new Map<string, string>();
64
+ const completedJobCleanupTimers = new Map<
65
+ string,
66
+ ReturnType<typeof setTimeout>
67
+ >();
68
+ const COMPLETED_JOB_TTL_MS = 5 * 60 * 1000;
69
+
70
+ function assertRecord(
71
+ value: unknown,
72
+ ): asserts value is Record<string, unknown> {
73
+ if (!value || typeof value !== "object") {
74
+ throw new Error("Expected sync result object");
75
+ }
76
+ }
77
+
78
+ function readNumber(value: unknown, key: string): number {
79
+ assertRecord(value);
80
+ const raw = value[key];
81
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
82
+ }
83
+
84
+ function readString(value: unknown, key: string) {
85
+ assertRecord(value);
86
+ const raw = value[key];
87
+ return typeof raw === "string" ? raw : undefined;
88
+ }
89
+
90
+ function readBoolean(value: unknown, key: string) {
91
+ assertRecord(value);
92
+ const raw = value[key];
93
+ return typeof raw === "boolean" ? raw : undefined;
94
+ }
95
+
96
+ export function parseWebSyncKind(value: unknown): WebSyncKind | null {
97
+ return value === "timeline" ||
98
+ value === "mentions" ||
99
+ value === "likes" ||
100
+ value === "bookmarks" ||
101
+ value === "dms"
102
+ ? value
103
+ : null;
104
+ }
105
+
106
+ function summarizeSteps(steps: WebSyncStep[]) {
107
+ const total = steps.reduce((sum, step) => sum + step.count, 0);
108
+ const partial = steps.some((step) => step.partial);
109
+ const suffix = partial ? " (partial)" : "";
110
+ return `Synced ${String(total)} items${suffix}`;
111
+ }
112
+
113
+ const WEB_SYNC_PLANS: Record<WebSyncKind, WebSyncPlan> = {
114
+ timeline: {
115
+ label: "Home timeline",
116
+ accountAware: false,
117
+ run: async (account) => {
118
+ const result = await syncHomeTimeline({
119
+ account,
120
+ limit: 100,
121
+ following: true,
122
+ refresh: true,
123
+ });
124
+ return [
125
+ {
126
+ kind: "timeline",
127
+ label: "Home timeline",
128
+ count: readNumber(result, "count"),
129
+ source: readString(result, "source"),
130
+ },
131
+ ];
132
+ },
133
+ },
134
+ mentions: {
135
+ label: "Mentions",
136
+ accountAware: true,
137
+ run: async (account) => {
138
+ const mentions = await syncMentions({
139
+ account,
140
+ mode: "xurl",
141
+ limit: 100,
142
+ maxPages: 3,
143
+ refresh: true,
144
+ });
145
+ const steps: WebSyncStep[] = [
146
+ {
147
+ kind: "mentions",
148
+ label: "Mentions",
149
+ count: readNumber(mentions, "count"),
150
+ source: readString(mentions, "source"),
151
+ partial: readBoolean(mentions, "partial"),
152
+ },
153
+ ];
154
+
155
+ const threads = await syncMentionThreads({
156
+ account,
157
+ mode: "xurl",
158
+ limit: 30,
159
+ delayMs: 1500,
160
+ timeoutMs: 15000,
161
+ });
162
+ steps.push({
163
+ kind: "mention-threads",
164
+ label: "Mention threads",
165
+ count: readNumber(threads, "mergedTweets"),
166
+ source: readString(threads, "source"),
167
+ partial: readBoolean(threads, "partial"),
168
+ warnings:
169
+ Array.isArray(threads.warnings) && threads.warnings.length > 0
170
+ ? threads.warnings.map(String)
171
+ : undefined,
172
+ });
173
+ return steps;
174
+ },
175
+ },
176
+ likes: {
177
+ label: "Likes",
178
+ accountAware: true,
179
+ run: (account) => syncSavedCollection("likes", account),
180
+ },
181
+ bookmarks: {
182
+ label: "Bookmarks",
183
+ accountAware: true,
184
+ run: (account) => syncSavedCollection("bookmarks", account),
185
+ },
186
+ dms: {
187
+ label: "Direct messages",
188
+ accountAware: false,
189
+ run: async (account) => {
190
+ const result = await syncDirectMessagesViaCachedBird({
191
+ account,
192
+ limit: 50,
193
+ refresh: true,
194
+ });
195
+ return [
196
+ {
197
+ kind: "dms",
198
+ label: "Direct messages",
199
+ count: readNumber(result, "messages"),
200
+ source: readString(result, "source"),
201
+ },
202
+ ];
203
+ },
204
+ },
205
+ };
206
+
207
+ async function syncSavedCollection(
208
+ kind: "likes" | "bookmarks",
209
+ account: string | undefined,
210
+ ) {
211
+ const isNonDefaultAccount =
212
+ account !== undefined && account !== resolveDefaultSyncAccountId();
213
+ const result = await syncTimelineCollection({
214
+ kind,
215
+ account,
216
+ mode: isNonDefaultAccount ? "xurl" : "auto",
217
+ limit: 100,
218
+ maxPages: 5,
219
+ refresh: true,
220
+ earlyStop: true,
221
+ });
222
+ return [
223
+ {
224
+ kind,
225
+ label: kind === "likes" ? "Likes" : "Bookmarks",
226
+ count: readNumber(result, "count"),
227
+ source: readString(result, "source"),
228
+ },
229
+ ];
230
+ }
231
+
232
+ async function performWebSync(
233
+ kind: WebSyncKind,
234
+ accountId?: string,
235
+ ): Promise<WebSyncResponse> {
236
+ const startedAt = new Date().toISOString();
237
+ const steps = await WEB_SYNC_PLANS[kind].run(accountId);
238
+
239
+ const backup = await maybeAutoSyncBackup();
240
+ const finishedAt = new Date().toISOString();
241
+ return {
242
+ ok: true,
243
+ kind,
244
+ ...(accountId ? { accountId } : {}),
245
+ startedAt,
246
+ finishedAt,
247
+ summary: summarizeSteps(steps),
248
+ steps,
249
+ backup,
250
+ };
251
+ }
252
+
253
+ function createWebSyncJobId(kind: WebSyncKind) {
254
+ return `sync_${kind}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
255
+ }
256
+
257
+ function resolveDefaultSyncAccountId() {
258
+ const dbPath = getBirdclawPaths().dbPath;
259
+ if (!existsSync(dbPath)) {
260
+ return "acct_primary";
261
+ }
262
+
263
+ let db: NativeSqliteDatabase | undefined;
264
+ try {
265
+ db = new NativeSqliteDatabase(dbPath, { readonly: true });
266
+ const row = db
267
+ .prepare(
268
+ `
269
+ select id
270
+ from accounts
271
+ order by is_default desc, created_at asc
272
+ limit 1
273
+ `,
274
+ )
275
+ .get() as { id: string } | undefined;
276
+ return row?.id ?? "acct_primary";
277
+ } catch {
278
+ return "acct_primary";
279
+ } finally {
280
+ db?.close();
281
+ }
282
+ }
283
+
284
+ function getRunningSyncKey(kind: WebSyncKind, accountId: string | undefined) {
285
+ if (!WEB_SYNC_PLANS[kind].accountAware) {
286
+ return kind;
287
+ }
288
+ return `${kind}:${accountId ?? resolveDefaultSyncAccountId()}`;
289
+ }
290
+
291
+ function getEffectiveAccountId(
292
+ kind: WebSyncKind,
293
+ accountId: string | undefined,
294
+ ) {
295
+ return WEB_SYNC_PLANS[kind].accountAware ? accountId : undefined;
296
+ }
297
+
298
+ function setJobSnapshot(snapshot: WebSyncJobSnapshot) {
299
+ webSyncJobs.set(snapshot.id, snapshot);
300
+ const syncKey =
301
+ webSyncJobKeys.get(snapshot.id) ??
302
+ getRunningSyncKey(snapshot.kind, snapshot.accountId);
303
+ const cleanupTimer = completedJobCleanupTimers.get(snapshot.id);
304
+ if (cleanupTimer) {
305
+ clearTimeout(cleanupTimer);
306
+ completedJobCleanupTimers.delete(snapshot.id);
307
+ }
308
+ if (snapshot.inProgress) {
309
+ runningSyncs.set(syncKey, snapshot);
310
+ } else if (runningSyncs.get(syncKey)?.id === snapshot.id) {
311
+ runningSyncs.delete(syncKey);
312
+ const timer = setTimeout(() => {
313
+ webSyncJobs.delete(snapshot.id);
314
+ webSyncJobKeys.delete(snapshot.id);
315
+ completedJobCleanupTimers.delete(snapshot.id);
316
+ }, COMPLETED_JOB_TTL_MS);
317
+ timer.unref?.();
318
+ completedJobCleanupTimers.set(snapshot.id, timer);
319
+ }
320
+ }
321
+
322
+ function toFailedResponse(
323
+ kind: WebSyncKind,
324
+ startedAt: string,
325
+ error: unknown,
326
+ accountId?: string,
327
+ ): WebSyncResponse {
328
+ const finishedAt = new Date().toISOString();
329
+ const message = error instanceof Error ? error.message : "Sync failed";
330
+ return {
331
+ ok: false,
332
+ kind,
333
+ ...(accountId ? { accountId } : {}),
334
+ startedAt,
335
+ finishedAt,
336
+ summary: message,
337
+ steps: [],
338
+ error: message,
339
+ };
340
+ }
341
+
342
+ export function startWebSync(
343
+ kind: WebSyncKind,
344
+ accountId?: string,
345
+ ): WebSyncJobSnapshot {
346
+ const effectiveAccountId = getEffectiveAccountId(kind, accountId);
347
+ const syncKey = getRunningSyncKey(kind, effectiveAccountId);
348
+ const current = runningSyncs.get(syncKey);
349
+ if (current) {
350
+ return current;
351
+ }
352
+
353
+ const startedAt = new Date().toISOString();
354
+ const job: WebSyncJobSnapshot = {
355
+ id: createWebSyncJobId(kind),
356
+ kind,
357
+ ...(effectiveAccountId ? { accountId: effectiveAccountId } : {}),
358
+ status: "running",
359
+ startedAt,
360
+ summary: `Syncing ${WEB_SYNC_PLANS[kind].label}`,
361
+ inProgress: true,
362
+ };
363
+ webSyncJobKeys.set(job.id, syncKey);
364
+ setJobSnapshot(job);
365
+
366
+ void performWebSync(kind, effectiveAccountId)
367
+ .then((result) => {
368
+ setJobSnapshot({
369
+ ...job,
370
+ status: "succeeded",
371
+ finishedAt: result.finishedAt,
372
+ summary: result.summary,
373
+ inProgress: false,
374
+ result,
375
+ });
376
+ })
377
+ .catch((error: unknown) => {
378
+ const result = toFailedResponse(
379
+ kind,
380
+ startedAt,
381
+ error,
382
+ effectiveAccountId,
383
+ );
384
+ setJobSnapshot({
385
+ ...job,
386
+ status: "failed",
387
+ finishedAt: result.finishedAt,
388
+ summary: result.summary,
389
+ inProgress: false,
390
+ result,
391
+ error: result.error,
392
+ });
393
+ });
394
+
395
+ return job;
396
+ }
397
+
398
+ export function getWebSyncJob(id: string): WebSyncJobSnapshot | null {
399
+ return webSyncJobs.get(id) ?? null;
400
+ }
401
+
402
+ export async function runWebSync(
403
+ kind: WebSyncKind,
404
+ accountId?: string,
405
+ ): Promise<WebSyncResponse> {
406
+ const effectiveAccountId = getEffectiveAccountId(kind, accountId);
407
+ const current = runningSyncs.get(getRunningSyncKey(kind, effectiveAccountId));
408
+ const startedAt = new Date().toISOString();
409
+ if (current) {
410
+ return {
411
+ ok: false,
412
+ kind,
413
+ ...(effectiveAccountId ? { accountId: effectiveAccountId } : {}),
414
+ startedAt,
415
+ summary: "Sync already running",
416
+ steps: [],
417
+ inProgress: true,
418
+ };
419
+ }
420
+
421
+ const job = startWebSync(kind, effectiveAccountId);
422
+ while (job.inProgress) {
423
+ await new Promise((resolve) => setTimeout(resolve, 25));
424
+ const latest = getWebSyncJob(job.id);
425
+ if (!latest?.inProgress) {
426
+ if (!latest?.result) throw new Error("Sync job disappeared");
427
+ return latest.result;
428
+ }
429
+ }
430
+
431
+ if (!job.result) throw new Error("Sync job did not finish");
432
+ return job.result;
433
+ }
434
+
435
+ export function clearWebSyncLocksForTests() {
436
+ runningSyncs.clear();
437
+ webSyncJobs.clear();
438
+ webSyncJobKeys.clear();
439
+ for (const timer of completedJobCleanupTimers.values()) {
440
+ clearTimeout(timer);
441
+ }
442
+ completedJobCleanupTimers.clear();
443
+ }
@@ -150,6 +150,7 @@ function updateExistingProfileFromUser(
150
150
  bio = ?,
151
151
  followers_count = ?,
152
152
  following_count = coalesce(?, following_count),
153
+ public_metrics_json = ?,
153
154
  avatar_url = coalesce(?, avatar_url),
154
155
  location = coalesce(?, location),
155
156
  url = coalesce(?, url),
@@ -167,6 +168,7 @@ function updateExistingProfileFromUser(
167
168
  bio,
168
169
  followersCount,
169
170
  followingCount,
171
+ JSON.stringify(user.public_metrics ?? {}),
170
172
  avatarUrl,
171
173
  metadata.location,
172
174
  metadata.url,
@@ -245,13 +247,15 @@ export function upsertProfileFromXUser(
245
247
  `
246
248
  insert into profiles (
247
249
  id, handle, display_name, bio, followers_count, following_count, avatar_hue,
248
- avatar_url, location, url, verified_type, entities_json, raw_json, created_at
249
- ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
250
+ public_metrics_json, avatar_url, location, url, verified_type, entities_json,
251
+ raw_json, created_at
252
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
250
253
  on conflict(id) do update set
251
254
  handle = excluded.handle,
252
255
  display_name = excluded.display_name,
253
256
  bio = excluded.bio,
254
257
  followers_count = excluded.followers_count,
258
+ public_metrics_json = excluded.public_metrics_json,
255
259
  following_count = case
256
260
  when ? then excluded.following_count
257
261
  else profiles.following_count
@@ -274,6 +278,7 @@ export function upsertProfileFromXUser(
274
278
  followersCount,
275
279
  followingCount ?? 0,
276
280
  avatarHue,
281
+ JSON.stringify(user.public_metrics ?? {}),
277
282
  avatarUrl,
278
283
  metadata.location,
279
284
  metadata.url,