birdclaw 0.8.2 → 0.8.3
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 +16 -0
- package/package.json +2 -1
- package/src/cli/command-context.ts +17 -0
- package/src/cli/register-analysis.ts +500 -0
- package/src/cli/register-compose.ts +40 -0
- package/src/cli/register-graph.ts +132 -0
- package/src/cli/register-inbox.ts +41 -0
- package/src/cli/register-storage.ts +106 -0
- package/src/cli.ts +30 -750
- package/src/components/AccountSwitcher.tsx +7 -15
- package/src/components/AvatarChip.tsx +1 -1
- package/src/components/AvatarPreload.ts +149 -0
- package/src/components/MarkdownCitations.tsx +680 -0
- package/src/components/MarkdownViewer.tsx +8 -674
- package/src/components/ProfileAnalysisClient.ts +191 -0
- package/src/components/ProfileAnalysisStream.tsx +16 -185
- package/src/components/ProfilePreview.tsx +2 -0
- package/src/components/links-controller.ts +162 -0
- package/src/components/links-model.ts +198 -0
- package/src/components/network-map-controller.ts +84 -0
- package/src/components/network-map-model.ts +255 -0
- package/src/components/useTimelineRouteData.ts +105 -235
- package/src/lib/analysis-runtime.ts +238 -0
- package/src/lib/api-client.ts +16 -215
- package/src/lib/api-contracts.ts +328 -0
- package/src/lib/archive-import-plan.ts +102 -0
- package/src/lib/archive-import.ts +170 -239
- package/src/lib/authored-live.ts +75 -120
- package/src/lib/backup.ts +335 -424
- package/src/lib/blocks-write.ts +30 -26
- package/src/lib/blocks.ts +18 -20
- package/src/lib/database-metrics.ts +88 -0
- package/src/lib/database-migrations.ts +34 -0
- package/src/lib/database-schema.ts +312 -0
- package/src/lib/database-writer.ts +69 -0
- package/src/lib/db.ts +84 -330
- package/src/lib/dm-read-model.ts +533 -0
- package/src/lib/dms-live.ts +34 -97
- package/src/lib/follow-graph.ts +17 -27
- package/src/lib/import-repository.ts +138 -0
- package/src/lib/inbox.ts +2 -1
- package/src/lib/live-sync-engine.ts +209 -0
- package/src/lib/live-transport-gateway.ts +128 -0
- package/src/lib/mention-threads-live.ts +90 -177
- package/src/lib/mentions-export.ts +1 -1
- package/src/lib/mentions-live.ts +57 -181
- package/src/lib/moderation-target.ts +15 -4
- package/src/lib/moderation-write.ts +1 -1
- package/src/lib/mutes-write.ts +30 -26
- package/src/lib/openai-response-runtime.ts +251 -0
- package/src/lib/paginated-sync.ts +93 -0
- package/src/lib/period-digest.ts +116 -304
- package/src/lib/profile-analysis.ts +36 -110
- package/src/lib/queries.ts +6 -2381
- package/src/lib/query-actions.ts +437 -0
- package/src/lib/query-client.tsx +47 -0
- package/src/lib/query-read-model-shared.ts +52 -0
- package/src/lib/query-read-models.ts +5 -0
- package/src/lib/query-resource.ts +41 -0
- package/src/lib/query-status.ts +164 -0
- package/src/lib/research.ts +1 -1
- package/src/lib/runtime-services.ts +20 -0
- package/src/lib/search-discussion.ts +75 -279
- package/src/lib/server-runtime-services.ts +30 -0
- package/src/lib/sqlite.ts +48 -12
- package/src/lib/streaming-ingestion.ts +240 -0
- package/src/lib/sync-cache.ts +6 -1
- package/src/lib/sync-plan.ts +175 -0
- package/src/lib/timeline-collections-live.ts +83 -257
- package/src/lib/timeline-live.ts +86 -236
- package/src/lib/timeline-read-model.ts +1191 -0
- package/src/lib/tweet-repository.ts +156 -0
- package/src/lib/tweet-search-live.ts +63 -167
- package/src/lib/web-sync.ts +67 -50
- package/src/lib/whois.ts +2 -1
- package/src/routes/__root.tsx +11 -8
- package/src/routes/api/action.tsx +1 -1
- package/src/routes/api/conversation.tsx +1 -1
- package/src/routes/api/query.tsx +32 -26
- package/src/routes/api/status.tsx +6 -4
- package/src/routes/api/sync.tsx +5 -2
- package/src/routes/blocks.tsx +97 -131
- package/src/routes/data-sources.tsx +17 -25
- package/src/routes/dms.tsx +167 -184
- package/src/routes/inbox.tsx +63 -57
- package/src/routes/links.tsx +31 -394
- package/src/routes/network-map.tsx +41 -344
- package/src/routes/rate-limits.tsx +17 -21
- package/src/lib/client-cache.ts +0 -109
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import type { Database } from "./sqlite";
|
|
4
|
+
import { getReadDb } from "./db";
|
|
5
|
+
import { databaseWriteEffect } from "./database-writer";
|
|
6
|
+
import { getConversationThread } from "./dm-read-model";
|
|
7
|
+
import { runEffectPromise, tryPromise } from "./effect-runtime";
|
|
8
|
+
import {
|
|
9
|
+
dmViaXurlEffect,
|
|
10
|
+
lookupAuthenticatedUserFresh,
|
|
11
|
+
postViaXurlEffect,
|
|
12
|
+
replyViaXurlEffect,
|
|
13
|
+
} from "./xurl";
|
|
14
|
+
|
|
15
|
+
function toError(error: unknown) {
|
|
16
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function trySync<T>(try_: () => T) {
|
|
20
|
+
return Effect.try({ try: try_, catch: toError });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function e2eFakeLiveWritesEnabled() {
|
|
24
|
+
return (
|
|
25
|
+
process.env.BIRDCLAW_E2E === "1" &&
|
|
26
|
+
process.env.BIRDCLAW_E2E_FAKE_LIVE_WRITES === "1"
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function liveWritesDisabled() {
|
|
31
|
+
return process.env.BIRDCLAW_DISABLE_LIVE_WRITES === "1";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function verifySelectedXurlAccountEffect(accountId: string) {
|
|
35
|
+
return Effect.gen(function* () {
|
|
36
|
+
if (liveWritesDisabled()) return;
|
|
37
|
+
if (e2eFakeLiveWritesEnabled()) return;
|
|
38
|
+
const db = yield* trySync(() => getReadDb());
|
|
39
|
+
const account = yield* trySync(
|
|
40
|
+
() =>
|
|
41
|
+
db
|
|
42
|
+
.prepare("select handle, external_user_id from accounts where id = ?")
|
|
43
|
+
.get(accountId) as
|
|
44
|
+
| { handle: string; external_user_id: string | null }
|
|
45
|
+
| undefined,
|
|
46
|
+
);
|
|
47
|
+
if (!account) {
|
|
48
|
+
return yield* Effect.fail(new Error(`Unknown account: ${accountId}`));
|
|
49
|
+
}
|
|
50
|
+
const authenticated = yield* tryPromise(() =>
|
|
51
|
+
lookupAuthenticatedUserFresh(),
|
|
52
|
+
);
|
|
53
|
+
const authenticatedId =
|
|
54
|
+
typeof authenticated?.id === "string" ? authenticated.id : "";
|
|
55
|
+
const authenticatedHandle =
|
|
56
|
+
typeof authenticated?.username === "string"
|
|
57
|
+
? authenticated.username.replace(/^@/, "")
|
|
58
|
+
: "";
|
|
59
|
+
const expectedHandle = account.handle.replace(/^@/, "");
|
|
60
|
+
if (
|
|
61
|
+
(account.external_user_id &&
|
|
62
|
+
account.external_user_id !== authenticatedId) ||
|
|
63
|
+
(!account.external_user_id &&
|
|
64
|
+
(!authenticatedHandle ||
|
|
65
|
+
authenticatedHandle.toLowerCase() !== expectedHandle.toLowerCase()))
|
|
66
|
+
) {
|
|
67
|
+
return yield* Effect.fail(
|
|
68
|
+
new Error(
|
|
69
|
+
`xurl is authenticated as @${authenticatedHandle || authenticatedId}, not @${expectedHandle}`,
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function refreshDmConversationState(
|
|
77
|
+
db: Database,
|
|
78
|
+
conversationId: string,
|
|
79
|
+
lastMessageAt: string,
|
|
80
|
+
observedLastMessageAt = lastMessageAt,
|
|
81
|
+
) {
|
|
82
|
+
db.prepare(
|
|
83
|
+
`
|
|
84
|
+
update dm_conversations
|
|
85
|
+
set last_message_at = case
|
|
86
|
+
when last_message_at < ? then ?
|
|
87
|
+
else last_message_at
|
|
88
|
+
end,
|
|
89
|
+
unread_count = case
|
|
90
|
+
when last_message_at = ? then 0
|
|
91
|
+
when last_message_at <= ? then 0
|
|
92
|
+
else unread_count
|
|
93
|
+
end,
|
|
94
|
+
needs_reply = case
|
|
95
|
+
when last_message_at = ? then 0
|
|
96
|
+
when last_message_at <= ? then 0
|
|
97
|
+
else needs_reply
|
|
98
|
+
end
|
|
99
|
+
where id = ?
|
|
100
|
+
`,
|
|
101
|
+
).run(
|
|
102
|
+
lastMessageAt,
|
|
103
|
+
lastMessageAt,
|
|
104
|
+
observedLastMessageAt,
|
|
105
|
+
lastMessageAt,
|
|
106
|
+
observedLastMessageAt,
|
|
107
|
+
lastMessageAt,
|
|
108
|
+
conversationId,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getLocalAuthorProfileId(accountId: string) {
|
|
113
|
+
const db = getReadDb();
|
|
114
|
+
const row = db
|
|
115
|
+
.prepare(
|
|
116
|
+
`
|
|
117
|
+
select p.id
|
|
118
|
+
from accounts a
|
|
119
|
+
join profiles p on p.handle = replace(a.handle, '@', '')
|
|
120
|
+
where a.id = ?
|
|
121
|
+
`,
|
|
122
|
+
)
|
|
123
|
+
.get(accountId) as { id: string } | undefined;
|
|
124
|
+
|
|
125
|
+
return row?.id;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let savepointCounter = 0;
|
|
129
|
+
|
|
130
|
+
function preflightWrite<T>(db: Database, write: (db: Database) => T) {
|
|
131
|
+
const savepoint = `__birdclaw_preflight_${++savepointCounter}`;
|
|
132
|
+
db.exec(`savepoint ${savepoint}`);
|
|
133
|
+
try {
|
|
134
|
+
const result = write(db);
|
|
135
|
+
db.exec(`rollback to ${savepoint}`);
|
|
136
|
+
db.exec(`release ${savepoint}`);
|
|
137
|
+
return result;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
try {
|
|
140
|
+
db.exec(`rollback to ${savepoint}`);
|
|
141
|
+
db.exec(`release ${savepoint}`);
|
|
142
|
+
} catch {
|
|
143
|
+
// Preserve the original staging error; cleanup is best effort here.
|
|
144
|
+
}
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
type PostDraft = {
|
|
150
|
+
actionId: string;
|
|
151
|
+
authorProfileId: string;
|
|
152
|
+
createdAt: string;
|
|
153
|
+
tweetId: string;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
function preparePostDraft(accountId: string): PostDraft {
|
|
157
|
+
const authorProfileId = getLocalAuthorProfileId(accountId);
|
|
158
|
+
if (!authorProfileId) {
|
|
159
|
+
throw new Error("No local author profile for account");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
actionId: randomUUID(),
|
|
164
|
+
authorProfileId,
|
|
165
|
+
createdAt: new Date().toISOString(),
|
|
166
|
+
tweetId: `tweet_${randomUUID()}`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function writePostDraft(
|
|
171
|
+
db: Database,
|
|
172
|
+
accountId: string,
|
|
173
|
+
text: string,
|
|
174
|
+
draft: PostDraft,
|
|
175
|
+
) {
|
|
176
|
+
db.prepare(
|
|
177
|
+
`
|
|
178
|
+
insert into tweets (
|
|
179
|
+
id, account_id, author_profile_id, kind, text, created_at,
|
|
180
|
+
is_replied, reply_to_id, like_count, media_count, bookmarked, liked
|
|
181
|
+
) values (?, ?, ?, 'home', ?, ?, 0, null, 0, 0, 0, 0)
|
|
182
|
+
`,
|
|
183
|
+
).run(draft.tweetId, accountId, draft.authorProfileId, text, draft.createdAt);
|
|
184
|
+
|
|
185
|
+
db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
|
|
186
|
+
draft.tweetId,
|
|
187
|
+
text,
|
|
188
|
+
);
|
|
189
|
+
db.prepare(
|
|
190
|
+
"insert into tweet_actions (id, account_id, tweet_id, kind, body, created_at) values (?, ?, ?, ?, ?, ?)",
|
|
191
|
+
).run(
|
|
192
|
+
draft.actionId,
|
|
193
|
+
accountId,
|
|
194
|
+
draft.tweetId,
|
|
195
|
+
"post",
|
|
196
|
+
text,
|
|
197
|
+
draft.createdAt,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function createPostEffect(accountId: string, text: string) {
|
|
202
|
+
return Effect.gen(function* () {
|
|
203
|
+
const draft = yield* trySync(() => preparePostDraft(accountId));
|
|
204
|
+
yield* databaseWriteEffect((db) =>
|
|
205
|
+
preflightWrite(db, (writeDb) =>
|
|
206
|
+
writePostDraft(writeDb, accountId, text, draft),
|
|
207
|
+
),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
yield* verifySelectedXurlAccountEffect(accountId);
|
|
211
|
+
const transport = yield* postViaXurlEffect(text);
|
|
212
|
+
if (!transport.ok) {
|
|
213
|
+
return yield* Effect.fail(new Error(transport.output || "post failed"));
|
|
214
|
+
}
|
|
215
|
+
yield* databaseWriteEffect((db) =>
|
|
216
|
+
writePostDraft(db, accountId, text, draft),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
return { ok: true, transport, tweetId: draft.tweetId };
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function createPost(accountId: string, text: string) {
|
|
224
|
+
return runEffectPromise(createPostEffect(accountId, text));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function createTweetReplyEffect(
|
|
228
|
+
accountId: string,
|
|
229
|
+
tweetId: string,
|
|
230
|
+
text: string,
|
|
231
|
+
) {
|
|
232
|
+
type ReplyDraft = PostDraft & { replyId: string };
|
|
233
|
+
|
|
234
|
+
function prepareReplyDraft(): ReplyDraft {
|
|
235
|
+
const postDraft = preparePostDraft(accountId);
|
|
236
|
+
return {
|
|
237
|
+
...postDraft,
|
|
238
|
+
replyId: postDraft.tweetId,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function writeReplyDraft(db: Database, draft: ReplyDraft) {
|
|
243
|
+
db.prepare("update tweets set is_replied = 1 where id = ?").run(tweetId);
|
|
244
|
+
|
|
245
|
+
db.prepare(
|
|
246
|
+
`
|
|
247
|
+
insert into tweets (
|
|
248
|
+
id, account_id, author_profile_id, kind, text, created_at,
|
|
249
|
+
is_replied, reply_to_id, like_count, media_count, bookmarked, liked
|
|
250
|
+
) values (?, ?, ?, 'home', ?, ?, 1, ?, 0, 0, 0, 0)
|
|
251
|
+
`,
|
|
252
|
+
).run(
|
|
253
|
+
draft.replyId,
|
|
254
|
+
accountId,
|
|
255
|
+
draft.authorProfileId,
|
|
256
|
+
text,
|
|
257
|
+
draft.createdAt,
|
|
258
|
+
tweetId,
|
|
259
|
+
);
|
|
260
|
+
db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
|
|
261
|
+
draft.replyId,
|
|
262
|
+
text,
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
db.prepare(
|
|
266
|
+
"insert into tweet_actions (id, account_id, tweet_id, kind, body, created_at) values (?, ?, ?, ?, ?, ?)",
|
|
267
|
+
).run(draft.actionId, accountId, tweetId, "reply", text, draft.createdAt);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return Effect.gen(function* () {
|
|
271
|
+
const draft = yield* trySync(() => prepareReplyDraft());
|
|
272
|
+
yield* databaseWriteEffect((db) =>
|
|
273
|
+
preflightWrite(db, (writeDb) => writeReplyDraft(writeDb, draft)),
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
yield* verifySelectedXurlAccountEffect(accountId);
|
|
277
|
+
const transport = yield* replyViaXurlEffect(tweetId, text);
|
|
278
|
+
if (!transport.ok) {
|
|
279
|
+
return yield* Effect.fail(new Error(transport.output || "reply failed"));
|
|
280
|
+
}
|
|
281
|
+
yield* databaseWriteEffect((db) => writeReplyDraft(db, draft));
|
|
282
|
+
|
|
283
|
+
return { ok: true, transport, replyId: draft.replyId };
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function createTweetReply(
|
|
288
|
+
accountId: string,
|
|
289
|
+
tweetId: string,
|
|
290
|
+
text: string,
|
|
291
|
+
) {
|
|
292
|
+
return runEffectPromise(createTweetReplyEffect(accountId, tweetId, text));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function createDmReplyEffect(conversationId: string, text: string) {
|
|
296
|
+
return Effect.gen(function* () {
|
|
297
|
+
const draft = yield* trySync(() => {
|
|
298
|
+
const conversation = getConversationThread(conversationId);
|
|
299
|
+
if (!conversation) {
|
|
300
|
+
throw new Error("Conversation not found");
|
|
301
|
+
}
|
|
302
|
+
const authorProfileId = getLocalAuthorProfileId(
|
|
303
|
+
conversation.conversation.accountId,
|
|
304
|
+
);
|
|
305
|
+
if (!authorProfileId) {
|
|
306
|
+
throw new Error("No local author profile for account");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const dmDraft = {
|
|
310
|
+
accountId: conversation.conversation.accountId,
|
|
311
|
+
authorProfileId,
|
|
312
|
+
createdAt: new Date().toISOString(),
|
|
313
|
+
handle: conversation.conversation.participant.handle,
|
|
314
|
+
observedLastMessageAt: conversation.conversation.lastMessageAt,
|
|
315
|
+
outboundId: `msg_${randomUUID()}`,
|
|
316
|
+
};
|
|
317
|
+
return dmDraft;
|
|
318
|
+
});
|
|
319
|
+
yield* databaseWriteEffect((db) =>
|
|
320
|
+
preflightWrite(db, (writeDb) => {
|
|
321
|
+
writeDb
|
|
322
|
+
.prepare(
|
|
323
|
+
`
|
|
324
|
+
insert into dm_messages (
|
|
325
|
+
id, conversation_id, sender_profile_id, text, created_at, direction, is_replied, media_count
|
|
326
|
+
) values (?, ?, ?, ?, ?, 'outbound', 1, 0)
|
|
327
|
+
`,
|
|
328
|
+
)
|
|
329
|
+
.run(
|
|
330
|
+
draft.outboundId,
|
|
331
|
+
conversationId,
|
|
332
|
+
draft.authorProfileId,
|
|
333
|
+
text,
|
|
334
|
+
draft.createdAt,
|
|
335
|
+
);
|
|
336
|
+
writeDb
|
|
337
|
+
.prepare("insert into dm_fts (message_id, text) values (?, ?)")
|
|
338
|
+
.run(draft.outboundId, text);
|
|
339
|
+
|
|
340
|
+
refreshDmConversationState(
|
|
341
|
+
writeDb,
|
|
342
|
+
conversationId,
|
|
343
|
+
draft.createdAt,
|
|
344
|
+
draft.observedLastMessageAt,
|
|
345
|
+
);
|
|
346
|
+
}),
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
yield* verifySelectedXurlAccountEffect(draft.accountId);
|
|
350
|
+
const transport = yield* dmViaXurlEffect(draft.handle, text);
|
|
351
|
+
if (!transport.ok) {
|
|
352
|
+
return yield* Effect.fail(new Error(transport.output || "dm failed"));
|
|
353
|
+
}
|
|
354
|
+
yield* databaseWriteEffect((db) => {
|
|
355
|
+
db.prepare(
|
|
356
|
+
`
|
|
357
|
+
insert into dm_messages (
|
|
358
|
+
id, conversation_id, sender_profile_id, text, created_at, direction, is_replied, media_count
|
|
359
|
+
) values (?, ?, ?, ?, ?, 'outbound', 1, 0)
|
|
360
|
+
`,
|
|
361
|
+
).run(
|
|
362
|
+
draft.outboundId,
|
|
363
|
+
conversationId,
|
|
364
|
+
draft.authorProfileId,
|
|
365
|
+
text,
|
|
366
|
+
draft.createdAt,
|
|
367
|
+
);
|
|
368
|
+
db.prepare("insert into dm_fts (message_id, text) values (?, ?)").run(
|
|
369
|
+
draft.outboundId,
|
|
370
|
+
text,
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
refreshDmConversationState(
|
|
374
|
+
db,
|
|
375
|
+
conversationId,
|
|
376
|
+
draft.createdAt,
|
|
377
|
+
draft.observedLastMessageAt,
|
|
378
|
+
);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
return { ok: true, transport, messageId: draft.outboundId };
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function createDmReply(conversationId: string, text: string) {
|
|
386
|
+
return runEffectPromise(createDmReplyEffect(conversationId, text));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export type DmRequestMutationAction = "accept" | "reject" | "block";
|
|
390
|
+
|
|
391
|
+
export async function applyDmRequestMutationToLocalStore(
|
|
392
|
+
conversationId: string,
|
|
393
|
+
action: DmRequestMutationAction,
|
|
394
|
+
) {
|
|
395
|
+
return runEffectPromise(
|
|
396
|
+
databaseWriteEffect((db) => {
|
|
397
|
+
db.prepare(
|
|
398
|
+
"delete from sync_cache where cache_key like 'dms:bird:%'",
|
|
399
|
+
).run();
|
|
400
|
+
if (action === "accept") {
|
|
401
|
+
return db
|
|
402
|
+
.prepare(
|
|
403
|
+
`
|
|
404
|
+
update dm_conversations
|
|
405
|
+
set inbox_kind = 'accepted'
|
|
406
|
+
where id = ?
|
|
407
|
+
`,
|
|
408
|
+
)
|
|
409
|
+
.run(conversationId).changes;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
db.prepare(
|
|
413
|
+
`
|
|
414
|
+
delete from link_occurrences
|
|
415
|
+
where source_kind = 'dm'
|
|
416
|
+
and source_id in (
|
|
417
|
+
select id from dm_messages where conversation_id = ?
|
|
418
|
+
)
|
|
419
|
+
`,
|
|
420
|
+
).run(conversationId);
|
|
421
|
+
db.prepare(
|
|
422
|
+
`
|
|
423
|
+
delete from dm_fts
|
|
424
|
+
where message_id in (
|
|
425
|
+
select id from dm_messages where conversation_id = ?
|
|
426
|
+
)
|
|
427
|
+
`,
|
|
428
|
+
).run(conversationId);
|
|
429
|
+
db.prepare("delete from dm_messages where conversation_id = ?").run(
|
|
430
|
+
conversationId,
|
|
431
|
+
);
|
|
432
|
+
return db
|
|
433
|
+
.prepare("delete from dm_conversations where id = ?")
|
|
434
|
+
.run(conversationId).changes;
|
|
435
|
+
}),
|
|
436
|
+
);
|
|
437
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {
|
|
2
|
+
QueryClient,
|
|
3
|
+
QueryClientProvider,
|
|
4
|
+
type QueryKey,
|
|
5
|
+
} from "@tanstack/react-query";
|
|
6
|
+
import { useState, type ReactNode } from "react";
|
|
7
|
+
|
|
8
|
+
export const queryKeys = {
|
|
9
|
+
status: ["status"] as const,
|
|
10
|
+
timelines: ["timeline"] as const,
|
|
11
|
+
dms: ["dms"] as const,
|
|
12
|
+
linkInsights: ["link-insights"] as const,
|
|
13
|
+
profileHydration: ["profile-hydration"] as const,
|
|
14
|
+
networkMap: ["network-map"] as const,
|
|
15
|
+
blocks: ["blocks"] as const,
|
|
16
|
+
blockSync: ["block-sync"] as const,
|
|
17
|
+
inbox: ["inbox"] as const,
|
|
18
|
+
dataSources: ["data-sources"] as const,
|
|
19
|
+
rateLimits: ["rate-limits"] as const,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function createBirdclawQueryClient() {
|
|
23
|
+
return new QueryClient({
|
|
24
|
+
defaultOptions: {
|
|
25
|
+
queries: {
|
|
26
|
+
gcTime: 30 * 60_000,
|
|
27
|
+
refetchOnWindowFocus: false,
|
|
28
|
+
retry: 1,
|
|
29
|
+
staleTime: 60_000,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function invalidateQueryFamily(
|
|
36
|
+
queryClient: QueryClient,
|
|
37
|
+
queryKey: QueryKey,
|
|
38
|
+
) {
|
|
39
|
+
return queryClient.invalidateQueries({ queryKey });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function BirdclawQueryProvider({ children }: { children: ReactNode }) {
|
|
43
|
+
const [queryClient] = useState(createBirdclawQueryClient);
|
|
44
|
+
return (
|
|
45
|
+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ProfileRecord } from "./types";
|
|
2
|
+
|
|
3
|
+
export function toProfile(row: Record<string, unknown>): ProfileRecord {
|
|
4
|
+
const followingCount = Number(row.following_count ?? 0);
|
|
5
|
+
const entities = parseJsonField<Record<string, unknown> | undefined>(
|
|
6
|
+
row.entities_json,
|
|
7
|
+
undefined,
|
|
8
|
+
);
|
|
9
|
+
return {
|
|
10
|
+
id: String(row.id),
|
|
11
|
+
handle: String(row.handle),
|
|
12
|
+
displayName: String(row.display_name),
|
|
13
|
+
bio: String(row.bio),
|
|
14
|
+
followersCount: Number(row.followers_count),
|
|
15
|
+
...(Number.isFinite(followingCount) ? { followingCount } : {}),
|
|
16
|
+
avatarHue: Number(row.avatar_hue),
|
|
17
|
+
avatarUrl:
|
|
18
|
+
typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
|
|
19
|
+
...(typeof row.location === "string" && row.location.length > 0
|
|
20
|
+
? { location: row.location }
|
|
21
|
+
: {}),
|
|
22
|
+
...(typeof row.url === "string" && row.url.length > 0
|
|
23
|
+
? { url: row.url }
|
|
24
|
+
: {}),
|
|
25
|
+
...(typeof row.verified_type === "string" && row.verified_type.length > 0
|
|
26
|
+
? { verifiedType: row.verified_type }
|
|
27
|
+
: {}),
|
|
28
|
+
...(entities ? { entities } : {}),
|
|
29
|
+
createdAt: String(row.created_at),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseJsonField<T>(value: unknown, fallback: T): T {
|
|
34
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
35
|
+
return fallback;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(value) as T;
|
|
40
|
+
} catch {
|
|
41
|
+
return fallback;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function toFtsSearchQuery(value: string) {
|
|
46
|
+
const terms = value.match(/[\p{L}\p{N}_]+/gu) ?? [];
|
|
47
|
+
return terms
|
|
48
|
+
.map((term) => term.trim())
|
|
49
|
+
.filter((term) => term.length > 0)
|
|
50
|
+
.map((term) => `"${term.replaceAll('"', '""')}"`)
|
|
51
|
+
.join(" ");
|
|
52
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { getConversationThread, listDmConversations } from "./dm-read-model";
|
|
2
|
+
import { listTimelineItems } from "./timeline-read-model";
|
|
3
|
+
import type { DmQuery, QueryResponse, TimelineQuery } from "./types";
|
|
4
|
+
|
|
5
|
+
export type { QueryResponse } from "./types";
|
|
6
|
+
|
|
7
|
+
export function queryResource(
|
|
8
|
+
resource: "home" | "mentions" | "authored" | "search" | "dms",
|
|
9
|
+
filters: (TimelineQuery | DmQuery) & { conversationId?: string },
|
|
10
|
+
): QueryResponse {
|
|
11
|
+
if (resource === "dms") {
|
|
12
|
+
const dmFilters = filters as DmQuery & { conversationId?: string };
|
|
13
|
+
const items = listDmConversations(dmFilters);
|
|
14
|
+
const requestedConversationId = dmFilters.conversationId;
|
|
15
|
+
const selectedConversationId =
|
|
16
|
+
requestedConversationId &&
|
|
17
|
+
items.some((item) => item.id === requestedConversationId)
|
|
18
|
+
? requestedConversationId
|
|
19
|
+
: items[0]?.id;
|
|
20
|
+
return {
|
|
21
|
+
resource,
|
|
22
|
+
items,
|
|
23
|
+
selectedConversation: selectedConversationId
|
|
24
|
+
? getConversationThread(selectedConversationId, {
|
|
25
|
+
account: dmFilters.account,
|
|
26
|
+
})
|
|
27
|
+
: null,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { resource: _filterResource, ...timelineFilters } =
|
|
32
|
+
filters as TimelineQuery;
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
resource,
|
|
36
|
+
items: listTimelineItems({
|
|
37
|
+
resource,
|
|
38
|
+
...timelineFilters,
|
|
39
|
+
}),
|
|
40
|
+
};
|
|
41
|
+
}
|