birdclaw 0.5.1 → 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.
Files changed (89) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +50 -5
  3. package/package.json +3 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +376 -13
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +27 -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 +452 -0
  13. package/src/components/SyncNowButton.tsx +57 -25
  14. package/src/components/ThemeSlider.tsx +55 -50
  15. package/src/components/TimelineCard.tsx +225 -93
  16. package/src/components/TimelineRouteFrame.tsx +22 -8
  17. package/src/components/TweetMediaGrid.tsx +87 -38
  18. package/src/components/TweetRichText.tsx +15 -11
  19. package/src/components/account-selection.ts +64 -0
  20. package/src/components/useTimelineRouteData.ts +23 -7
  21. package/src/lib/account-sync-job.ts +654 -0
  22. package/src/lib/actions-transport.ts +216 -146
  23. package/src/lib/api-client.ts +128 -53
  24. package/src/lib/archive-finder.ts +78 -63
  25. package/src/lib/archive-import.ts +1364 -1300
  26. package/src/lib/authored-live.ts +261 -204
  27. package/src/lib/avatar-cache.ts +159 -44
  28. package/src/lib/backup.ts +1532 -951
  29. package/src/lib/bird-actions.ts +139 -57
  30. package/src/lib/bird-command.ts +101 -28
  31. package/src/lib/bird.ts +549 -194
  32. package/src/lib/blocklist.ts +40 -23
  33. package/src/lib/blocks-write.ts +129 -80
  34. package/src/lib/blocks.ts +165 -97
  35. package/src/lib/bookmark-sync-job.ts +250 -160
  36. package/src/lib/conversation-surface.ts +79 -48
  37. package/src/lib/db.ts +33 -3
  38. package/src/lib/dms-live.ts +720 -66
  39. package/src/lib/effect-runtime.ts +45 -0
  40. package/src/lib/follow-graph.ts +224 -180
  41. package/src/lib/http-effect.ts +222 -0
  42. package/src/lib/inbox.ts +74 -43
  43. package/src/lib/link-index.ts +88 -76
  44. package/src/lib/link-insights.ts +24 -0
  45. package/src/lib/link-preview-metadata.ts +472 -52
  46. package/src/lib/media-fetch.ts +286 -213
  47. package/src/lib/mention-threads-live.ts +352 -288
  48. package/src/lib/mentions-live.ts +390 -342
  49. package/src/lib/moderation-target.ts +102 -65
  50. package/src/lib/moderation-write.ts +77 -18
  51. package/src/lib/mutes-write.ts +129 -80
  52. package/src/lib/mutes.ts +8 -1
  53. package/src/lib/openai.ts +84 -53
  54. package/src/lib/period-digest.ts +953 -0
  55. package/src/lib/profile-affiliation-hydration.ts +93 -54
  56. package/src/lib/profile-hydration.ts +124 -72
  57. package/src/lib/profile-replies.ts +60 -43
  58. package/src/lib/profile-resolver.ts +402 -294
  59. package/src/lib/queries.ts +969 -199
  60. package/src/lib/research.ts +165 -120
  61. package/src/lib/sqlite.ts +1 -0
  62. package/src/lib/timeline-collections-live.ts +204 -167
  63. package/src/lib/timeline-live.ts +60 -39
  64. package/src/lib/tweet-lookup.ts +30 -19
  65. package/src/lib/types.ts +38 -1
  66. package/src/lib/ui.ts +30 -7
  67. package/src/lib/url-expansion.ts +226 -55
  68. package/src/lib/url-safety.ts +220 -0
  69. package/src/lib/web-sync.ts +216 -148
  70. package/src/lib/whois.ts +166 -147
  71. package/src/lib/x-web.ts +102 -71
  72. package/src/lib/xurl.ts +681 -411
  73. package/src/routeTree.gen.ts +42 -0
  74. package/src/routes/__root.tsx +25 -5
  75. package/src/routes/api/action.tsx +127 -78
  76. package/src/routes/api/avatar.tsx +39 -30
  77. package/src/routes/api/blocks.tsx +26 -23
  78. package/src/routes/api/conversation.tsx +25 -14
  79. package/src/routes/api/inbox.tsx +27 -21
  80. package/src/routes/api/link-insights.tsx +31 -25
  81. package/src/routes/api/link-preview.tsx +25 -21
  82. package/src/routes/api/period-digest.tsx +123 -0
  83. package/src/routes/api/profile-hydrate.tsx +31 -28
  84. package/src/routes/api/query.tsx +79 -55
  85. package/src/routes/api/status.tsx +18 -10
  86. package/src/routes/api/sync.tsx +75 -29
  87. package/src/routes/dms.tsx +95 -28
  88. package/src/routes/inbox.tsx +32 -19
  89. package/src/routes/today.tsx +441 -0
@@ -6,15 +6,16 @@ import {
6
6
  unmuteUserViaBird,
7
7
  } from "./bird-actions";
8
8
  import { type ActionsTransport, resolveActionsTransport } from "./config";
9
+ import { Effect } from "effect";
10
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
9
11
  import type {
10
12
  ModerationAction,
11
13
  ModerationActionTransportResult,
12
14
  ModerationTransportKind,
13
15
  } from "./types";
14
- import { blockUserViaXWeb, unblockUserViaXWeb } from "./x-web";
15
16
  import {
16
17
  blockUserViaXurl,
17
- lookupAuthenticatedUser,
18
+ lookupAuthenticatedUserFresh,
18
19
  muteUserViaXurl,
19
20
  unblockUserViaXurl,
20
21
  unmuteUserViaXurl,
@@ -27,29 +28,96 @@ interface RunActionParams {
27
28
  query: string;
28
29
  targetUserId?: string;
29
30
  transport?: string;
31
+ expectedAccount?: ExpectedActionAccount;
32
+ }
33
+
34
+ export interface ExpectedActionAccount {
35
+ id: string;
36
+ handle: string;
37
+ externalUserId?: string | null;
38
+ }
39
+
40
+ function toError(error: unknown) {
41
+ return error instanceof Error ? error : new Error(String(error));
42
+ }
43
+
44
+ function trySync<T>(try_: () => T) {
45
+ return Effect.try({
46
+ try: try_,
47
+ catch: toError,
48
+ });
30
49
  }
31
50
 
32
51
  function normalizeFailure(transport: ModerationTransportKind, output: string) {
33
52
  return `${transport}: ${output}`;
34
53
  }
35
54
 
36
- async function runBirdAction(
55
+ function liveWritesDisabled() {
56
+ return process.env.BIRDCLAW_DISABLE_LIVE_WRITES === "1";
57
+ }
58
+
59
+ function normalizeHandle(value: string | null | undefined) {
60
+ return value?.replace(/^@/, "").toLowerCase() ?? "";
61
+ }
62
+
63
+ function verifyExpectedAccountEffect(
64
+ expectedAccount: ExpectedActionAccount | undefined,
65
+ ) {
66
+ return Effect.gen(function* () {
67
+ if (!expectedAccount) return null;
68
+ if (liveWritesDisabled()) return null;
69
+
70
+ const sourceUser = yield* tryPromise(() => lookupAuthenticatedUserFresh());
71
+ const sourceUserId =
72
+ sourceUser && typeof sourceUser.id === "string" ? sourceUser.id : "";
73
+ const sourceUsername =
74
+ sourceUser && typeof sourceUser.username === "string"
75
+ ? normalizeHandle(sourceUser.username)
76
+ : "";
77
+ const expectedExternalUserId = expectedAccount.externalUserId?.trim() ?? "";
78
+ const expectedHandle = normalizeHandle(expectedAccount.handle);
79
+
80
+ if (expectedExternalUserId) {
81
+ if (sourceUserId === expectedExternalUserId) return sourceUserId;
82
+ return yield* Effect.fail(
83
+ new Error(
84
+ `xurl is authenticated as user ${sourceUserId || "unknown"}, not account ${expectedAccount.id}`,
85
+ ),
86
+ );
87
+ }
88
+
89
+ if (expectedHandle && sourceUsername === expectedHandle)
90
+ return sourceUserId;
91
+ return yield* Effect.fail(
92
+ new Error(
93
+ sourceUsername
94
+ ? `xurl is authenticated as @${sourceUsername}, not @${expectedHandle}`
95
+ : "xurl authenticated user unavailable",
96
+ ),
97
+ );
98
+ });
99
+ }
100
+
101
+ function runBirdActionEffect(
37
102
  action: ModerationAction,
38
103
  query: string,
39
- ): Promise<ActionTransportResult> {
40
- const result =
41
- action === "block"
42
- ? await blockUserViaBird(query)
43
- : action === "unblock"
44
- ? await unblockUserViaBird(query)
45
- : action === "mute"
46
- ? await muteUserViaBird(query)
47
- : await unmuteUserViaBird(query);
48
-
49
- return {
50
- ...result,
51
- transport: "bird",
52
- };
104
+ ): Effect.Effect<ActionTransportResult, unknown> {
105
+ return Effect.gen(function* () {
106
+ const result = yield* tryPromise(() =>
107
+ action === "block"
108
+ ? blockUserViaBird(query)
109
+ : action === "unblock"
110
+ ? unblockUserViaBird(query)
111
+ : action === "mute"
112
+ ? muteUserViaBird(query)
113
+ : unmuteUserViaBird(query),
114
+ );
115
+
116
+ return {
117
+ ...result,
118
+ transport: "bird",
119
+ };
120
+ });
53
121
  }
54
122
 
55
123
  function getVerifyExpectation(action: ModerationAction) {
@@ -64,145 +132,151 @@ function getVerifyExpectation(action: ModerationAction) {
64
132
  };
65
133
  }
66
134
 
67
- async function runXurlAction(
135
+ function runXurlActionEffect(
68
136
  action: ModerationAction,
69
137
  query: string,
70
138
  targetUserId?: string,
71
- ): Promise<ActionTransportResult> {
72
- if (!targetUserId) {
73
- return {
74
- ok: false,
75
- output: "missing target user id for xurl transport",
76
- transport: "xurl",
77
- };
78
- }
139
+ verifiedSourceUserId?: string | null,
140
+ ): Effect.Effect<ActionTransportResult, unknown> {
141
+ return Effect.gen(function* () {
142
+ if (!targetUserId) {
143
+ return {
144
+ ok: false,
145
+ output: "missing target user id for xurl transport",
146
+ transport: "xurl",
147
+ };
148
+ }
79
149
 
80
- const sourceUser = await lookupAuthenticatedUser();
81
- const sourceUserId =
82
- sourceUser && typeof sourceUser.id === "string" ? sourceUser.id : "";
83
- if (!sourceUserId) {
84
- return {
85
- ok: false,
86
- output: "xurl authenticated user unavailable",
87
- transport: "xurl",
88
- };
89
- }
90
-
91
- const result =
92
- action === "block"
93
- ? await blockUserViaXurl(sourceUserId, targetUserId)
94
- : action === "unblock"
95
- ? await unblockUserViaXurl(sourceUserId, targetUserId)
96
- : action === "mute"
97
- ? await muteUserViaXurl(sourceUserId, targetUserId)
98
- : await unmuteUserViaXurl(sourceUserId, targetUserId);
99
-
100
- if (!result.ok) {
101
- return {
102
- ...result,
103
- transport: "xurl",
104
- };
105
- }
150
+ let sourceUserId = verifiedSourceUserId ?? "";
151
+ if (!sourceUserId) {
152
+ const sourceUser = yield* tryPromise(() =>
153
+ lookupAuthenticatedUserFresh(),
154
+ );
155
+ sourceUserId =
156
+ sourceUser && typeof sourceUser.id === "string" ? sourceUser.id : "";
157
+ }
158
+ if (!sourceUserId) {
159
+ return {
160
+ ok: false,
161
+ output: "xurl authenticated user unavailable",
162
+ transport: "xurl",
163
+ };
164
+ }
106
165
 
107
- const status = await readBirdStatusViaBird(query);
108
- const { field: verifyField, expected: expectedValue } =
109
- getVerifyExpectation(action);
110
- const actualValue =
111
- status && typeof status[verifyField] === "boolean"
112
- ? Boolean(status[verifyField])
113
- : null;
166
+ const result = yield* tryPromise(() =>
167
+ action === "block"
168
+ ? blockUserViaXurl(sourceUserId, targetUserId)
169
+ : action === "unblock"
170
+ ? unblockUserViaXurl(sourceUserId, targetUserId)
171
+ : action === "mute"
172
+ ? muteUserViaXurl(sourceUserId, targetUserId)
173
+ : unmuteUserViaXurl(sourceUserId, targetUserId),
174
+ );
114
175
 
115
- if (actualValue === null) {
116
- return {
117
- ok: false,
118
- output: `${result.output}\nxurl verify unavailable from bird status`,
119
- transport: "xurl",
120
- };
121
- }
176
+ if (!result.ok) {
177
+ return {
178
+ ...result,
179
+ transport: "xurl",
180
+ };
181
+ }
122
182
 
123
- if (actualValue !== expectedValue) {
124
- return {
125
- ok: false,
126
- output: `${result.output}\nxurl verify mismatch ${verifyField}=${String(actualValue)}`,
127
- transport: "xurl",
128
- };
129
- }
183
+ const status = yield* tryPromise(() => readBirdStatusViaBird(query));
184
+ const { field: verifyField, expected: expectedValue } =
185
+ getVerifyExpectation(action);
186
+ const actualValue =
187
+ status && typeof status[verifyField] === "boolean"
188
+ ? Boolean(status[verifyField])
189
+ : null;
130
190
 
131
- return {
132
- ok: true,
133
- output: `${result.output}\nverified ${verifyField}=${String(actualValue)}`,
134
- transport: "xurl",
135
- };
136
- }
191
+ if (actualValue === null) {
192
+ return {
193
+ ok: false,
194
+ output: `${result.output}\nxurl verify unavailable from bird status`,
195
+ transport: "xurl",
196
+ };
197
+ }
137
198
 
138
- async function runXWebAction(
139
- action: ModerationAction,
140
- targetUserId?: string,
141
- ): Promise<ActionTransportResult> {
142
- if (action !== "block" && action !== "unblock") {
143
- return {
144
- ok: false,
145
- output: `x-web does not support ${action}`,
146
- transport: "x-web",
147
- };
148
- }
199
+ if (actualValue !== expectedValue) {
200
+ return {
201
+ ok: false,
202
+ output: `${result.output}\nxurl verify mismatch ${verifyField}=${String(actualValue)}`,
203
+ transport: "xurl",
204
+ };
205
+ }
149
206
 
150
- if (!targetUserId) {
151
207
  return {
152
- ok: false,
153
- output: "missing target user id for x-web transport",
154
- transport: "x-web",
208
+ ok: true,
209
+ output: `${result.output}\nverified ${verifyField}=${String(actualValue)}`,
210
+ transport: "xurl",
155
211
  };
156
- }
157
-
158
- const result =
159
- action === "block"
160
- ? await blockUserViaXWeb(targetUserId)
161
- : await unblockUserViaXWeb(targetUserId);
162
-
163
- return {
164
- ...result,
165
- transport: "x-web",
166
- };
212
+ });
167
213
  }
168
214
 
169
- export async function runModerationAction({
215
+ export function runModerationActionEffect({
170
216
  action,
171
217
  query,
172
218
  targetUserId,
173
219
  transport,
174
- }: RunActionParams): Promise<ActionTransportResult> {
175
- const requestedTransport = resolveActionsTransport(transport);
176
- if (requestedTransport === "bird") {
177
- return runBirdAction(action, query);
178
- }
179
- if (requestedTransport === "xurl") {
180
- return runXurlAction(action, query, targetUserId);
181
- }
182
-
183
- const birdResult = await runBirdAction(action, query);
184
- if (birdResult.ok) {
185
- return birdResult;
186
- }
187
-
188
- const xurlResult = await runXurlAction(action, query, targetUserId);
189
- if (xurlResult.ok) {
190
- return {
191
- ...xurlResult,
192
- output: `${xurlResult.output}\nfalling back after ${normalizeFailure("bird", birdResult.output)}`,
193
- };
194
- }
220
+ expectedAccount,
221
+ }: RunActionParams): Effect.Effect<ActionTransportResult, unknown> {
222
+ return Effect.gen(function* () {
223
+ const requestedTransport = yield* trySync(() =>
224
+ resolveActionsTransport(transport),
225
+ );
226
+
227
+ const verifyXurlAccount = () =>
228
+ verifyExpectedAccountEffect(expectedAccount).pipe(
229
+ Effect.catchAll((error) =>
230
+ Effect.succeed({
231
+ ok: false as const,
232
+ output: error instanceof Error ? error.message : String(error),
233
+ transport: "xurl" as const,
234
+ }),
235
+ ),
236
+ );
195
237
 
196
- if (action === "block" || action === "unblock") {
197
- const xWebResult = await runXWebAction(action, targetUserId);
198
- if (xWebResult.ok) {
238
+ if (requestedTransport === "bird") {
239
+ return yield* runBirdActionEffect(action, query);
240
+ }
241
+ if (requestedTransport === "xurl") {
242
+ const accountCheck = yield* verifyXurlAccount();
243
+ if (accountCheck && typeof accountCheck === "object") {
244
+ return accountCheck;
245
+ }
246
+ return yield* runXurlActionEffect(
247
+ action,
248
+ query,
249
+ targetUserId,
250
+ typeof accountCheck === "string" ? accountCheck : null,
251
+ );
252
+ }
253
+
254
+ const birdResult = yield* runBirdActionEffect(action, query);
255
+ if (birdResult.ok) {
256
+ return birdResult;
257
+ }
258
+
259
+ const accountCheck = yield* verifyXurlAccount();
260
+ if (accountCheck && typeof accountCheck === "object") {
199
261
  return {
200
- ...xWebResult,
262
+ ok: false,
201
263
  output: [
202
- xWebResult.output,
203
- `falling back after ${normalizeFailure("bird", birdResult.output)}`,
204
- `falling back after ${normalizeFailure("xurl", xurlResult.output)}`,
264
+ normalizeFailure("bird", birdResult.output),
265
+ normalizeFailure("xurl", accountCheck.output),
205
266
  ].join("\n"),
267
+ transport: "xurl",
268
+ };
269
+ }
270
+ const xurlResult = yield* runXurlActionEffect(
271
+ action,
272
+ query,
273
+ targetUserId,
274
+ typeof accountCheck === "string" ? accountCheck : null,
275
+ );
276
+ if (xurlResult.ok) {
277
+ return {
278
+ ...xurlResult,
279
+ output: `${xurlResult.output}\nfalling back after ${normalizeFailure("bird", birdResult.output)}`,
206
280
  };
207
281
  }
208
282
 
@@ -211,20 +285,16 @@ export async function runModerationAction({
211
285
  output: [
212
286
  normalizeFailure("bird", birdResult.output),
213
287
  normalizeFailure("xurl", xurlResult.output),
214
- normalizeFailure("x-web", xWebResult.output),
215
288
  ].join("\n"),
216
- transport: xWebResult.transport,
289
+ transport: xurlResult.transport,
217
290
  };
218
- }
219
-
220
- return {
221
- ok: false,
222
- output: [
223
- normalizeFailure("bird", birdResult.output),
224
- normalizeFailure("xurl", xurlResult.output),
225
- ].join("\n"),
226
- transport: xurlResult.transport,
227
- };
291
+ });
292
+ }
293
+
294
+ export function runModerationAction(
295
+ params: RunActionParams,
296
+ ): Promise<ActionTransportResult> {
297
+ return runEffectPromise(runModerationActionEffect(params));
228
298
  }
229
299
 
230
300
  export type { ActionsTransport };
@@ -1,4 +1,6 @@
1
+ import { Data, Effect } from "effect";
1
2
  import { z } from "zod";
3
+ import { runEffectPromise } from "./effect-runtime";
2
4
  import type {
3
5
  DmConversationItem,
4
6
  DmMessageItem,
@@ -9,6 +11,7 @@ import type {
9
11
  import type {
10
12
  WebSyncJobSnapshot,
11
13
  WebSyncKind,
14
+ WebSyncOptions,
12
15
  WebSyncResponse,
13
16
  } from "./web-sync";
14
17
 
@@ -103,15 +106,11 @@ const webSyncJobSchema = z
103
106
  const actionResponseSchema = jsonRecordSchema;
104
107
  const SYNC_POLL_INTERVAL_MS = 500;
105
108
 
106
- export class ApiFetchError extends Error {
107
- constructor(
108
- message: string,
109
- readonly status?: number,
110
- ) {
111
- super(message);
112
- this.name = "ApiFetchError";
113
- }
114
- }
109
+ export class ApiFetchError extends Data.TaggedError("ApiFetchError")<{
110
+ readonly message: string;
111
+ readonly status?: number;
112
+ readonly cause?: unknown;
113
+ }> {}
115
114
 
116
115
  function responseMessage(data: unknown, fallback: string) {
117
116
  if (data && typeof data === "object") {
@@ -127,38 +126,77 @@ function responseMessage(data: unknown, fallback: string) {
127
126
  return fallback;
128
127
  }
129
128
 
130
- async function readJson(response: Response) {
131
- try {
132
- return await response.json();
133
- } catch {
134
- return null;
129
+ function apiFetchErrorFromCause(cause: unknown, fallbackMessage: string) {
130
+ if (cause instanceof DOMException && cause.name === "AbortError") {
131
+ return cause;
132
+ }
133
+ if (cause instanceof ApiFetchError) return cause;
134
+ if (cause instanceof Error) {
135
+ return new ApiFetchError({ message: cause.message, cause });
135
136
  }
137
+ if (typeof cause === "string") {
138
+ return new ApiFetchError({ message: cause, cause });
139
+ }
140
+ return new ApiFetchError({ message: fallbackMessage, cause });
141
+ }
142
+
143
+ function readJsonEffect(response: Response) {
144
+ return Effect.promise(() => response.json().catch(() => null as unknown));
145
+ }
146
+
147
+ function runApiEffect<T, E>(effect: Effect.Effect<T, E>) {
148
+ return runEffectPromise(effect);
136
149
  }
137
150
 
138
- export async function fetchJson<T>(
151
+ export function fetchJsonEffect<T>(
139
152
  input: RequestInfo | URL,
140
153
  init: RequestInit | undefined,
141
154
  schema: z.ZodType<T>,
142
155
  fallbackMessage: string,
143
- ): Promise<T> {
144
- const response = await fetch(input, init);
145
- const data = await readJson(response);
146
- if (!response.ok) {
147
- throw new ApiFetchError(
148
- responseMessage(data, fallbackMessage),
149
- response.status,
150
- );
151
- }
156
+ ) {
157
+ return Effect.gen(function* () {
158
+ const response = yield* Effect.tryPromise({
159
+ try: () => fetch(input, init),
160
+ catch: (cause) => apiFetchErrorFromCause(cause, fallbackMessage),
161
+ });
162
+ const data = yield* readJsonEffect(response);
163
+ if (!response.ok) {
164
+ return yield* Effect.fail(
165
+ new ApiFetchError({
166
+ message: responseMessage(data, fallbackMessage),
167
+ status: response.status,
168
+ }),
169
+ );
170
+ }
152
171
 
153
- const parsed = schema.safeParse(data);
154
- if (!parsed.success) {
155
- throw new ApiFetchError(fallbackMessage);
156
- }
157
- return parsed.data;
172
+ const parsed = schema.safeParse(data);
173
+ if (!parsed.success) {
174
+ return yield* Effect.fail(
175
+ new ApiFetchError({
176
+ message: fallbackMessage,
177
+ cause: parsed.error,
178
+ }),
179
+ );
180
+ }
181
+ return parsed.data;
182
+ });
183
+ }
184
+
185
+ export function fetchJson<T>(
186
+ input: RequestInfo | URL,
187
+ init: RequestInit | undefined,
188
+ schema: z.ZodType<T>,
189
+ fallbackMessage: string,
190
+ ): Promise<T> {
191
+ return runApiEffect(fetchJsonEffect(input, init, schema, fallbackMessage));
158
192
  }
159
193
 
160
194
  export function fetchQueryEnvelope(init?: RequestInit) {
161
- return fetchJson(
195
+ return runApiEffect(fetchQueryEnvelopeEffect(init));
196
+ }
197
+
198
+ export function fetchQueryEnvelopeEffect(init?: RequestInit) {
199
+ return fetchJsonEffect(
162
200
  "/api/status",
163
201
  init,
164
202
  queryEnvelopeSchema,
@@ -170,11 +208,22 @@ export function fetchQueryResponse(
170
208
  input: RequestInfo | URL,
171
209
  init?: RequestInit,
172
210
  ) {
173
- return fetchJson(input, init, queryResponseSchema, "Query unavailable");
211
+ return runApiEffect(fetchQueryResponseEffect(input, init));
212
+ }
213
+
214
+ export function fetchQueryResponseEffect(
215
+ input: RequestInfo | URL,
216
+ init?: RequestInit,
217
+ ) {
218
+ return fetchJsonEffect(input, init, queryResponseSchema, "Query unavailable");
174
219
  }
175
220
 
176
221
  export function postAction(body: Record<string, unknown>) {
177
- return fetchJson(
222
+ return runApiEffect(postActionEffect(body));
223
+ }
224
+
225
+ export function postActionEffect(body: Record<string, unknown>) {
226
+ return fetchJsonEffect(
178
227
  "/api/action",
179
228
  {
180
229
  method: "POST",
@@ -186,8 +235,20 @@ export function postAction(body: Record<string, unknown>) {
186
235
  );
187
236
  }
188
237
 
189
- export function postSync(kind: WebSyncKind, accountId?: string) {
190
- return fetchJson(
238
+ export function postSync(
239
+ kind: WebSyncKind,
240
+ accountId?: string,
241
+ options: WebSyncOptions = {},
242
+ ) {
243
+ return runApiEffect(postSyncEffect(kind, accountId, options));
244
+ }
245
+
246
+ export function postSyncEffect(
247
+ kind: WebSyncKind,
248
+ accountId?: string,
249
+ options: WebSyncOptions = {},
250
+ ) {
251
+ return fetchJsonEffect(
191
252
  "/api/sync",
192
253
  {
193
254
  method: "POST",
@@ -195,35 +256,49 @@ export function postSync(kind: WebSyncKind, accountId?: string) {
195
256
  body: JSON.stringify({
196
257
  kind,
197
258
  ...(accountId ? { accountId } : {}),
259
+ ...options,
198
260
  }),
199
261
  },
200
262
  webSyncJobSchema,
201
263
  "Sync failed",
202
- ).then(waitForWebSyncJob);
264
+ ).pipe(Effect.flatMap(waitForWebSyncJobEffect));
203
265
  }
204
266
 
205
- function fetchSyncJob(id: string) {
267
+ function fetchSyncJobEffect(id: string) {
206
268
  const url = new URL("/api/sync", window.location.origin);
207
269
  url.searchParams.set("id", id);
208
- return fetchJson(url, undefined, webSyncJobSchema, "Sync status unavailable");
270
+ return fetchJsonEffect(
271
+ url,
272
+ undefined,
273
+ webSyncJobSchema,
274
+ "Sync status unavailable",
275
+ );
209
276
  }
210
277
 
211
- function delay(ms: number) {
212
- return new Promise((resolve) => setTimeout(resolve, ms));
213
- }
278
+ export function waitForWebSyncJobEffect(job: WebSyncJobSnapshot) {
279
+ return Effect.gen(function* () {
280
+ let current = job;
281
+ while (current.inProgress) {
282
+ yield* Effect.sleep(SYNC_POLL_INTERVAL_MS);
283
+ current = yield* fetchSyncJobEffect(current.id);
284
+ }
214
285
 
215
- async function waitForWebSyncJob(job: WebSyncJobSnapshot) {
216
- let current = job;
217
- while (current.inProgress) {
218
- await delay(SYNC_POLL_INTERVAL_MS);
219
- current = await fetchSyncJob(current.id);
220
- }
286
+ if (!current.result) {
287
+ return yield* Effect.fail(
288
+ new ApiFetchError({ message: current.error ?? current.summary }),
289
+ );
290
+ }
291
+ if (!current.result.ok) {
292
+ return yield* Effect.fail(
293
+ new ApiFetchError({
294
+ message: current.result.error ?? current.result.summary,
295
+ }),
296
+ );
297
+ }
298
+ return current.result;
299
+ });
300
+ }
221
301
 
222
- if (!current.result) {
223
- throw new ApiFetchError(current.error ?? current.summary);
224
- }
225
- if (!current.result.ok) {
226
- throw new ApiFetchError(current.result.error ?? current.result.summary);
227
- }
228
- return current.result;
302
+ export function waitForWebSyncJob(job: WebSyncJobSnapshot) {
303
+ return runApiEffect(waitForWebSyncJobEffect(job));
229
304
  }