birdclaw 0.5.1 → 0.7.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 (116) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +75 -5
  3. package/package.json +8 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +812 -37
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +37 -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 +818 -0
  13. package/src/components/ProfileAnalysisStream.tsx +428 -0
  14. package/src/components/ProfilePreview.tsx +120 -9
  15. package/src/components/SavedTimelineView.tsx +30 -8
  16. package/src/components/SyncNowButton.tsx +60 -25
  17. package/src/components/ThemeSlider.tsx +55 -50
  18. package/src/components/TimelineCard.tsx +240 -92
  19. package/src/components/TimelineRouteFrame.tsx +38 -8
  20. package/src/components/TweetMediaGrid.tsx +87 -38
  21. package/src/components/TweetRichText.tsx +45 -17
  22. package/src/components/account-selection.ts +64 -0
  23. package/src/components/useTimelineRouteData.ts +97 -13
  24. package/src/lib/account-sync-job.ts +666 -0
  25. package/src/lib/actions-transport.ts +216 -146
  26. package/src/lib/api-client.ts +128 -53
  27. package/src/lib/archive-finder.ts +78 -63
  28. package/src/lib/archive-import.ts +1593 -1291
  29. package/src/lib/authored-live.ts +262 -204
  30. package/src/lib/avatar-cache.ts +208 -43
  31. package/src/lib/backup.ts +1536 -954
  32. package/src/lib/bird-actions.ts +139 -57
  33. package/src/lib/bird-command.ts +101 -28
  34. package/src/lib/bird.ts +582 -194
  35. package/src/lib/blocklist.ts +40 -23
  36. package/src/lib/blocks-write.ts +129 -80
  37. package/src/lib/blocks.ts +165 -97
  38. package/src/lib/bookmark-sync-job.ts +250 -160
  39. package/src/lib/config.ts +35 -2
  40. package/src/lib/conversation-surface.ts +79 -48
  41. package/src/lib/data-sources.ts +219 -0
  42. package/src/lib/db.ts +95 -4
  43. package/src/lib/dms-live.ts +720 -66
  44. package/src/lib/effect-runtime.ts +45 -0
  45. package/src/lib/follow-graph.ts +224 -180
  46. package/src/lib/geocoding.ts +296 -0
  47. package/src/lib/http-effect.ts +222 -0
  48. package/src/lib/inbox.ts +74 -43
  49. package/src/lib/link-index.ts +88 -76
  50. package/src/lib/link-insights.ts +24 -0
  51. package/src/lib/link-preview-metadata.ts +472 -52
  52. package/src/lib/location.ts +137 -0
  53. package/src/lib/media-fetch.ts +286 -213
  54. package/src/lib/mention-threads-live.ts +445 -288
  55. package/src/lib/mentions-live.ts +549 -354
  56. package/src/lib/moderation-target.ts +102 -65
  57. package/src/lib/moderation-write.ts +77 -18
  58. package/src/lib/mutes-write.ts +129 -80
  59. package/src/lib/mutes.ts +8 -1
  60. package/src/lib/network-map.ts +382 -0
  61. package/src/lib/openai.ts +84 -53
  62. package/src/lib/period-digest.ts +1317 -0
  63. package/src/lib/profile-affiliation-hydration.ts +93 -54
  64. package/src/lib/profile-analysis.ts +1272 -0
  65. package/src/lib/profile-bio-entities.ts +1 -1
  66. package/src/lib/profile-hydration.ts +124 -72
  67. package/src/lib/profile-replies.ts +60 -43
  68. package/src/lib/profile-resolver.ts +402 -294
  69. package/src/lib/queries.ts +983 -203
  70. package/src/lib/research.ts +165 -120
  71. package/src/lib/search-discussion.ts +1016 -0
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +325 -51
  75. package/src/lib/tweet-account-edges.ts +2 -0
  76. package/src/lib/tweet-lookup.ts +30 -19
  77. package/src/lib/tweet-render.ts +141 -1
  78. package/src/lib/tweet-search-live.ts +565 -0
  79. package/src/lib/types.ts +75 -3
  80. package/src/lib/ui.ts +31 -8
  81. package/src/lib/url-expansion.ts +226 -55
  82. package/src/lib/url-safety.ts +220 -0
  83. package/src/lib/web-sync.ts +222 -149
  84. package/src/lib/whois.ts +166 -147
  85. package/src/lib/x-web.ts +102 -71
  86. package/src/lib/xurl-rate-limits.ts +267 -0
  87. package/src/lib/xurl.ts +1185 -405
  88. package/src/routeTree.gen.ts +273 -0
  89. package/src/routes/__root.tsx +24 -5
  90. package/src/routes/api/action.tsx +127 -78
  91. package/src/routes/api/avatar.tsx +39 -30
  92. package/src/routes/api/blocks.tsx +26 -23
  93. package/src/routes/api/conversation.tsx +25 -14
  94. package/src/routes/api/data-sources.tsx +24 -0
  95. package/src/routes/api/inbox.tsx +27 -21
  96. package/src/routes/api/link-insights.tsx +31 -25
  97. package/src/routes/api/link-preview.tsx +25 -21
  98. package/src/routes/api/network-map.tsx +55 -0
  99. package/src/routes/api/period-digest.tsx +133 -0
  100. package/src/routes/api/profile-analysis.tsx +152 -0
  101. package/src/routes/api/profile-hydrate.tsx +31 -28
  102. package/src/routes/api/query.tsx +80 -55
  103. package/src/routes/api/search-discussion.tsx +169 -0
  104. package/src/routes/api/status.tsx +18 -10
  105. package/src/routes/api/sync.tsx +75 -29
  106. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  107. package/src/routes/data-sources.tsx +255 -0
  108. package/src/routes/discuss.tsx +419 -0
  109. package/src/routes/dms.tsx +95 -28
  110. package/src/routes/inbox.tsx +32 -19
  111. package/src/routes/network-map.tsx +1035 -0
  112. package/src/routes/profile-analyze.tsx +112 -0
  113. package/src/routes/profiles.$handle.tsx +228 -0
  114. package/src/routes/rate-limits.tsx +309 -0
  115. package/src/routes/today.tsx +455 -0
  116. package/src/styles.css +22 -0
@@ -1,6 +1,8 @@
1
1
  import { useCallback, useEffect, useSyncExternalStore } from "react";
2
2
  import type { ReactNode } from "react";
3
+ import { Effect } from "effect";
3
4
  import type { EmbeddedTweet } from "#/lib/types";
5
+ import { runEffectPromise } from "./effect-runtime";
4
6
 
5
7
  type ConversationStatus = "idle" | "loading" | "ready" | "error";
6
8
 
@@ -60,53 +62,81 @@ function getSnapshot() {
60
62
  return snapshot;
61
63
  }
62
64
 
63
- async function loadConversation(tweetId: string) {
64
- const current = snapshot.records.get(tweetId);
65
- if (current?.status === "ready" || inFlight.has(tweetId)) {
66
- return;
67
- }
68
-
69
- const loadGeneration = generation;
70
- inFlight.add(tweetId);
71
- updateRecord(tweetId, {
72
- error: null,
73
- items: current?.items ?? [],
74
- status: "loading",
75
- });
76
-
77
- try {
78
- const response = await fetch(
79
- `/api/conversation?tweetId=${encodeURIComponent(tweetId)}`,
80
- );
81
- const data = (await response.json()) as {
65
+ function fetchConversationItemsEffect(tweetId: string) {
66
+ return Effect.gen(function* () {
67
+ const response = yield* Effect.tryPromise({
68
+ try: () =>
69
+ fetch(`/api/conversation?tweetId=${encodeURIComponent(tweetId)}`),
70
+ catch: (error) => error,
71
+ });
72
+ const data = (yield* Effect.tryPromise({
73
+ try: () => response.json(),
74
+ catch: (error) => error,
75
+ })) as {
82
76
  error?: string;
83
77
  items?: EmbeddedTweet[];
84
78
  ok?: boolean;
85
79
  };
86
80
  if (!response.ok || data.ok === false) {
87
- throw new Error(data.error ?? "Conversation unavailable");
81
+ return yield* Effect.fail(
82
+ new Error(data.error ?? "Conversation unavailable"),
83
+ );
88
84
  }
89
- if (loadGeneration !== generation) {
90
- return;
91
- }
92
- updateRecord(tweetId, {
93
- error: null,
94
- items: (data.items ?? []).filter(Boolean),
95
- status: "ready",
96
- });
97
- } catch (error) {
98
- if (loadGeneration !== generation) {
85
+ return (data.items ?? []).filter(Boolean);
86
+ });
87
+ }
88
+
89
+ export function loadConversationEffect(
90
+ surfaceId: string,
91
+ tweetId = surfaceId,
92
+ ): Effect.Effect<void, never> {
93
+ return Effect.gen(function* () {
94
+ const current = snapshot.records.get(surfaceId);
95
+ if (current?.status === "ready" || inFlight.has(surfaceId)) {
99
96
  return;
100
97
  }
101
- updateRecord(tweetId, {
102
- error:
103
- error instanceof Error ? error.message : "Conversation unavailable",
104
- items: [],
105
- status: "error",
106
- });
107
- } finally {
108
- inFlight.delete(tweetId);
109
- }
98
+
99
+ const loadGeneration = generation;
100
+ inFlight.add(surfaceId);
101
+ yield* Effect.gen(function* () {
102
+ updateRecord(surfaceId, {
103
+ error: null,
104
+ items: current?.items ?? [],
105
+ status: "loading",
106
+ });
107
+
108
+ const result = yield* fetchConversationItemsEffect(tweetId).pipe(
109
+ Effect.match({
110
+ onFailure: (error) => ({ error, ok: false as const }),
111
+ onSuccess: (items) => ({ items, ok: true as const }),
112
+ }),
113
+ );
114
+
115
+ if (loadGeneration !== generation) {
116
+ return;
117
+ }
118
+ if (result.ok) {
119
+ updateRecord(surfaceId, {
120
+ error: null,
121
+ items: result.items,
122
+ status: "ready",
123
+ });
124
+ } else {
125
+ updateRecord(surfaceId, {
126
+ error:
127
+ result.error instanceof Error
128
+ ? result.error.message
129
+ : "Conversation unavailable",
130
+ items: [],
131
+ status: "error",
132
+ });
133
+ }
134
+ }).pipe(Effect.ensuring(Effect.sync(() => inFlight.delete(surfaceId))));
135
+ });
136
+ }
137
+
138
+ function loadConversation(surfaceId: string, tweetId = surfaceId) {
139
+ return runEffectPromise(loadConversationEffect(surfaceId, tweetId));
110
140
  }
111
141
 
112
142
  export function retainConversationSurfaceScope() {
@@ -137,30 +167,31 @@ export function ConversationSurfaceScope({
137
167
  return children;
138
168
  }
139
169
 
140
- export function useConversationSurface(tweetId: string) {
170
+ export function useConversationSurface(surfaceId: string, tweetId = surfaceId) {
141
171
  const currentSnapshot = useSyncExternalStore(
142
172
  subscribe,
143
173
  getSnapshot,
144
174
  getSnapshot,
145
175
  );
146
- const record = currentSnapshot.records.get(tweetId) ?? emptyRecord;
147
- const isOpen = currentSnapshot.expandedTweetId === tweetId;
176
+ const record = currentSnapshot.records.get(surfaceId) ?? emptyRecord;
177
+ const isOpen = currentSnapshot.expandedTweetId === surfaceId;
148
178
 
149
179
  const toggle = useCallback(() => {
150
- const nextExpanded = snapshot.expandedTweetId === tweetId ? null : tweetId;
180
+ const nextExpanded =
181
+ snapshot.expandedTweetId === surfaceId ? null : surfaceId;
151
182
  setSnapshot({ ...snapshot, expandedTweetId: nextExpanded });
152
183
  if (nextExpanded) {
153
- void loadConversation(tweetId);
184
+ void loadConversation(surfaceId, tweetId);
154
185
  }
155
- }, [tweetId]);
186
+ }, [surfaceId, tweetId]);
156
187
 
157
188
  const prefetch = useCallback(() => {
158
- const current = snapshot.records.get(tweetId);
189
+ const current = snapshot.records.get(surfaceId);
159
190
  if (current?.status === "ready" || current?.status === "loading") {
160
191
  return;
161
192
  }
162
- void loadConversation(tweetId);
163
- }, [tweetId]);
193
+ void loadConversation(surfaceId, tweetId);
194
+ }, [surfaceId, tweetId]);
164
195
 
165
196
  return {
166
197
  error: record.error,
@@ -0,0 +1,219 @@
1
+ import { Effect } from "effect";
2
+ import { getAuthenticatedBirdAccountEffect } from "./bird";
3
+ import { getNativeDb } from "./db";
4
+ import { runEffectPromise } from "./effect-runtime";
5
+ import type {
6
+ LiveDataSourceAccount,
7
+ LiveDataSourceCapability,
8
+ LiveDataSourcesResponse,
9
+ LiveDataSourceStatus,
10
+ } from "./types";
11
+ import {
12
+ getTransportStatusEffect,
13
+ lookupAuthenticatedOAuth2UserEffect,
14
+ readXurlOAuth2AccountsEffect,
15
+ } from "./xurl";
16
+
17
+ function errorMessage(error: unknown) {
18
+ return error instanceof Error ? error.message : String(error);
19
+ }
20
+
21
+ function readLocalAccounts(): LiveDataSourceAccount[] {
22
+ const db = getNativeDb();
23
+ const rows = db
24
+ .prepare(
25
+ `
26
+ select id, handle, external_user_id, is_default
27
+ from accounts
28
+ order by is_default desc, lower(handle) asc
29
+ `,
30
+ )
31
+ .all() as Array<{
32
+ id: string;
33
+ handle: string;
34
+ external_user_id: string | null;
35
+ is_default: number;
36
+ }>;
37
+ return rows.map((row) => ({
38
+ id: row.external_user_id ?? row.id,
39
+ handle: row.handle,
40
+ username: row.handle.replace(/^@/, ""),
41
+ isDefault: row.is_default === 1,
42
+ }));
43
+ }
44
+
45
+ function getBirdclawStatusEffect(): Effect.Effect<LiveDataSourceStatus, never> {
46
+ return Effect.try({
47
+ try: () => readLocalAccounts(),
48
+ catch: (error) => error,
49
+ }).pipe(
50
+ Effect.map((accounts) => ({
51
+ source: "birdclaw" as const,
52
+ label: "Birdclaw local",
53
+ works: true,
54
+ installed: true,
55
+ status: "ok" as const,
56
+ detail:
57
+ accounts.length > 0
58
+ ? `${accounts.length.toString()} local account${accounts.length === 1 ? "" : "s"}`
59
+ : "local database ready; no accounts imported yet",
60
+ accounts,
61
+ })),
62
+ Effect.catchAll((error) =>
63
+ Effect.succeed({
64
+ source: "birdclaw" as const,
65
+ label: "Birdclaw local",
66
+ works: false,
67
+ installed: true,
68
+ status: "error" as const,
69
+ detail: errorMessage(error),
70
+ accounts: [],
71
+ }),
72
+ ),
73
+ );
74
+ }
75
+
76
+ function getBirdStatusEffect(): Effect.Effect<LiveDataSourceStatus, never> {
77
+ return getAuthenticatedBirdAccountEffect().pipe(
78
+ Effect.map((account) => ({
79
+ source: "bird" as const,
80
+ label: "bird",
81
+ works: true,
82
+ installed: true,
83
+ status: "ok" as const,
84
+ detail: `authenticated as @${account.username}`,
85
+ accounts: [
86
+ {
87
+ ...(account.id ? { id: account.id } : {}),
88
+ username: account.username,
89
+ handle: `@${account.username}`,
90
+ },
91
+ ],
92
+ })),
93
+ Effect.catchAll((error) =>
94
+ Effect.succeed({
95
+ source: "bird" as const,
96
+ label: "bird",
97
+ works: false,
98
+ status: "error" as const,
99
+ detail: errorMessage(error),
100
+ accounts: [],
101
+ }),
102
+ ),
103
+ );
104
+ }
105
+
106
+ function getXurlStatusEffect(): Effect.Effect<LiveDataSourceStatus, never> {
107
+ return Effect.gen(function* () {
108
+ const transport = yield* getTransportStatusEffect();
109
+ const oauth2Accounts = yield* readXurlOAuth2AccountsEffect();
110
+ const authenticated = yield* lookupAuthenticatedOAuth2UserEffect().pipe(
111
+ Effect.catchAll(() => Effect.succeed(null)),
112
+ );
113
+ const authenticatedAccount =
114
+ authenticated && typeof authenticated === "object"
115
+ ? ({
116
+ ...(typeof authenticated.id === "string"
117
+ ? { id: authenticated.id }
118
+ : {}),
119
+ ...(typeof authenticated.username === "string"
120
+ ? {
121
+ username: authenticated.username,
122
+ handle: `@${authenticated.username}`,
123
+ }
124
+ : {}),
125
+ } satisfies LiveDataSourceAccount)
126
+ : undefined;
127
+ const accounts: LiveDataSourceAccount[] = [
128
+ ...(authenticatedAccount ? [authenticatedAccount] : []),
129
+ ...oauth2Accounts,
130
+ ];
131
+ const deduped = accounts.filter(
132
+ (account, index) =>
133
+ accounts.findIndex(
134
+ (candidate) =>
135
+ (candidate.app ?? "") === (account.app ?? "") &&
136
+ (candidate.username ?? candidate.handle ?? "") ===
137
+ (account.username ?? account.handle ?? ""),
138
+ ) === index,
139
+ );
140
+ const works = transport.availableTransport === "xurl";
141
+ return {
142
+ source: "xurl" as const,
143
+ label: "xurl",
144
+ works,
145
+ installed: transport.installed,
146
+ status: works ? ("ok" as const) : ("warning" as const),
147
+ detail: transport.statusText,
148
+ accounts: deduped,
149
+ };
150
+ }).pipe(
151
+ Effect.catchAll((error) =>
152
+ Effect.succeed({
153
+ source: "xurl" as const,
154
+ label: "xurl",
155
+ works: false,
156
+ status: "error" as const,
157
+ detail: errorMessage(error),
158
+ accounts: [],
159
+ }),
160
+ ),
161
+ );
162
+ }
163
+
164
+ const capabilities: LiveDataSourceCapability[] = [
165
+ {
166
+ key: "timeline",
167
+ label: "Home timeline",
168
+ primary: "xurl",
169
+ fallbacks: ["bird"],
170
+ },
171
+ {
172
+ key: "mentions",
173
+ label: "Mentions",
174
+ primary: "xurl",
175
+ fallbacks: ["bird", "birdclaw"],
176
+ notes: "bird fallback is skipped when a since/start cursor requires xurl.",
177
+ },
178
+ {
179
+ key: "search",
180
+ label: "Fresh search",
181
+ primary: "bird",
182
+ fallbacks: ["xurl", "birdclaw"],
183
+ notes: "dated searches require xurl.",
184
+ },
185
+ {
186
+ key: "dms",
187
+ label: "DMs",
188
+ primary: "xurl",
189
+ fallbacks: ["bird", "birdclaw"],
190
+ notes: "message requests require bird.",
191
+ },
192
+ {
193
+ key: "follow-graph",
194
+ label: "Followers / following",
195
+ primary: "bird",
196
+ fallbacks: ["xurl", "birdclaw"],
197
+ },
198
+ ];
199
+
200
+ export function getLiveDataSourcesEffect(): Effect.Effect<
201
+ LiveDataSourcesResponse,
202
+ never
203
+ > {
204
+ return Effect.gen(function* () {
205
+ const sources = yield* Effect.all(
206
+ [getBirdclawStatusEffect(), getBirdStatusEffect(), getXurlStatusEffect()],
207
+ { concurrency: "unbounded" },
208
+ );
209
+ return {
210
+ generatedAt: new Date().toISOString(),
211
+ sources,
212
+ capabilities,
213
+ };
214
+ });
215
+ }
216
+
217
+ export function getLiveDataSources(): Promise<LiveDataSourcesResponse> {
218
+ return runEffectPromise(getLiveDataSourcesEffect());
219
+ }
package/src/lib/db.ts CHANGED
@@ -117,7 +117,13 @@ export interface TweetCollectionsTable {
117
117
  export interface TweetAccountEdgesTable {
118
118
  account_id: string;
119
119
  tweet_id: string;
120
- kind: "home" | "mention" | "authored" | "thread_context";
120
+ kind:
121
+ | "home"
122
+ | "mention"
123
+ | "authored"
124
+ | "search"
125
+ | "profile"
126
+ | "thread_context";
121
127
  first_seen_at: string;
122
128
  last_seen_at: string;
123
129
  seen_count: number;
@@ -131,6 +137,7 @@ export interface DmConversationsTable {
131
137
  account_id: string;
132
138
  participant_profile_id: string;
133
139
  title: string;
140
+ inbox_kind: "accepted" | "request";
134
141
  last_message_at: string;
135
142
  unread_count: number;
136
143
  needs_reply: number;
@@ -257,6 +264,31 @@ export interface FollowEventsTable {
257
264
  snapshot_id: string;
258
265
  }
259
266
 
267
+ export interface GeocodedLocationsTable {
268
+ normalized_key: string;
269
+ original: string;
270
+ lat: number;
271
+ lng: number;
272
+ formatted: string | null;
273
+ country_code: string | null;
274
+ confidence: number | null;
275
+ provider: string;
276
+ approx_radius_m: number | null;
277
+ bounds_json: string;
278
+ components_json: string;
279
+ hits: number;
280
+ created_at: string;
281
+ last_used_at: string;
282
+ }
283
+
284
+ export interface GeocodedLocationsUnresolvedTable {
285
+ normalized_key: string;
286
+ original: string;
287
+ reason: string;
288
+ last_attempted_at: string;
289
+ ttl_until: string | null;
290
+ }
291
+
260
292
  export interface BirdclawDatabase {
261
293
  accounts: AccountsTable;
262
294
  profiles: ProfilesTable;
@@ -280,6 +312,8 @@ export interface BirdclawDatabase {
280
312
  follow_snapshots: FollowSnapshotsTable;
281
313
  follow_snapshot_members: FollowSnapshotMembersTable;
282
314
  follow_events: FollowEventsTable;
315
+ geocoded_locations: GeocodedLocationsTable;
316
+ geocoded_locations_unresolved: GeocodedLocationsUnresolvedTable;
283
317
  }
284
318
 
285
319
  let nativeDb: Database | undefined;
@@ -429,6 +463,7 @@ const BASE_SCHEMA_SQL = `
429
463
  account_id text not null,
430
464
  participant_profile_id text not null,
431
465
  title text not null,
466
+ inbox_kind text not null default 'accepted',
432
467
  last_message_at text not null,
433
468
  unread_count integer not null default 0,
434
469
  needs_reply integer not null default 0
@@ -561,6 +596,31 @@ const BASE_SCHEMA_SQL = `
561
596
  snapshot_id text not null
562
597
  );
563
598
 
599
+ create table if not exists geocoded_locations (
600
+ normalized_key text primary key,
601
+ original text not null,
602
+ lat real not null,
603
+ lng real not null,
604
+ formatted text,
605
+ country_code text,
606
+ confidence integer,
607
+ provider text not null,
608
+ approx_radius_m real,
609
+ bounds_json text not null default '{}',
610
+ components_json text not null default '{}',
611
+ hits integer not null default 1,
612
+ created_at text not null,
613
+ last_used_at text not null
614
+ );
615
+
616
+ create table if not exists geocoded_locations_unresolved (
617
+ normalized_key text primary key,
618
+ original text not null,
619
+ reason text not null,
620
+ last_attempted_at text not null,
621
+ ttl_until text
622
+ );
623
+
564
624
  create virtual table if not exists tweets_fts using fts5(
565
625
  tweet_id unindexed,
566
626
  text
@@ -609,6 +669,9 @@ const INDEX_SQL = `
609
669
  create index if not exists idx_follow_edges_profile on follow_edges(profile_id, current);
610
670
  create index if not exists idx_follow_snapshots_account on follow_snapshots(account_id, direction, completed_at desc);
611
671
  create index if not exists idx_follow_events_account on follow_events(account_id, direction, kind, event_at desc);
672
+ create index if not exists idx_geocoded_locations_country on geocoded_locations(country_code);
673
+ create index if not exists idx_geocoded_locations_last_used on geocoded_locations(last_used_at desc);
674
+ create index if not exists idx_geocoded_unresolved_ttl on geocoded_locations_unresolved(ttl_until desc);
612
675
  `;
613
676
 
614
677
  function getColumnNames(db: Database, tableName: string): Set<string> {
@@ -678,6 +741,15 @@ function ensureAccountExternalUserIdColumn(db: Database) {
678
741
  }
679
742
  }
680
743
 
744
+ function ensureDmConversationInboxColumns(db: Database) {
745
+ const columnNames = getColumnNames(db, "dm_conversations");
746
+ if (!columnNames.has("inbox_kind")) {
747
+ db.exec(
748
+ "alter table dm_conversations add column inbox_kind text not null default 'accepted'",
749
+ );
750
+ }
751
+ }
752
+
681
753
  function ensureTweetCollectionsTable(db: Database) {
682
754
  db.exec(`
683
755
  create table if not exists tweet_collections (
@@ -941,6 +1013,7 @@ function initDatabase(options: InitDatabaseOptions = {}) {
941
1013
  nativeDb = new NativeSqliteDatabase(dbPath);
942
1014
  nativeDb.exec(BASE_SCHEMA_SQL);
943
1015
  ensureAccountExternalUserIdColumn(nativeDb);
1016
+ ensureDmConversationInboxColumns(nativeDb);
944
1017
  ensureTweetMetadataColumns(nativeDb);
945
1018
  ensureProfileAvatarColumns(nativeDb);
946
1019
  ensureTweetCollectionsTable(nativeDb);
@@ -981,11 +1054,29 @@ export function getDb() {
981
1054
  return kyselyDb as Kysely<BirdclawDatabase>;
982
1055
  }
983
1056
 
984
- export function resetDatabaseForTests() {
985
- kyselyDb?.destroy();
1057
+ export async function closeDatabase() {
1058
+ const db = kyselyDb;
1059
+ const native = nativeDb;
986
1060
  kyselyDb = undefined;
1061
+ nativeDb = undefined;
1062
+ demoSeedAttempted = false;
987
1063
 
988
- nativeDb?.close();
1064
+ if (db) {
1065
+ await db.destroy();
1066
+ } else {
1067
+ native?.close();
1068
+ }
1069
+ }
1070
+
1071
+ export function resetDatabaseForTests() {
1072
+ const db = kyselyDb;
1073
+ const native = nativeDb;
1074
+ kyselyDb = undefined;
989
1075
  nativeDb = undefined;
990
1076
  demoSeedAttempted = false;
1077
+ if (db) {
1078
+ void db.destroy();
1079
+ } else {
1080
+ native?.close();
1081
+ }
991
1082
  }