birdclaw 0.8.1 → 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.
Files changed (103) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/package.json +2 -4
  3. package/scripts/browser-perf.mjs +27 -0
  4. package/src/cli/command-context.ts +17 -0
  5. package/src/cli/register-analysis.ts +500 -0
  6. package/src/cli/register-compose.ts +40 -0
  7. package/src/cli/register-graph.ts +132 -0
  8. package/src/cli/register-inbox.ts +41 -0
  9. package/src/cli/register-storage.ts +106 -0
  10. package/src/cli.ts +30 -750
  11. package/src/components/AccountSwitcher.tsx +7 -15
  12. package/src/components/AvatarChip.tsx +1 -1
  13. package/src/components/AvatarPreload.ts +149 -0
  14. package/src/components/ConversationThread.tsx +4 -0
  15. package/src/components/EmbeddedTweetCard.tsx +4 -0
  16. package/src/components/FloatingPreview.tsx +382 -0
  17. package/src/components/MarkdownCitations.tsx +680 -0
  18. package/src/components/MarkdownViewer.tsx +7 -715
  19. package/src/components/ProfileAnalysisClient.ts +191 -0
  20. package/src/components/ProfileAnalysisStream.tsx +16 -185
  21. package/src/components/ProfilePreview.tsx +41 -81
  22. package/src/components/TimelineCard.tsx +18 -2
  23. package/src/components/TweetArticleCard.tsx +66 -0
  24. package/src/components/TweetRichText.tsx +33 -2
  25. package/src/components/links-controller.ts +162 -0
  26. package/src/components/links-model.ts +198 -0
  27. package/src/components/network-map-controller.ts +84 -0
  28. package/src/components/network-map-model.ts +255 -0
  29. package/src/components/useDebouncedValue.ts +12 -0
  30. package/src/components/useTimelineRouteData.ts +142 -170
  31. package/src/lib/analysis-runtime.ts +238 -0
  32. package/src/lib/api-client.ts +16 -100
  33. package/src/lib/api-contracts.ts +328 -0
  34. package/src/lib/archive-finder.ts +38 -0
  35. package/src/lib/archive-import-plan.ts +102 -0
  36. package/src/lib/archive-import.ts +170 -239
  37. package/src/lib/authored-live.ts +77 -182
  38. package/src/lib/backup.ts +335 -424
  39. package/src/lib/bird.ts +32 -1
  40. package/src/lib/blocks-write.ts +30 -26
  41. package/src/lib/blocks.ts +18 -20
  42. package/src/lib/database-metrics.ts +88 -0
  43. package/src/lib/database-migrations.ts +34 -0
  44. package/src/lib/database-schema.ts +312 -0
  45. package/src/lib/database-writer.ts +69 -0
  46. package/src/lib/db.ts +141 -334
  47. package/src/lib/dm-read-model.ts +533 -0
  48. package/src/lib/dms-live.ts +34 -97
  49. package/src/lib/follow-graph.ts +17 -27
  50. package/src/lib/import-repository.ts +138 -0
  51. package/src/lib/inbox.ts +2 -1
  52. package/src/lib/live-sync-engine.ts +209 -0
  53. package/src/lib/live-transport-gateway.ts +128 -0
  54. package/src/lib/mention-threads-live.ts +90 -176
  55. package/src/lib/mentions-export.ts +1 -1
  56. package/src/lib/mentions-live.ts +57 -225
  57. package/src/lib/moderation-target.ts +15 -4
  58. package/src/lib/moderation-write.ts +1 -1
  59. package/src/lib/mutes-write.ts +30 -26
  60. package/src/lib/openai-response-runtime.ts +251 -0
  61. package/src/lib/paginated-sync.ts +93 -0
  62. package/src/lib/period-digest.ts +116 -304
  63. package/src/lib/profile-analysis.ts +37 -111
  64. package/src/lib/queries.ts +6 -2380
  65. package/src/lib/query-actions.ts +437 -0
  66. package/src/lib/query-client.tsx +47 -0
  67. package/src/lib/query-read-model-shared.ts +52 -0
  68. package/src/lib/query-read-models.ts +5 -0
  69. package/src/lib/query-resource.ts +41 -0
  70. package/src/lib/query-status.ts +164 -0
  71. package/src/lib/research.ts +1 -1
  72. package/src/lib/runtime-services.ts +20 -0
  73. package/src/lib/search-discussion.ts +75 -279
  74. package/src/lib/server-runtime-services.ts +30 -0
  75. package/src/lib/sqlite.ts +53 -14
  76. package/src/lib/streaming-ingestion.ts +240 -0
  77. package/src/lib/sync-cache.ts +6 -1
  78. package/src/lib/sync-plan.ts +175 -0
  79. package/src/lib/timeline-collections-live.ts +83 -256
  80. package/src/lib/timeline-live.ts +86 -235
  81. package/src/lib/timeline-read-model.ts +1191 -0
  82. package/src/lib/tweet-render.ts +78 -0
  83. package/src/lib/tweet-repository.ts +156 -0
  84. package/src/lib/tweet-search-live.ts +63 -166
  85. package/src/lib/types.ts +8 -0
  86. package/src/lib/ui.ts +1 -1
  87. package/src/lib/web-sync.ts +67 -50
  88. package/src/lib/whois.ts +2 -1
  89. package/src/router.tsx +1 -1
  90. package/src/routes/__root.tsx +11 -21
  91. package/src/routes/api/action.tsx +1 -1
  92. package/src/routes/api/conversation.tsx +1 -1
  93. package/src/routes/api/query.tsx +32 -26
  94. package/src/routes/api/status.tsx +6 -2
  95. package/src/routes/api/sync.tsx +5 -2
  96. package/src/routes/blocks.tsx +97 -131
  97. package/src/routes/data-sources.tsx +17 -25
  98. package/src/routes/dms.tsx +168 -167
  99. package/src/routes/inbox.tsx +63 -57
  100. package/src/routes/links.tsx +31 -329
  101. package/src/routes/network-map.tsx +41 -344
  102. package/src/routes/rate-limits.tsx +17 -21
  103. package/vite.config.ts +0 -2
package/src/lib/bird.ts CHANGED
@@ -29,6 +29,12 @@ interface BirdTweetAuthor {
29
29
  name?: string;
30
30
  }
31
31
 
32
+ interface BirdTweetArticle {
33
+ title?: string;
34
+ previewText?: string;
35
+ coverImageUrl?: string;
36
+ }
37
+
32
38
  interface BirdTweetItem {
33
39
  id: string;
34
40
  text: string;
@@ -45,6 +51,7 @@ interface BirdTweetItem {
45
51
  author?: BirdTweetAuthor;
46
52
  authorId?: string;
47
53
  media?: BirdTweetMedia[];
54
+ article?: BirdTweetArticle | null;
48
55
  }
49
56
 
50
57
  export interface BirdDmUser {
@@ -385,6 +392,29 @@ function toMediaEntities(media: BirdTweetMedia[] | undefined) {
385
392
  };
386
393
  }
387
394
 
395
+ function toTweetEntities(item: BirdTweetItem) {
396
+ const mediaEntities = toMediaEntities(item.media);
397
+ const title = item.article?.title?.trim();
398
+ if (!title) return mediaEntities;
399
+ const handle = item.author?.username?.replace(/^@/, "");
400
+ const url = handle
401
+ ? `https://x.com/${handle}/status/${item.id}`
402
+ : `https://x.com/i/status/${item.id}`;
403
+ return {
404
+ ...mediaEntities,
405
+ article: {
406
+ title,
407
+ url,
408
+ ...(item.article?.previewText?.trim()
409
+ ? { previewText: item.article.previewText.trim() }
410
+ : {}),
411
+ ...(item.article?.coverImageUrl?.trim()
412
+ ? { coverImageUrl: item.article.coverImageUrl.trim() }
413
+ : {}),
414
+ },
415
+ };
416
+ }
417
+
388
418
  function toReferencedTweets(item: BirdTweetItem) {
389
419
  const references: XurlReferencedTweet[] = [];
390
420
  if (typeof item.inReplyToStatusId === "string" && item.inReplyToStatusId) {
@@ -434,7 +464,7 @@ function normalizeBirdTweets(items: BirdTweetItem[]): XurlMentionsResponse {
434
464
  text: item.text,
435
465
  created_at: toIsoTimestamp(item.createdAt),
436
466
  conversation_id: item.conversationId ?? item.id,
437
- entities: toMediaEntities(item.media),
467
+ entities: toTweetEntities(item),
438
468
  referenced_tweets: toReferencedTweets(item),
439
469
  public_metrics: {
440
470
  reply_count: Number(item.replyCount ?? 0),
@@ -1104,6 +1134,7 @@ export const __test__ = {
1104
1134
  getBirdTweetItems,
1105
1135
  getBirdTweetItem,
1106
1136
  toMediaEntities,
1137
+ toTweetEntities,
1107
1138
  toReferencedTweets,
1108
1139
  normalizeBirdTweets,
1109
1140
  };
@@ -1,5 +1,6 @@
1
1
  import { Effect } from "effect";
2
2
  import { runModerationAction } from "./actions-transport";
3
+ import { databaseWriteEffect } from "./database-writer";
3
4
  import { runEffectPromise, tryPromise } from "./effect-runtime";
4
5
  import {
5
6
  deleteModerationRow,
@@ -8,14 +9,6 @@ import {
8
9
  writeModerationRow,
9
10
  } from "./moderation-write";
10
11
 
11
- function trySync<T>(try_: () => T) {
12
- return Effect.try({
13
- try: try_,
14
- catch: (error) =>
15
- error instanceof Error ? error : new Error(String(error)),
16
- });
17
- }
18
-
19
12
  export function addBlockEffect(
20
13
  accountId: string,
21
14
  query: string,
@@ -49,14 +42,16 @@ export function addBlockEffect(
49
42
  }
50
43
 
51
44
  const blockedAt = new Date().toISOString();
52
- yield* trySync(() =>
53
- writeModerationRow(
54
- db,
55
- "blocks",
56
- resolvedAccountId,
57
- resolved.profile.id,
58
- blockedAt,
59
- ),
45
+ yield* databaseWriteEffect(
46
+ (writeDb) =>
47
+ writeModerationRow(
48
+ writeDb,
49
+ "blocks",
50
+ resolvedAccountId,
51
+ resolved.profile.id,
52
+ blockedAt,
53
+ ),
54
+ db,
60
55
  );
61
56
 
62
57
  return {
@@ -88,14 +83,16 @@ export function recordBlockEffect(accountId: string, query: string) {
88
83
  });
89
84
 
90
85
  const blockedAt = new Date().toISOString();
91
- yield* trySync(() =>
92
- writeModerationRow(
93
- db,
94
- "blocks",
95
- resolvedAccountId,
96
- resolved.profile.id,
97
- blockedAt,
98
- ),
86
+ yield* databaseWriteEffect(
87
+ (writeDb) =>
88
+ writeModerationRow(
89
+ writeDb,
90
+ "blocks",
91
+ resolvedAccountId,
92
+ resolved.profile.id,
93
+ blockedAt,
94
+ ),
95
+ db,
99
96
  );
100
97
 
101
98
  return {
@@ -144,8 +141,15 @@ export function removeBlockEffect(
144
141
  };
145
142
  }
146
143
 
147
- yield* trySync(() =>
148
- deleteModerationRow(db, "blocks", resolvedAccountId, resolved.profile.id),
144
+ yield* databaseWriteEffect(
145
+ (writeDb) =>
146
+ deleteModerationRow(
147
+ writeDb,
148
+ "blocks",
149
+ resolvedAccountId,
150
+ resolved.profile.id,
151
+ ),
152
+ db,
149
153
  );
150
154
 
151
155
  return {
package/src/lib/blocks.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Effect } from "effect";
2
2
  import type { Database } from "./sqlite";
3
3
  import { getNativeDb } from "./db";
4
+ import { databaseWriteEffect } from "./database-writer";
4
5
  import { runEffectPromise, tryPromise } from "./effect-runtime";
5
6
  import {
6
7
  getAccountHandle,
@@ -322,33 +323,30 @@ export function syncBlocksEffect(accountId: string) {
322
323
  const page = yield* tryPromise(() =>
323
324
  listBlockedUsers(sourceUserId, nextToken ?? undefined),
324
325
  ).pipe(Effect.mapError(toError));
325
- yield* trySync(() => {
326
- const mergeRemotePage = db.transaction(() => {
327
- for (const user of page.items) {
328
- const resolved = upsertProfileFromXUser(db, user);
329
- remoteProfileIds.push(resolved.profile.id);
330
- upsertRemoteBlock(
331
- db,
332
- resolvedAccountId,
333
- resolved.profile.id,
334
- blockedAt,
335
- );
336
- }
326
+ const pageProfileIds = yield* databaseWriteEffect((writeDb) => {
327
+ return page.items.map((user) => {
328
+ const resolved = upsertProfileFromXUser(writeDb, user);
329
+ upsertRemoteBlock(
330
+ writeDb,
331
+ resolvedAccountId,
332
+ resolved.profile.id,
333
+ blockedAt,
334
+ );
335
+ return resolved.profile.id;
337
336
  });
338
- mergeRemotePage();
339
- });
337
+ }, db);
338
+ remoteProfileIds.push(...pageProfileIds);
340
339
  nextToken = page.nextToken;
341
340
  pageCount += 1;
342
341
  } while (nextToken && pageCount < 20);
343
342
 
344
343
  completed = !nextToken;
345
344
  if (completed) {
346
- yield* trySync(() => {
347
- const pruneMergedBlocks = db.transaction(() => {
348
- pruneRemoteBlocks(db, resolvedAccountId, remoteProfileIds);
349
- });
350
- pruneMergedBlocks();
351
- });
345
+ yield* databaseWriteEffect(
346
+ (writeDb) =>
347
+ pruneRemoteBlocks(writeDb, resolvedAccountId, remoteProfileIds),
348
+ db,
349
+ );
352
350
  }
353
351
 
354
352
  return {
@@ -0,0 +1,88 @@
1
+ export type DatabaseConnectionRole = "reader" | "writer";
2
+
3
+ export interface SlowDatabaseStatement {
4
+ durationMs: number;
5
+ role: DatabaseConnectionRole;
6
+ sql: string;
7
+ }
8
+
9
+ const SLOW_STATEMENT_MS = 50;
10
+ const MAX_SLOW_STATEMENTS = 10;
11
+
12
+ let readStatements = 0;
13
+ let writeStatements = 0;
14
+ let slowStatements: SlowDatabaseStatement[] = [];
15
+ let queuedWrites = 0;
16
+ let activeWrites = 0;
17
+ let completedWrites = 0;
18
+ let failedWrites = 0;
19
+ let maxQueuedWrites = 0;
20
+ let maxWriteWaitMs = 0;
21
+
22
+ function compactSql(sql: string) {
23
+ return sql.replace(/\s+/g, " ").trim().slice(0, 240);
24
+ }
25
+
26
+ export function recordDatabaseStatement(
27
+ role: DatabaseConnectionRole,
28
+ sql: string,
29
+ durationMs: number,
30
+ ) {
31
+ if (role === "reader") {
32
+ readStatements += 1;
33
+ } else {
34
+ writeStatements += 1;
35
+ }
36
+ if (durationMs < SLOW_STATEMENT_MS) return;
37
+ slowStatements = [
38
+ ...slowStatements,
39
+ { durationMs, role, sql: compactSql(sql) },
40
+ ].slice(-MAX_SLOW_STATEMENTS);
41
+ }
42
+
43
+ export function recordDatabaseWriteQueued() {
44
+ queuedWrites += 1;
45
+ maxQueuedWrites = Math.max(maxQueuedWrites, queuedWrites);
46
+ }
47
+
48
+ export function recordDatabaseWriteStarted(waitMs: number) {
49
+ queuedWrites = Math.max(0, queuedWrites - 1);
50
+ activeWrites += 1;
51
+ maxWriteWaitMs = Math.max(maxWriteWaitMs, waitMs);
52
+ }
53
+
54
+ export function recordDatabaseWriteCompleted(failed: boolean) {
55
+ activeWrites = Math.max(0, activeWrites - 1);
56
+ completedWrites += 1;
57
+ if (failed) failedWrites += 1;
58
+ }
59
+
60
+ export function getDatabaseRuntimeMetrics() {
61
+ return {
62
+ connections: {
63
+ readStatements,
64
+ writeStatements,
65
+ slowStatements: [...slowStatements],
66
+ },
67
+ writer: {
68
+ active: activeWrites,
69
+ completed: completedWrites,
70
+ failed: failedWrites,
71
+ maxQueued: maxQueuedWrites,
72
+ maxWaitMs: maxWriteWaitMs,
73
+ queued: queuedWrites,
74
+ },
75
+ };
76
+ }
77
+
78
+ export function resetDatabaseRuntimeMetricsForTests() {
79
+ readStatements = 0;
80
+ writeStatements = 0;
81
+ slowStatements = [];
82
+ queuedWrites = 0;
83
+ activeWrites = 0;
84
+ completedWrites = 0;
85
+ failedWrites = 0;
86
+ maxQueuedWrites = 0;
87
+ maxWriteWaitMs = 0;
88
+ }
@@ -0,0 +1,34 @@
1
+ import type { Database } from "./sqlite";
2
+
3
+ export interface DatabaseMigration {
4
+ version: number;
5
+ name: string;
6
+ up: (db: Database) => void;
7
+ }
8
+
9
+ export function getDatabaseSchemaVersion(db: Database) {
10
+ return Number(db.pragma("user_version", { simple: true }) ?? 0);
11
+ }
12
+
13
+ export function runDatabaseMigrations(
14
+ db: Database,
15
+ migrations: readonly DatabaseMigration[],
16
+ ) {
17
+ let currentVersion = getDatabaseSchemaVersion(db);
18
+ const pending = [...migrations]
19
+ .filter((migration) => migration.version > currentVersion)
20
+ .sort((left, right) => left.version - right.version);
21
+
22
+ for (const migration of pending) {
23
+ if (migration.version !== currentVersion + 1) {
24
+ throw new Error(
25
+ `Missing database migration between versions ${String(currentVersion)} and ${String(migration.version)}`,
26
+ );
27
+ }
28
+ migration.up(db);
29
+ db.pragma(`user_version = ${String(migration.version)}`);
30
+ currentVersion = migration.version;
31
+ }
32
+
33
+ return currentVersion;
34
+ }
@@ -0,0 +1,312 @@
1
+ export interface AccountsTable {
2
+ id: string;
3
+ name: string;
4
+ handle: string;
5
+ external_user_id: string | null;
6
+ transport: string;
7
+ is_default: number;
8
+ created_at: string;
9
+ }
10
+
11
+ export interface ProfilesTable {
12
+ id: string;
13
+ handle: string;
14
+ display_name: string;
15
+ bio: string;
16
+ followers_count: number;
17
+ following_count: number;
18
+ public_metrics_json: string;
19
+ avatar_hue: number;
20
+ avatar_url: string | null;
21
+ location: string | null;
22
+ url: string | null;
23
+ verified_type: string | null;
24
+ entities_json: string;
25
+ raw_json: string;
26
+ created_at: string;
27
+ }
28
+
29
+ export interface ProfileAffiliationsTable {
30
+ subject_profile_id: string;
31
+ organization_profile_id: string;
32
+ organization_name: string | null;
33
+ organization_handle: string | null;
34
+ badge_url: string | null;
35
+ url: string | null;
36
+ label: string | null;
37
+ source: string;
38
+ is_active: number;
39
+ first_seen_at: string;
40
+ last_seen_at: string;
41
+ raw_json: string;
42
+ updated_at: string;
43
+ }
44
+
45
+ export interface ProfileSnapshotsTable {
46
+ profile_id: string;
47
+ snapshot_hash: string;
48
+ observed_at: string;
49
+ last_seen_at: string;
50
+ source: string;
51
+ handle: string;
52
+ display_name: string;
53
+ bio: string;
54
+ location: string | null;
55
+ url: string | null;
56
+ verified_type: string | null;
57
+ followers_count: number;
58
+ following_count: number;
59
+ affiliations_json: string;
60
+ raw_json: string;
61
+ }
62
+
63
+ export interface ProfileBioEntitiesTable {
64
+ profile_id: string;
65
+ kind: string;
66
+ value: string;
67
+ source: string;
68
+ is_active: number;
69
+ first_seen_at: string;
70
+ last_seen_at: string;
71
+ raw_json: string;
72
+ }
73
+
74
+ export interface IdentitySearchIndexTable {
75
+ profile_id: string;
76
+ kind: string;
77
+ value: string;
78
+ normalized_value: string;
79
+ source: string;
80
+ weight: number;
81
+ updated_at: string;
82
+ }
83
+
84
+ export interface TweetsTable {
85
+ id: string;
86
+ account_id: string;
87
+ author_profile_id: string;
88
+ kind: string;
89
+ text: string;
90
+ created_at: string;
91
+ is_replied: number;
92
+ reply_to_id: string | null;
93
+ like_count: number;
94
+ media_count: number;
95
+ bookmarked: number;
96
+ liked: number;
97
+ entities_json: string;
98
+ media_json: string;
99
+ quoted_tweet_id: string | null;
100
+ }
101
+
102
+ export interface TweetCollectionsTable {
103
+ account_id: string;
104
+ tweet_id: string;
105
+ kind: "likes" | "bookmarks";
106
+ collected_at: string | null;
107
+ source: string;
108
+ raw_json: string;
109
+ updated_at: string;
110
+ }
111
+
112
+ export interface TweetAccountEdgesTable {
113
+ account_id: string;
114
+ tweet_id: string;
115
+ kind:
116
+ | "home"
117
+ | "mention"
118
+ | "authored"
119
+ | "search"
120
+ | "profile"
121
+ | "thread_context";
122
+ first_seen_at: string;
123
+ last_seen_at: string;
124
+ seen_count: number;
125
+ source: string;
126
+ raw_json: string;
127
+ updated_at: string;
128
+ }
129
+
130
+ export interface DmConversationsTable {
131
+ id: string;
132
+ account_id: string;
133
+ participant_profile_id: string;
134
+ title: string;
135
+ inbox_kind: "accepted" | "request";
136
+ last_message_at: string;
137
+ unread_count: number;
138
+ needs_reply: number;
139
+ }
140
+
141
+ export interface DmMessagesTable {
142
+ id: string;
143
+ conversation_id: string;
144
+ sender_profile_id: string;
145
+ text: string;
146
+ created_at: string;
147
+ direction: "inbound" | "outbound";
148
+ is_replied: number;
149
+ media_count: number;
150
+ }
151
+
152
+ export interface TweetActionsTable {
153
+ id: string;
154
+ account_id: string;
155
+ tweet_id: string | null;
156
+ kind: string;
157
+ body: string;
158
+ created_at: string;
159
+ }
160
+
161
+ export interface BlocksTable {
162
+ account_id: string;
163
+ profile_id: string;
164
+ source: string;
165
+ created_at: string;
166
+ }
167
+
168
+ export interface MutesTable {
169
+ account_id: string;
170
+ profile_id: string;
171
+ source: string;
172
+ created_at: string;
173
+ }
174
+
175
+ export interface AiScoresTable {
176
+ entity_kind: string;
177
+ entity_id: string;
178
+ model: string;
179
+ score: number;
180
+ summary: string;
181
+ reasoning: string;
182
+ updated_at: string;
183
+ }
184
+
185
+ export interface SyncCacheTable {
186
+ cache_key: string;
187
+ value_json: string;
188
+ updated_at: string;
189
+ }
190
+
191
+ export interface UrlExpansionsTable {
192
+ short_url: string;
193
+ expanded_url: string;
194
+ final_url: string;
195
+ status: string;
196
+ expanded_tweet_id: string | null;
197
+ expanded_handle: string | null;
198
+ title: string | null;
199
+ description: string | null;
200
+ image_url: string | null;
201
+ site_name: string | null;
202
+ error: string | null;
203
+ source: string;
204
+ updated_at: string;
205
+ }
206
+
207
+ export interface LinkOccurrencesTable {
208
+ source_kind: "dm" | "tweet";
209
+ source_id: string;
210
+ source_position: number;
211
+ short_url: string;
212
+ account_id: string | null;
213
+ conversation_id: string | null;
214
+ direction: string | null;
215
+ created_at: string;
216
+ }
217
+
218
+ export interface FollowEdgesTable {
219
+ account_id: string;
220
+ direction: "followers" | "following";
221
+ profile_id: string;
222
+ external_user_id: string;
223
+ source: string;
224
+ current: number;
225
+ first_seen_at: string;
226
+ last_seen_at: string;
227
+ ended_at: string | null;
228
+ updated_at: string;
229
+ }
230
+
231
+ export interface FollowSnapshotsTable {
232
+ id: string;
233
+ account_id: string;
234
+ direction: "followers" | "following";
235
+ source: string;
236
+ status: "complete" | "incomplete";
237
+ page_count: number;
238
+ result_count: number;
239
+ started_at: string;
240
+ completed_at: string;
241
+ raw_meta_json: string;
242
+ }
243
+
244
+ export interface FollowSnapshotMembersTable {
245
+ snapshot_id: string;
246
+ profile_id: string;
247
+ external_user_id: string;
248
+ position: number;
249
+ }
250
+
251
+ export interface FollowEventsTable {
252
+ id: string;
253
+ account_id: string;
254
+ direction: "followers" | "following";
255
+ profile_id: string;
256
+ external_user_id: string;
257
+ kind: "started" | "ended";
258
+ event_at: string;
259
+ snapshot_id: string;
260
+ }
261
+
262
+ export interface GeocodedLocationsTable {
263
+ normalized_key: string;
264
+ original: string;
265
+ lat: number;
266
+ lng: number;
267
+ formatted: string | null;
268
+ country_code: string | null;
269
+ confidence: number | null;
270
+ provider: string;
271
+ approx_radius_m: number | null;
272
+ bounds_json: string;
273
+ components_json: string;
274
+ hits: number;
275
+ created_at: string;
276
+ last_used_at: string;
277
+ }
278
+
279
+ export interface GeocodedLocationsUnresolvedTable {
280
+ normalized_key: string;
281
+ original: string;
282
+ reason: string;
283
+ last_attempted_at: string;
284
+ ttl_until: string | null;
285
+ }
286
+
287
+ export interface BirdclawDatabase {
288
+ accounts: AccountsTable;
289
+ profiles: ProfilesTable;
290
+ profile_affiliations: ProfileAffiliationsTable;
291
+ profile_snapshots: ProfileSnapshotsTable;
292
+ profile_bio_entities: ProfileBioEntitiesTable;
293
+ identity_search_index: IdentitySearchIndexTable;
294
+ tweets: TweetsTable;
295
+ tweet_collections: TweetCollectionsTable;
296
+ tweet_account_edges: TweetAccountEdgesTable;
297
+ dm_conversations: DmConversationsTable;
298
+ dm_messages: DmMessagesTable;
299
+ tweet_actions: TweetActionsTable;
300
+ blocks: BlocksTable;
301
+ mutes: MutesTable;
302
+ ai_scores: AiScoresTable;
303
+ sync_cache: SyncCacheTable;
304
+ url_expansions: UrlExpansionsTable;
305
+ link_occurrences: LinkOccurrencesTable;
306
+ follow_edges: FollowEdgesTable;
307
+ follow_snapshots: FollowSnapshotsTable;
308
+ follow_snapshot_members: FollowSnapshotMembersTable;
309
+ follow_events: FollowEventsTable;
310
+ geocoded_locations: GeocodedLocationsTable;
311
+ geocoded_locations_unresolved: GeocodedLocationsUnresolvedTable;
312
+ }
@@ -0,0 +1,69 @@
1
+ import { Effect } from "effect";
2
+ import type { Database } from "./sqlite";
3
+ import {
4
+ defaultServerRuntimeServices,
5
+ type ServerRuntimeServices,
6
+ } from "./server-runtime-services";
7
+ import {
8
+ recordDatabaseWriteCompleted,
9
+ recordDatabaseWriteQueued,
10
+ recordDatabaseWriteStarted,
11
+ } from "./database-metrics";
12
+
13
+ let writeTails = new Map<string | object, Promise<void>>();
14
+
15
+ export function enqueueDatabaseWrite<T>(
16
+ write: (db: Database) => T,
17
+ providedDb?: Database,
18
+ runtime: ServerRuntimeServices = defaultServerRuntimeServices,
19
+ ): Promise<T> {
20
+ const db = providedDb ?? runtime.getDatabase({ seedDemoData: false });
21
+ const writeIdentity = db.writeIdentity;
22
+ const queuedAt = performance.now();
23
+ recordDatabaseWriteQueued();
24
+ const writeTail = writeTails.get(writeIdentity) ?? Promise.resolve();
25
+ const pending = writeTail.then(() => {
26
+ recordDatabaseWriteStarted(performance.now() - queuedAt);
27
+ try {
28
+ const result = db.transaction(() => write(db))();
29
+ recordDatabaseWriteCompleted(false);
30
+ return result;
31
+ } catch (error) {
32
+ recordDatabaseWriteCompleted(true);
33
+ throw error;
34
+ }
35
+ });
36
+ const settled = pending.then(
37
+ () => undefined,
38
+ () => undefined,
39
+ );
40
+ writeTails.set(writeIdentity, settled);
41
+ void settled.then(() => {
42
+ if (writeTails.get(writeIdentity) === settled) {
43
+ writeTails.delete(writeIdentity);
44
+ }
45
+ });
46
+ return pending;
47
+ }
48
+
49
+ export function databaseWriteEffect<T>(
50
+ write: (db: Database) => T,
51
+ providedDb?: Database,
52
+ runtime: ServerRuntimeServices = defaultServerRuntimeServices,
53
+ ) {
54
+ return Effect.tryPromise({
55
+ try: () => enqueueDatabaseWrite(write, providedDb, runtime),
56
+ catch: (error) =>
57
+ error instanceof Error ? error : new Error(String(error)),
58
+ });
59
+ }
60
+
61
+ export async function drainDatabaseWrites() {
62
+ while (writeTails.size > 0) {
63
+ await Promise.all(writeTails.values());
64
+ }
65
+ }
66
+
67
+ export function resetDatabaseWriterForTests() {
68
+ writeTails = new Map();
69
+ }