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.
- package/CHANGELOG.md +44 -1
- package/README.md +50 -5
- package/package.json +3 -2
- package/scripts/browser-perf.mjs +1 -0
- package/scripts/start-test-server.mjs +16 -3
- package/src/cli.ts +376 -13
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +27 -7
- package/src/components/AvatarChip.tsx +9 -3
- package/src/components/DmWorkspace.tsx +18 -8
- package/src/components/LinkPreviewCard.tsx +40 -18
- package/src/components/MarkdownViewer.tsx +452 -0
- package/src/components/SyncNowButton.tsx +57 -25
- package/src/components/ThemeSlider.tsx +55 -50
- package/src/components/TimelineCard.tsx +225 -93
- package/src/components/TimelineRouteFrame.tsx +22 -8
- package/src/components/TweetMediaGrid.tsx +87 -38
- package/src/components/TweetRichText.tsx +15 -11
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +23 -7
- package/src/lib/account-sync-job.ts +654 -0
- package/src/lib/actions-transport.ts +216 -146
- package/src/lib/api-client.ts +128 -53
- package/src/lib/archive-finder.ts +78 -63
- package/src/lib/archive-import.ts +1364 -1300
- 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 +79 -48
- package/src/lib/db.ts +33 -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 +969 -199
- 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 +30 -7
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +216 -148
- 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 +42 -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 +75 -29
- package/src/routes/dms.tsx +95 -28
- package/src/routes/inbox.tsx +32 -19
- 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
|
-
|
|
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
|
-
|
|
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
|
-
):
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
135
|
+
function runXurlActionEffect(
|
|
68
136
|
action: ModerationAction,
|
|
69
137
|
query: string,
|
|
70
138
|
targetUserId?: string,
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
}
|
|
176
|
+
if (!result.ok) {
|
|
177
|
+
return {
|
|
178
|
+
...result,
|
|
179
|
+
transport: "xurl",
|
|
180
|
+
};
|
|
181
|
+
}
|
|
122
182
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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:
|
|
153
|
-
output:
|
|
154
|
-
transport: "
|
|
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
|
|
215
|
+
export function runModerationActionEffect({
|
|
170
216
|
action,
|
|
171
217
|
query,
|
|
172
218
|
targetUserId,
|
|
173
219
|
transport,
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
262
|
+
ok: false,
|
|
201
263
|
output: [
|
|
202
|
-
|
|
203
|
-
|
|
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:
|
|
289
|
+
transport: xurlResult.transport,
|
|
217
290
|
};
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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 };
|
package/src/lib/api-client.ts
CHANGED
|
@@ -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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
return
|
|
133
|
-
}
|
|
134
|
-
|
|
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
|
|
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
|
-
)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
190
|
-
|
|
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
|
-
).
|
|
264
|
+
).pipe(Effect.flatMap(waitForWebSyncJobEffect));
|
|
203
265
|
}
|
|
204
266
|
|
|
205
|
-
function
|
|
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
|
|
270
|
+
return fetchJsonEffect(
|
|
271
|
+
url,
|
|
272
|
+
undefined,
|
|
273
|
+
webSyncJobSchema,
|
|
274
|
+
"Sync status unavailable",
|
|
275
|
+
);
|
|
209
276
|
}
|
|
210
277
|
|
|
211
|
-
function
|
|
212
|
-
return
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
}
|