birdclaw 0.5.0 → 0.6.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.
- package/CHANGELOG.md +63 -0
- package/README.md +55 -5
- package/bin/birdclaw.mjs +50 -11
- package/package.json +9 -7
- package/public/birdclaw-mark.png +0 -0
- package/scripts/browser-perf.mjs +400 -0
- package/scripts/build-docs-site.mjs +940 -0
- package/scripts/docs-site-assets.mjs +311 -0
- package/scripts/run-vitest.mjs +21 -0
- package/scripts/sanitize-node-options.mjs +23 -0
- package/scripts/start-test-server.mjs +42 -0
- package/src/cli.ts +422 -14
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +29 -9
- package/src/components/AvatarChip.tsx +9 -3
- package/src/components/BrandMark.tsx +67 -0
- package/src/components/ConversationThread.tsx +11 -10
- package/src/components/DmWorkspace.tsx +39 -14
- package/src/components/FeedState.tsx +147 -0
- package/src/components/InboxCard.tsx +14 -2
- package/src/components/LinkPreviewCard.tsx +40 -18
- package/src/components/MarkdownViewer.tsx +452 -0
- package/src/components/SavedTimelineView.tsx +64 -56
- package/src/components/SyncNowButton.tsx +137 -0
- package/src/components/ThemeSlider.tsx +49 -93
- package/src/components/TimelineCard.tsx +364 -136
- package/src/components/TimelineRouteFrame.tsx +170 -0
- package/src/components/TweetMediaGrid.tsx +170 -24
- package/src/components/TweetRichText.tsx +28 -13
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +153 -0
- package/src/lib/account-sync-job.ts +654 -0
- package/src/lib/actions-transport.ts +216 -146
- package/src/lib/api-client.ts +304 -0
- package/src/lib/archive-finder.ts +72 -53
- package/src/lib/archive-import.ts +1377 -1298
- package/src/lib/authored-live.ts +261 -204
- package/src/lib/avatar-cache.ts +159 -44
- package/src/lib/backup.ts +1532 -951
- package/src/lib/bird-actions.ts +139 -57
- package/src/lib/bird-command.ts +101 -28
- package/src/lib/bird.ts +549 -194
- package/src/lib/blocklist.ts +40 -23
- package/src/lib/blocks-write.ts +129 -80
- package/src/lib/blocks.ts +165 -97
- package/src/lib/bookmark-sync-job.ts +250 -160
- package/src/lib/conversation-surface.ts +205 -0
- package/src/lib/db.ts +35 -3
- package/src/lib/dms-live.ts +720 -66
- package/src/lib/effect-runtime.ts +45 -0
- package/src/lib/follow-graph.ts +224 -180
- package/src/lib/http-effect.ts +222 -0
- package/src/lib/inbox.ts +74 -43
- package/src/lib/link-index.ts +88 -76
- package/src/lib/link-insights.ts +24 -0
- package/src/lib/link-preview-metadata.ts +472 -52
- package/src/lib/media-fetch.ts +286 -213
- package/src/lib/mention-threads-live.ts +352 -288
- package/src/lib/mentions-live.ts +390 -342
- package/src/lib/moderation-target.ts +102 -65
- package/src/lib/moderation-write.ts +77 -18
- package/src/lib/mutes-write.ts +129 -80
- package/src/lib/mutes.ts +8 -1
- package/src/lib/openai.ts +84 -53
- package/src/lib/period-digest.ts +953 -0
- package/src/lib/profile-affiliation-hydration.ts +93 -54
- package/src/lib/profile-hydration.ts +124 -72
- package/src/lib/profile-replies.ts +60 -43
- package/src/lib/profile-resolver.ts +402 -294
- package/src/lib/queries.ts +1024 -189
- package/src/lib/research.ts +165 -120
- package/src/lib/sqlite.ts +1 -0
- package/src/lib/timeline-collections-live.ts +204 -167
- package/src/lib/timeline-live.ts +60 -39
- package/src/lib/tweet-lookup.ts +30 -19
- package/src/lib/types.ts +38 -1
- package/src/lib/ui.ts +41 -10
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +511 -0
- package/src/lib/whois.ts +166 -147
- package/src/lib/x-web.ts +102 -71
- package/src/lib/xurl.ts +681 -411
- package/src/routeTree.gen.ts +63 -0
- package/src/routes/__root.tsx +25 -5
- package/src/routes/api/action.tsx +127 -78
- package/src/routes/api/avatar.tsx +39 -30
- package/src/routes/api/blocks.tsx +26 -23
- package/src/routes/api/conversation.tsx +25 -14
- package/src/routes/api/inbox.tsx +27 -21
- package/src/routes/api/link-insights.tsx +31 -25
- package/src/routes/api/link-preview.tsx +25 -21
- package/src/routes/api/period-digest.tsx +123 -0
- package/src/routes/api/profile-hydrate.tsx +31 -28
- package/src/routes/api/query.tsx +79 -55
- package/src/routes/api/status.tsx +18 -10
- package/src/routes/api/sync.tsx +105 -0
- package/src/routes/dms.tsx +195 -55
- package/src/routes/inbox.tsx +32 -19
- package/src/routes/index.tsx +21 -127
- package/src/routes/links.tsx +50 -5
- package/src/routes/mentions.tsx +21 -127
- package/src/routes/today.tsx +441 -0
- package/src/styles.css +74 -11
- package/vite.config.ts +8 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { maybeAutoSyncBackupEffect } from "./backup";
|
|
4
|
+
import { getBirdclawPaths } from "./config";
|
|
5
|
+
import { syncDirectMessagesViaCachedBirdEffect } from "./dms-live";
|
|
6
|
+
import { runEffectBackground, runEffectPromise } from "./effect-runtime";
|
|
7
|
+
import { syncMentionThreadsEffect } from "./mention-threads-live";
|
|
8
|
+
import { syncMentionsEffect } from "./mentions-live";
|
|
9
|
+
import NativeSqliteDatabase from "./sqlite";
|
|
10
|
+
import { syncTimelineCollectionEffect } from "./timeline-collections-live";
|
|
11
|
+
import { syncHomeTimelineEffect } from "./timeline-live";
|
|
12
|
+
|
|
13
|
+
export type WebSyncKind =
|
|
14
|
+
| "timeline"
|
|
15
|
+
| "mentions"
|
|
16
|
+
| "likes"
|
|
17
|
+
| "bookmarks"
|
|
18
|
+
| "dms";
|
|
19
|
+
|
|
20
|
+
export interface WebSyncStep {
|
|
21
|
+
kind: WebSyncKind | "mention-threads";
|
|
22
|
+
label: string;
|
|
23
|
+
count: number;
|
|
24
|
+
source?: string;
|
|
25
|
+
partial?: boolean;
|
|
26
|
+
warnings?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface WebSyncResponse {
|
|
30
|
+
ok: boolean;
|
|
31
|
+
kind: WebSyncKind;
|
|
32
|
+
accountId?: string;
|
|
33
|
+
startedAt: string;
|
|
34
|
+
finishedAt?: string;
|
|
35
|
+
summary: string;
|
|
36
|
+
steps: WebSyncStep[];
|
|
37
|
+
inProgress?: boolean;
|
|
38
|
+
backup?: Effect.Effect.Success<ReturnType<typeof maybeAutoSyncBackupEffect>>;
|
|
39
|
+
error?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type WebSyncJobStatus = "running" | "succeeded" | "failed";
|
|
43
|
+
|
|
44
|
+
export interface WebSyncJobSnapshot {
|
|
45
|
+
id: string;
|
|
46
|
+
kind: WebSyncKind;
|
|
47
|
+
accountId?: string;
|
|
48
|
+
status: WebSyncJobStatus;
|
|
49
|
+
startedAt: string;
|
|
50
|
+
finishedAt?: string;
|
|
51
|
+
summary: string;
|
|
52
|
+
inProgress: boolean;
|
|
53
|
+
result?: WebSyncResponse;
|
|
54
|
+
error?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type WebSyncDmInbox = "all" | "accepted" | "requests";
|
|
58
|
+
|
|
59
|
+
export interface WebSyncOptions {
|
|
60
|
+
inbox?: WebSyncDmInbox;
|
|
61
|
+
limit?: number;
|
|
62
|
+
maxPages?: number;
|
|
63
|
+
allPages?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface WebSyncPlan {
|
|
67
|
+
label: string;
|
|
68
|
+
accountAware: boolean;
|
|
69
|
+
run: (
|
|
70
|
+
accountId: string | undefined,
|
|
71
|
+
options: WebSyncOptions,
|
|
72
|
+
) => Effect.Effect<WebSyncStep[], unknown>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const runningSyncs = new Map<string, WebSyncJobSnapshot>();
|
|
76
|
+
const webSyncJobs = new Map<string, WebSyncJobSnapshot>();
|
|
77
|
+
const webSyncJobKeys = new Map<string, string>();
|
|
78
|
+
const completedJobCleanupTimers = new Map<
|
|
79
|
+
string,
|
|
80
|
+
ReturnType<typeof setTimeout>
|
|
81
|
+
>();
|
|
82
|
+
const COMPLETED_JOB_TTL_MS = 5 * 60 * 1000;
|
|
83
|
+
|
|
84
|
+
function assertRecord(
|
|
85
|
+
value: unknown,
|
|
86
|
+
): asserts value is Record<string, unknown> {
|
|
87
|
+
if (!value || typeof value !== "object") {
|
|
88
|
+
throw new Error("Expected sync result object");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readNumber(value: unknown, key: string): number {
|
|
93
|
+
assertRecord(value);
|
|
94
|
+
const raw = value[key];
|
|
95
|
+
return typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readString(value: unknown, key: string) {
|
|
99
|
+
assertRecord(value);
|
|
100
|
+
const raw = value[key];
|
|
101
|
+
return typeof raw === "string" ? raw : undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function readBoolean(value: unknown, key: string) {
|
|
105
|
+
assertRecord(value);
|
|
106
|
+
const raw = value[key];
|
|
107
|
+
return typeof raw === "boolean" ? raw : undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function messageFromError(error: unknown) {
|
|
111
|
+
return error instanceof Error ? error.message : String(error);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function parseWebSyncKind(value: unknown): WebSyncKind | null {
|
|
115
|
+
return value === "timeline" ||
|
|
116
|
+
value === "mentions" ||
|
|
117
|
+
value === "likes" ||
|
|
118
|
+
value === "bookmarks" ||
|
|
119
|
+
value === "dms"
|
|
120
|
+
? value
|
|
121
|
+
: null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function summarizeSteps(steps: WebSyncStep[]) {
|
|
125
|
+
const total = steps.reduce((sum, step) => sum + step.count, 0);
|
|
126
|
+
const partial = steps.some((step) => step.partial);
|
|
127
|
+
const suffix = partial ? " (partial)" : "";
|
|
128
|
+
return `Synced ${String(total)} items${suffix}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const WEB_SYNC_PLANS: Record<WebSyncKind, WebSyncPlan> = {
|
|
132
|
+
timeline: {
|
|
133
|
+
label: "Home timeline",
|
|
134
|
+
accountAware: false,
|
|
135
|
+
run: (account) =>
|
|
136
|
+
Effect.gen(function* () {
|
|
137
|
+
const result = yield* syncHomeTimelineEffect({
|
|
138
|
+
account,
|
|
139
|
+
limit: 100,
|
|
140
|
+
following: true,
|
|
141
|
+
refresh: true,
|
|
142
|
+
});
|
|
143
|
+
return [
|
|
144
|
+
{
|
|
145
|
+
kind: "timeline",
|
|
146
|
+
label: "Home timeline",
|
|
147
|
+
count: readNumber(result, "count"),
|
|
148
|
+
source: readString(result, "source"),
|
|
149
|
+
},
|
|
150
|
+
];
|
|
151
|
+
}),
|
|
152
|
+
},
|
|
153
|
+
mentions: {
|
|
154
|
+
label: "Mentions",
|
|
155
|
+
accountAware: true,
|
|
156
|
+
run: (account) =>
|
|
157
|
+
Effect.gen(function* () {
|
|
158
|
+
const mentions = yield* syncMentionsEffect({
|
|
159
|
+
account,
|
|
160
|
+
mode: "xurl",
|
|
161
|
+
limit: 100,
|
|
162
|
+
maxPages: 3,
|
|
163
|
+
refresh: true,
|
|
164
|
+
});
|
|
165
|
+
const steps: WebSyncStep[] = [
|
|
166
|
+
{
|
|
167
|
+
kind: "mentions",
|
|
168
|
+
label: "Mentions",
|
|
169
|
+
count: readNumber(mentions, "count"),
|
|
170
|
+
source: readString(mentions, "source"),
|
|
171
|
+
partial: readBoolean(mentions, "partial"),
|
|
172
|
+
},
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
const threads = yield* syncMentionThreadsEffect({
|
|
176
|
+
account,
|
|
177
|
+
mode: "xurl",
|
|
178
|
+
limit: 30,
|
|
179
|
+
delayMs: 1500,
|
|
180
|
+
timeoutMs: 15000,
|
|
181
|
+
});
|
|
182
|
+
steps.push({
|
|
183
|
+
kind: "mention-threads",
|
|
184
|
+
label: "Mention threads",
|
|
185
|
+
count: readNumber(threads, "mergedTweets"),
|
|
186
|
+
source: readString(threads, "source"),
|
|
187
|
+
partial: readBoolean(threads, "partial"),
|
|
188
|
+
warnings:
|
|
189
|
+
Array.isArray(threads.warnings) && threads.warnings.length > 0
|
|
190
|
+
? threads.warnings.map(String)
|
|
191
|
+
: undefined,
|
|
192
|
+
});
|
|
193
|
+
return steps;
|
|
194
|
+
}),
|
|
195
|
+
},
|
|
196
|
+
likes: {
|
|
197
|
+
label: "Likes",
|
|
198
|
+
accountAware: true,
|
|
199
|
+
run: (account) => syncSavedCollection("likes", account),
|
|
200
|
+
},
|
|
201
|
+
bookmarks: {
|
|
202
|
+
label: "Bookmarks",
|
|
203
|
+
accountAware: true,
|
|
204
|
+
run: (account) => syncSavedCollection("bookmarks", account),
|
|
205
|
+
},
|
|
206
|
+
dms: {
|
|
207
|
+
label: "Direct messages",
|
|
208
|
+
accountAware: false,
|
|
209
|
+
run: (account, options) =>
|
|
210
|
+
Effect.gen(function* () {
|
|
211
|
+
const inbox = options.inbox ?? "all";
|
|
212
|
+
const result = yield* syncDirectMessagesViaCachedBirdEffect({
|
|
213
|
+
account,
|
|
214
|
+
inbox,
|
|
215
|
+
limit: options.limit ?? (inbox === "requests" ? 200 : 50),
|
|
216
|
+
...(options.maxPages !== undefined
|
|
217
|
+
? { maxPages: options.maxPages }
|
|
218
|
+
: {}),
|
|
219
|
+
...(options.allPages !== undefined
|
|
220
|
+
? { allPages: options.allPages }
|
|
221
|
+
: {}),
|
|
222
|
+
pageDelayMs: inbox === "requests" ? 750 : 0,
|
|
223
|
+
refresh: true,
|
|
224
|
+
});
|
|
225
|
+
return [
|
|
226
|
+
{
|
|
227
|
+
kind: "dms",
|
|
228
|
+
label: "Direct messages",
|
|
229
|
+
count: readNumber(result, "messages"),
|
|
230
|
+
source: readString(result, "source"),
|
|
231
|
+
},
|
|
232
|
+
];
|
|
233
|
+
}),
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
function syncSavedCollection(
|
|
238
|
+
kind: "likes" | "bookmarks",
|
|
239
|
+
account: string | undefined,
|
|
240
|
+
): Effect.Effect<WebSyncStep[], unknown> {
|
|
241
|
+
return Effect.gen(function* () {
|
|
242
|
+
const isNonDefaultAccount =
|
|
243
|
+
account !== undefined && account !== resolveDefaultSyncAccountId();
|
|
244
|
+
const result = yield* syncTimelineCollectionEffect({
|
|
245
|
+
kind,
|
|
246
|
+
account,
|
|
247
|
+
mode: isNonDefaultAccount ? "xurl" : "auto",
|
|
248
|
+
limit: 100,
|
|
249
|
+
maxPages: 5,
|
|
250
|
+
refresh: true,
|
|
251
|
+
earlyStop: true,
|
|
252
|
+
});
|
|
253
|
+
return [
|
|
254
|
+
{
|
|
255
|
+
kind,
|
|
256
|
+
label: kind === "likes" ? "Likes" : "Bookmarks",
|
|
257
|
+
count: readNumber(result, "count"),
|
|
258
|
+
source: readString(result, "source"),
|
|
259
|
+
},
|
|
260
|
+
];
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function performWebSyncEffect(
|
|
265
|
+
kind: WebSyncKind,
|
|
266
|
+
accountId?: string,
|
|
267
|
+
options: WebSyncOptions = {},
|
|
268
|
+
) {
|
|
269
|
+
return Effect.gen(function* () {
|
|
270
|
+
const startedAt = new Date().toISOString();
|
|
271
|
+
const steps = yield* WEB_SYNC_PLANS[kind].run(accountId, options);
|
|
272
|
+
|
|
273
|
+
const backup = yield* maybeAutoSyncBackupEffect();
|
|
274
|
+
const finishedAt = new Date().toISOString();
|
|
275
|
+
return {
|
|
276
|
+
ok: true,
|
|
277
|
+
kind,
|
|
278
|
+
...(accountId ? { accountId } : {}),
|
|
279
|
+
startedAt,
|
|
280
|
+
finishedAt,
|
|
281
|
+
summary: summarizeSteps(steps),
|
|
282
|
+
steps,
|
|
283
|
+
backup,
|
|
284
|
+
} satisfies WebSyncResponse;
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function createWebSyncJobId(kind: WebSyncKind) {
|
|
289
|
+
return `sync_${kind}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function resolveDefaultSyncAccountId() {
|
|
293
|
+
const dbPath = getBirdclawPaths().dbPath;
|
|
294
|
+
if (!existsSync(dbPath)) {
|
|
295
|
+
return "acct_primary";
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
let db: NativeSqliteDatabase | undefined;
|
|
299
|
+
try {
|
|
300
|
+
db = new NativeSqliteDatabase(dbPath, { readonly: true });
|
|
301
|
+
const row = db
|
|
302
|
+
.prepare(
|
|
303
|
+
`
|
|
304
|
+
select id
|
|
305
|
+
from accounts
|
|
306
|
+
order by is_default desc, created_at asc
|
|
307
|
+
limit 1
|
|
308
|
+
`,
|
|
309
|
+
)
|
|
310
|
+
.get() as { id: string } | undefined;
|
|
311
|
+
return row?.id ?? "acct_primary";
|
|
312
|
+
} catch {
|
|
313
|
+
return "acct_primary";
|
|
314
|
+
} finally {
|
|
315
|
+
db?.close();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function serializeSyncOptions(kind: WebSyncKind, options: WebSyncOptions) {
|
|
320
|
+
if (kind !== "dms") return "";
|
|
321
|
+
const parts = [
|
|
322
|
+
options.inbox ?? "all",
|
|
323
|
+
options.limit ?? "",
|
|
324
|
+
options.maxPages ?? "",
|
|
325
|
+
options.allPages === undefined ? "" : String(options.allPages),
|
|
326
|
+
];
|
|
327
|
+
return parts.join(":");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function getRunningSyncKey(
|
|
331
|
+
kind: WebSyncKind,
|
|
332
|
+
accountId: string | undefined,
|
|
333
|
+
options: WebSyncOptions = {},
|
|
334
|
+
) {
|
|
335
|
+
if (!WEB_SYNC_PLANS[kind].accountAware) {
|
|
336
|
+
const optionKey = serializeSyncOptions(kind, options);
|
|
337
|
+
return optionKey ? `${kind}:${optionKey}` : kind;
|
|
338
|
+
}
|
|
339
|
+
return `${kind}:${accountId ?? resolveDefaultSyncAccountId()}`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function getEffectiveAccountId(
|
|
343
|
+
kind: WebSyncKind,
|
|
344
|
+
accountId: string | undefined,
|
|
345
|
+
) {
|
|
346
|
+
return WEB_SYNC_PLANS[kind].accountAware ? accountId : undefined;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function setJobSnapshot(snapshot: WebSyncJobSnapshot) {
|
|
350
|
+
webSyncJobs.set(snapshot.id, snapshot);
|
|
351
|
+
const syncKey =
|
|
352
|
+
webSyncJobKeys.get(snapshot.id) ??
|
|
353
|
+
getRunningSyncKey(snapshot.kind, snapshot.accountId);
|
|
354
|
+
const cleanupTimer = completedJobCleanupTimers.get(snapshot.id);
|
|
355
|
+
if (cleanupTimer) {
|
|
356
|
+
clearTimeout(cleanupTimer);
|
|
357
|
+
completedJobCleanupTimers.delete(snapshot.id);
|
|
358
|
+
}
|
|
359
|
+
if (snapshot.inProgress) {
|
|
360
|
+
runningSyncs.set(syncKey, snapshot);
|
|
361
|
+
} else if (runningSyncs.get(syncKey)?.id === snapshot.id) {
|
|
362
|
+
runningSyncs.delete(syncKey);
|
|
363
|
+
const timer = setTimeout(() => {
|
|
364
|
+
webSyncJobs.delete(snapshot.id);
|
|
365
|
+
webSyncJobKeys.delete(snapshot.id);
|
|
366
|
+
completedJobCleanupTimers.delete(snapshot.id);
|
|
367
|
+
}, COMPLETED_JOB_TTL_MS);
|
|
368
|
+
timer.unref?.();
|
|
369
|
+
completedJobCleanupTimers.set(snapshot.id, timer);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function toFailedResponse(
|
|
374
|
+
kind: WebSyncKind,
|
|
375
|
+
startedAt: string,
|
|
376
|
+
error: unknown,
|
|
377
|
+
accountId?: string,
|
|
378
|
+
): WebSyncResponse {
|
|
379
|
+
const finishedAt = new Date().toISOString();
|
|
380
|
+
const message = messageFromError(error);
|
|
381
|
+
return {
|
|
382
|
+
ok: false,
|
|
383
|
+
kind,
|
|
384
|
+
...(accountId ? { accountId } : {}),
|
|
385
|
+
startedAt,
|
|
386
|
+
finishedAt,
|
|
387
|
+
summary: message,
|
|
388
|
+
steps: [],
|
|
389
|
+
error: message,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function startWebSync(
|
|
394
|
+
kind: WebSyncKind,
|
|
395
|
+
accountId?: string,
|
|
396
|
+
options: WebSyncOptions = {},
|
|
397
|
+
): WebSyncJobSnapshot {
|
|
398
|
+
const effectiveAccountId = getEffectiveAccountId(kind, accountId);
|
|
399
|
+
const syncKey = getRunningSyncKey(kind, effectiveAccountId, options);
|
|
400
|
+
const current = runningSyncs.get(syncKey);
|
|
401
|
+
if (current) {
|
|
402
|
+
return current;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const startedAt = new Date().toISOString();
|
|
406
|
+
const job: WebSyncJobSnapshot = {
|
|
407
|
+
id: createWebSyncJobId(kind),
|
|
408
|
+
kind,
|
|
409
|
+
...(effectiveAccountId ? { accountId: effectiveAccountId } : {}),
|
|
410
|
+
status: "running",
|
|
411
|
+
startedAt,
|
|
412
|
+
summary: `Syncing ${WEB_SYNC_PLANS[kind].label}`,
|
|
413
|
+
inProgress: true,
|
|
414
|
+
};
|
|
415
|
+
webSyncJobKeys.set(job.id, syncKey);
|
|
416
|
+
setJobSnapshot(job);
|
|
417
|
+
|
|
418
|
+
runEffectBackground(performWebSyncEffect(kind, effectiveAccountId, options), {
|
|
419
|
+
onSuccess: (result) => {
|
|
420
|
+
setJobSnapshot({
|
|
421
|
+
...job,
|
|
422
|
+
status: "succeeded",
|
|
423
|
+
finishedAt: result.finishedAt,
|
|
424
|
+
summary: result.summary,
|
|
425
|
+
inProgress: false,
|
|
426
|
+
result,
|
|
427
|
+
});
|
|
428
|
+
},
|
|
429
|
+
onFailure: (error) => {
|
|
430
|
+
const result = toFailedResponse(
|
|
431
|
+
kind,
|
|
432
|
+
startedAt,
|
|
433
|
+
error,
|
|
434
|
+
effectiveAccountId,
|
|
435
|
+
);
|
|
436
|
+
setJobSnapshot({
|
|
437
|
+
...job,
|
|
438
|
+
status: "failed",
|
|
439
|
+
finishedAt: result.finishedAt,
|
|
440
|
+
summary: result.summary,
|
|
441
|
+
inProgress: false,
|
|
442
|
+
result,
|
|
443
|
+
error: result.error,
|
|
444
|
+
});
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
return job;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function getWebSyncJob(id: string): WebSyncJobSnapshot | null {
|
|
452
|
+
return webSyncJobs.get(id) ?? null;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function runWebSyncEffect(
|
|
456
|
+
kind: WebSyncKind,
|
|
457
|
+
accountId?: string,
|
|
458
|
+
options: WebSyncOptions = {},
|
|
459
|
+
): Effect.Effect<WebSyncResponse, Error> {
|
|
460
|
+
return Effect.gen(function* () {
|
|
461
|
+
const effectiveAccountId = getEffectiveAccountId(kind, accountId);
|
|
462
|
+
const current = runningSyncs.get(
|
|
463
|
+
getRunningSyncKey(kind, effectiveAccountId, options),
|
|
464
|
+
);
|
|
465
|
+
const startedAt = new Date().toISOString();
|
|
466
|
+
if (current) {
|
|
467
|
+
return {
|
|
468
|
+
ok: false,
|
|
469
|
+
kind,
|
|
470
|
+
...(effectiveAccountId ? { accountId: effectiveAccountId } : {}),
|
|
471
|
+
startedAt,
|
|
472
|
+
summary: "Sync already running",
|
|
473
|
+
steps: [],
|
|
474
|
+
inProgress: true,
|
|
475
|
+
} satisfies WebSyncResponse;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const job = startWebSync(kind, effectiveAccountId, options);
|
|
479
|
+
while (job.inProgress) {
|
|
480
|
+
yield* Effect.sleep(25);
|
|
481
|
+
const latest = getWebSyncJob(job.id);
|
|
482
|
+
if (!latest?.inProgress) {
|
|
483
|
+
if (!latest?.result)
|
|
484
|
+
return yield* Effect.fail(new Error("Sync job disappeared"));
|
|
485
|
+
return latest.result;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (!job.result)
|
|
490
|
+
return yield* Effect.fail(new Error("Sync job did not finish"));
|
|
491
|
+
return job.result;
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export function runWebSync(
|
|
496
|
+
kind: WebSyncKind,
|
|
497
|
+
accountId?: string,
|
|
498
|
+
options: WebSyncOptions = {},
|
|
499
|
+
): Promise<WebSyncResponse> {
|
|
500
|
+
return runEffectPromise(runWebSyncEffect(kind, accountId, options));
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export function clearWebSyncLocksForTests() {
|
|
504
|
+
runningSyncs.clear();
|
|
505
|
+
webSyncJobs.clear();
|
|
506
|
+
webSyncJobKeys.clear();
|
|
507
|
+
for (const timer of completedJobCleanupTimers.values()) {
|
|
508
|
+
clearTimeout(timer);
|
|
509
|
+
}
|
|
510
|
+
completedJobCleanupTimers.clear();
|
|
511
|
+
}
|