birdclaw 0.5.0 → 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 (42) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +5 -0
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +7 -6
  5. package/public/birdclaw-mark.png +0 -0
  6. package/scripts/browser-perf.mjs +399 -0
  7. package/scripts/build-docs-site.mjs +940 -0
  8. package/scripts/docs-site-assets.mjs +311 -0
  9. package/scripts/run-vitest.mjs +21 -0
  10. package/scripts/sanitize-node-options.mjs +23 -0
  11. package/scripts/start-test-server.mjs +29 -0
  12. package/src/cli.ts +46 -1
  13. package/src/components/AppNav.tsx +3 -3
  14. package/src/components/BrandMark.tsx +67 -0
  15. package/src/components/ConversationThread.tsx +11 -10
  16. package/src/components/DmWorkspace.tsx +24 -9
  17. package/src/components/FeedState.tsx +147 -0
  18. package/src/components/InboxCard.tsx +14 -2
  19. package/src/components/SavedTimelineView.tsx +64 -56
  20. package/src/components/SyncNowButton.tsx +105 -0
  21. package/src/components/ThemeSlider.tsx +10 -59
  22. package/src/components/TimelineCard.tsx +157 -61
  23. package/src/components/TimelineRouteFrame.tsx +156 -0
  24. package/src/components/TweetMediaGrid.tsx +120 -23
  25. package/src/components/TweetRichText.tsx +13 -2
  26. package/src/components/useTimelineRouteData.ts +137 -0
  27. package/src/lib/api-client.ts +229 -0
  28. package/src/lib/archive-finder.ts +24 -20
  29. package/src/lib/archive-import.ts +18 -3
  30. package/src/lib/conversation-surface.ts +174 -0
  31. package/src/lib/db.ts +2 -0
  32. package/src/lib/queries.ts +93 -28
  33. package/src/lib/ui.ts +11 -3
  34. package/src/lib/web-sync.ts +443 -0
  35. package/src/routeTree.gen.ts +21 -0
  36. package/src/routes/api/sync.tsx +59 -0
  37. package/src/routes/dms.tsx +100 -27
  38. package/src/routes/index.tsx +21 -127
  39. package/src/routes/links.tsx +50 -5
  40. package/src/routes/mentions.tsx +21 -127
  41. package/src/styles.css +74 -11
  42. package/vite.config.ts +8 -0
@@ -321,32 +321,39 @@ function countTimelineEdges(db: Database, kind: "home" | "mention") {
321
321
  const row = db
322
322
  .prepare(
323
323
  `
324
- with timeline_edges as (
325
- select account_id, tweet_id, kind
326
- from tweet_account_edges
327
- where kind = ?
328
- union all
329
- select legacy.account_id, legacy.id as tweet_id, legacy.kind
330
- from tweets legacy
331
- where legacy.kind = ?
332
- and not exists (
333
- select 1
334
- from tweet_account_edges edge
335
- where edge.account_id = legacy.account_id
336
- and edge.tweet_id = legacy.id
337
- and edge.kind = legacy.kind
338
- )
339
- )
340
- select count(*) as count
341
- from timeline_edges e
342
- join tweets t on t.id = e.tweet_id
343
- where e.kind = ?
324
+ select (
325
+ (
326
+ select count(*)
327
+ from tweet_account_edges edge
328
+ where edge.kind = ?
329
+ and exists (
330
+ select 1
331
+ from tweets t
332
+ where t.id = edge.tweet_id
333
+ )
334
+ )
335
+ +
336
+ (
337
+ select count(*)
338
+ from tweets legacy
339
+ where legacy.kind = ?
340
+ and not exists (
341
+ select 1
342
+ from tweet_account_edges edge
343
+ where edge.account_id = legacy.account_id
344
+ and edge.tweet_id = legacy.id
345
+ and edge.kind = legacy.kind
346
+ )
347
+ )
348
+ ) as count
344
349
  `,
345
350
  )
346
- .get(kind, kind, kind) as { count: number | bigint } | undefined;
351
+ .get(kind, kind) as { count: number | bigint } | undefined;
347
352
  return Number(row?.count ?? 0);
348
353
  }
349
354
 
355
+ const RECENT_TIMELINE_EDGE_CANDIDATES = 5000;
356
+
350
357
  export async function getQueryEnvelope(): Promise<QueryEnvelope> {
351
358
  const db = getDb();
352
359
  const nativeDb = getNativeDb();
@@ -432,10 +439,23 @@ export function listTimelineItems({
432
439
  )
433
440
  )
434
441
  `;
442
+ const unwindowedTimelineEdgesCte = timelineEdgesCte;
443
+ let usedRecentEdgeWindow = false;
435
444
  let join = "";
436
445
  let where = "where t.kind = ?";
437
446
  let searchSnippetSelect = "";
438
447
 
448
+ const canUseRecentEdgeWindow =
449
+ !likedOnly &&
450
+ !bookmarkedOnly &&
451
+ !account &&
452
+ !search?.trim() &&
453
+ replyFilter === "all" &&
454
+ !since?.trim() &&
455
+ !until?.trim() &&
456
+ includeReplies &&
457
+ qualityFilter === "all";
458
+
439
459
  if (likedOnly || bookmarkedOnly) {
440
460
  if (likedOnly && bookmarkedOnly) {
441
461
  timelineEdgesCte = `
@@ -481,10 +501,49 @@ export function listTimelineItems({
481
501
  and collection.kind = ?
482
502
  )
483
503
  )
484
- `;
504
+ `;
485
505
  params.push(collectionKind, collectionKind);
486
506
  }
487
507
  where = "where 1 = 1";
508
+ } else if (canUseRecentEdgeWindow) {
509
+ usedRecentEdgeWindow = true;
510
+ timelineEdgesCte = `
511
+ with timeline_edges as (
512
+ select account_id, tweet_id, kind
513
+ from tweet_account_edges
514
+ where kind = ?
515
+ and tweet_id in (
516
+ select id
517
+ from tweets
518
+ order by created_at desc
519
+ limit ?
520
+ )
521
+ union all
522
+ select legacy.account_id, legacy.id as tweet_id, legacy.kind
523
+ from tweets legacy
524
+ where legacy.kind = ?
525
+ and legacy.id in (
526
+ select id
527
+ from tweets
528
+ order by created_at desc
529
+ limit ?
530
+ )
531
+ and not exists (
532
+ select 1
533
+ from tweet_account_edges edge
534
+ where edge.account_id = legacy.account_id
535
+ and edge.tweet_id = legacy.id
536
+ and edge.kind = legacy.kind
537
+ )
538
+ )
539
+ `;
540
+ const candidateLimit = Math.max(
541
+ RECENT_TIMELINE_EDGE_CANDIDATES,
542
+ limit * 50,
543
+ );
544
+ params.push(kind, candidateLimit, kind, candidateLimit);
545
+ where = "where e.kind = ?";
546
+ params.push(kind);
488
547
  } else {
489
548
  params.push(kind, kind);
490
549
  where = "where e.kind = ?";
@@ -532,10 +591,8 @@ export function listTimelineItems({
532
591
 
533
592
  params.push(limit);
534
593
 
535
- const rows = db
536
- .prepare(
537
- `
538
- ${timelineEdgesCte}
594
+ const buildTimelineSelectSql = (timelineEdgesSql: string) => `
595
+ ${timelineEdgesSql}
539
596
  select
540
597
  t.id,
541
598
  e.account_id,
@@ -626,10 +683,18 @@ export function listTimelineItems({
626
683
  ${where}
627
684
  order by t.created_at desc
628
685
  limit ?
629
- `,
630
- )
686
+ `;
687
+
688
+ let rows = db
689
+ .prepare(buildTimelineSelectSql(timelineEdgesCte))
631
690
  .all(...params) as Array<Record<string, unknown>>;
632
691
 
692
+ if (usedRecentEdgeWindow && rows.length < limit) {
693
+ rows = db
694
+ .prepare(buildTimelineSelectSql(unwindowedTimelineEdgesCte))
695
+ .all(kind, kind, kind, limit) as Array<Record<string, unknown>>;
696
+ }
697
+
633
698
  const urlExpansionCache: UrlExpansionCache = new Map();
634
699
  return rows.map((row) => {
635
700
  const author = {
package/src/lib/ui.ts CHANGED
@@ -15,8 +15,7 @@ export const sidebarShellClass =
15
15
  export const sidebarBrandClass =
16
16
  "flex items-center gap-2.5 px-2 py-2 text-[var(--ink)] min-[1100px]:px-3";
17
17
 
18
- export const sidebarBrandMarkClass =
19
- "grid size-9 place-items-center rounded-full bg-[var(--accent)] text-white";
18
+ export const sidebarBrandMarkClass = "grid size-10 place-items-center";
20
19
 
21
20
  export const sidebarBrandCopyClass =
22
21
  "hidden flex-col leading-tight min-[1100px]:flex";
@@ -121,6 +120,15 @@ export const feedRowBadgeAlertClass =
121
120
  export const feedRowBadgeNeutralClass =
122
121
  "bg-[var(--bg-active)] text-[var(--ink-soft)]";
123
122
 
123
+ export const feedRowStatePillClass =
124
+ "inline-flex shrink-0 items-center gap-1 rounded-full border border-[var(--line)] bg-[var(--bg-active)] px-2 py-0.5 text-[12px] font-semibold text-[var(--ink-soft)]";
125
+
126
+ export const feedRowStatePillActiveClass =
127
+ "border-[color:color-mix(in_srgb,var(--accent)_35%,var(--line))] bg-[var(--accent-soft)] text-[var(--accent)]";
128
+
129
+ export const feedRowStatePillOpenClass =
130
+ "border-[color:color-mix(in_srgb,var(--ink-soft)_34%,var(--line))] bg-transparent text-[var(--ink-soft)]";
131
+
124
132
  /* Forms / inputs. */
125
133
  export const searchFieldShellClass =
126
134
  "flex items-center gap-2 rounded-full border border-transparent bg-[var(--bg-active)] px-4 py-2 transition-colors focus-within:border-[var(--accent)] focus-within:bg-[var(--bg)] focus-within:shadow-[0_0_0_1px_var(--accent)]";
@@ -328,7 +336,7 @@ export function tweetMediaGridClass(count: number) {
328
336
 
329
337
  export function tweetMediaTileClass(index: number, count: number) {
330
338
  return cx(
331
- "tweet-media-tile relative block overflow-hidden bg-[var(--bg-active)]",
339
+ "tweet-media-tile relative block overflow-hidden border-0 bg-[var(--bg-active)] p-0 text-left",
332
340
  count === 1 && "aspect-[16/10]",
333
341
  count === 2 && "aspect-square",
334
342
  count === 3 && index === 0 && "row-span-2 aspect-[3/4]",
@@ -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
+ }