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.
Files changed (89) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +2 -1
  3. package/src/cli/command-context.ts +17 -0
  4. package/src/cli/register-analysis.ts +500 -0
  5. package/src/cli/register-compose.ts +40 -0
  6. package/src/cli/register-graph.ts +132 -0
  7. package/src/cli/register-inbox.ts +41 -0
  8. package/src/cli/register-storage.ts +106 -0
  9. package/src/cli.ts +30 -750
  10. package/src/components/AccountSwitcher.tsx +7 -15
  11. package/src/components/AvatarChip.tsx +1 -1
  12. package/src/components/AvatarPreload.ts +149 -0
  13. package/src/components/MarkdownCitations.tsx +680 -0
  14. package/src/components/MarkdownViewer.tsx +8 -674
  15. package/src/components/ProfileAnalysisClient.ts +191 -0
  16. package/src/components/ProfileAnalysisStream.tsx +16 -185
  17. package/src/components/ProfilePreview.tsx +2 -0
  18. package/src/components/links-controller.ts +162 -0
  19. package/src/components/links-model.ts +198 -0
  20. package/src/components/network-map-controller.ts +84 -0
  21. package/src/components/network-map-model.ts +255 -0
  22. package/src/components/useTimelineRouteData.ts +105 -235
  23. package/src/lib/analysis-runtime.ts +238 -0
  24. package/src/lib/api-client.ts +16 -215
  25. package/src/lib/api-contracts.ts +328 -0
  26. package/src/lib/archive-import-plan.ts +102 -0
  27. package/src/lib/archive-import.ts +170 -239
  28. package/src/lib/authored-live.ts +75 -120
  29. package/src/lib/backup.ts +335 -424
  30. package/src/lib/blocks-write.ts +30 -26
  31. package/src/lib/blocks.ts +18 -20
  32. package/src/lib/database-metrics.ts +88 -0
  33. package/src/lib/database-migrations.ts +34 -0
  34. package/src/lib/database-schema.ts +312 -0
  35. package/src/lib/database-writer.ts +69 -0
  36. package/src/lib/db.ts +84 -330
  37. package/src/lib/dm-read-model.ts +533 -0
  38. package/src/lib/dms-live.ts +34 -97
  39. package/src/lib/follow-graph.ts +17 -27
  40. package/src/lib/import-repository.ts +138 -0
  41. package/src/lib/inbox.ts +2 -1
  42. package/src/lib/live-sync-engine.ts +209 -0
  43. package/src/lib/live-transport-gateway.ts +128 -0
  44. package/src/lib/mention-threads-live.ts +90 -177
  45. package/src/lib/mentions-export.ts +1 -1
  46. package/src/lib/mentions-live.ts +57 -181
  47. package/src/lib/moderation-target.ts +15 -4
  48. package/src/lib/moderation-write.ts +1 -1
  49. package/src/lib/mutes-write.ts +30 -26
  50. package/src/lib/openai-response-runtime.ts +251 -0
  51. package/src/lib/paginated-sync.ts +93 -0
  52. package/src/lib/period-digest.ts +116 -304
  53. package/src/lib/profile-analysis.ts +36 -110
  54. package/src/lib/queries.ts +6 -2381
  55. package/src/lib/query-actions.ts +437 -0
  56. package/src/lib/query-client.tsx +47 -0
  57. package/src/lib/query-read-model-shared.ts +52 -0
  58. package/src/lib/query-read-models.ts +5 -0
  59. package/src/lib/query-resource.ts +41 -0
  60. package/src/lib/query-status.ts +164 -0
  61. package/src/lib/research.ts +1 -1
  62. package/src/lib/runtime-services.ts +20 -0
  63. package/src/lib/search-discussion.ts +75 -279
  64. package/src/lib/server-runtime-services.ts +30 -0
  65. package/src/lib/sqlite.ts +48 -12
  66. package/src/lib/streaming-ingestion.ts +240 -0
  67. package/src/lib/sync-cache.ts +6 -1
  68. package/src/lib/sync-plan.ts +175 -0
  69. package/src/lib/timeline-collections-live.ts +83 -257
  70. package/src/lib/timeline-live.ts +86 -236
  71. package/src/lib/timeline-read-model.ts +1191 -0
  72. package/src/lib/tweet-repository.ts +156 -0
  73. package/src/lib/tweet-search-live.ts +63 -167
  74. package/src/lib/web-sync.ts +67 -50
  75. package/src/lib/whois.ts +2 -1
  76. package/src/routes/__root.tsx +11 -8
  77. package/src/routes/api/action.tsx +1 -1
  78. package/src/routes/api/conversation.tsx +1 -1
  79. package/src/routes/api/query.tsx +32 -26
  80. package/src/routes/api/status.tsx +6 -4
  81. package/src/routes/api/sync.tsx +5 -2
  82. package/src/routes/blocks.tsx +97 -131
  83. package/src/routes/data-sources.tsx +17 -25
  84. package/src/routes/dms.tsx +167 -184
  85. package/src/routes/inbox.tsx +63 -57
  86. package/src/routes/links.tsx +31 -394
  87. package/src/routes/network-map.tsx +41 -344
  88. package/src/routes/rate-limits.tsx +17 -21
  89. package/src/lib/client-cache.ts +0 -109
@@ -4,17 +4,18 @@ import {
4
4
  type BirdDmConversation,
5
5
  type BirdDmEvent,
6
6
  type BirdDmUser,
7
- getAuthenticatedBirdAccountEffect,
8
- listDirectMessagesViaBirdEffect,
9
7
  } from "./bird";
10
8
  import { getNativeDb } from "./db";
11
9
  import { runEffectPromise } from "./effect-runtime";
10
+ import { liveTransportGateway } from "./live-transport-gateway";
11
+ import {
12
+ assertLiveAccountMatches,
13
+ resolveLiveSyncAccount,
14
+ type LiveSyncAccount,
15
+ } from "./live-sync-engine";
12
16
  import { readSyncCache, writeSyncCache } from "./sync-cache";
17
+ import { runSyncPlanEffect } from "./sync-plan";
13
18
  import type { XurlDmEventsResponse, XurlMentionUser } from "./types";
14
- import {
15
- listDirectMessageEventsViaXurlEffect,
16
- lookupAuthenticatedOAuth2UserEffect,
17
- } from "./xurl";
18
19
  import {
19
20
  buildExternalProfileId,
20
21
  randomAvatarHue,
@@ -65,11 +66,6 @@ function parseSyncMode(mode: DirectMessagesSyncMode | undefined) {
65
66
  throw new Error("--mode must be auto, bird, or xurl");
66
67
  }
67
68
 
68
- function normalizeExternalUserId(value: string | null | undefined) {
69
- const trimmed = value?.trim();
70
- return trimmed ? trimmed : undefined;
71
- }
72
-
73
69
  function makePreviewMessageId(conversationId: string): string {
74
70
  return `${PREVIEW_MESSAGE_ID_PREFIX}${conversationId}`;
75
71
  }
@@ -85,39 +81,6 @@ function deleteDmFtsRows(db: Database, messageIds: string[]) {
85
81
  }
86
82
  }
87
83
 
88
- function resolveAccount(db: Database, accountId?: string) {
89
- const row = accountId
90
- ? (db
91
- .prepare(
92
- "select id, handle, external_user_id from accounts where id = ?",
93
- )
94
- .get(accountId) as
95
- | { id: string; handle: string; external_user_id: string | null }
96
- | undefined)
97
- : (db
98
- .prepare(
99
- `
100
- select id, handle, external_user_id
101
- from accounts
102
- order by is_default desc, created_at asc
103
- limit 1
104
- `,
105
- )
106
- .get() as
107
- | { id: string; handle: string; external_user_id: string | null }
108
- | undefined);
109
-
110
- if (!row) {
111
- throw new Error(`Unknown account: ${accountId ?? "default"}`);
112
- }
113
-
114
- return {
115
- accountId: row.id,
116
- username: row.handle.replace(/^@/, ""),
117
- externalUserId: normalizeExternalUserId(row.external_user_id),
118
- };
119
- }
120
-
121
84
  function toIsoTimestamp(value?: string) {
122
85
  if (!value) {
123
86
  return new Date().toISOString();
@@ -202,36 +165,21 @@ function getLatestEvent(events: BirdDmEvent[]) {
202
165
 
203
166
  function assertAuthenticatedBirdAccountMatches({
204
167
  source,
205
- accountId,
206
- username,
207
- externalUserId,
168
+ account,
208
169
  liveUsername,
209
170
  liveExternalUserId,
210
171
  }: {
211
172
  source: "bird" | "xurl";
212
- accountId: string;
213
- username: string;
214
- externalUserId?: string;
173
+ account: LiveSyncAccount;
215
174
  liveUsername: string;
216
175
  liveExternalUserId?: string;
217
176
  }) {
218
- if (
219
- externalUserId &&
220
- liveExternalUserId &&
221
- liveExternalUserId === externalUserId
222
- ) {
223
- return;
224
- }
225
- if (externalUserId && liveExternalUserId) {
226
- throw new Error(
227
- `${source} is authenticated as user ${liveExternalUserId}; refusing to sync into ${accountId} (${externalUserId})`,
228
- );
229
- }
230
- if (liveUsername.toLowerCase() !== username.toLowerCase()) {
231
- throw new Error(
232
- `${source} is authenticated as @${liveUsername}; refusing to sync into ${accountId} (@${username})`,
233
- );
234
- }
177
+ assertLiveAccountMatches({
178
+ source,
179
+ account,
180
+ liveUsername,
181
+ liveExternalUserId,
182
+ });
235
183
  }
236
184
 
237
185
  function getAuthenticatedXurlAccount(payload: Record<string, unknown> | null): {
@@ -707,10 +655,6 @@ function mergeXurlDmPages(pages: XurlDmEventsResponse[]): XurlDmEventsResponse {
707
655
  };
708
656
  }
709
657
 
710
- function sleepEffect(ms: number | undefined) {
711
- return typeof ms === "number" && ms > 0 ? Effect.sleep(ms) : Effect.void;
712
- }
713
-
714
658
  function fetchDirectMessagesViaXurlEffect({
715
659
  limit,
716
660
  username,
@@ -725,25 +669,21 @@ function fetchDirectMessagesViaXurlEffect({
725
669
  pageDelayMs?: number;
726
670
  }) {
727
671
  return Effect.gen(function* () {
728
- const pages: XurlDmEventsResponse[] = [];
729
- let paginationToken: string | undefined;
730
- let pageIndex = 0;
731
672
  const pageLimit = allPages
732
673
  ? Number.POSITIVE_INFINITY
733
674
  : Math.max(1, (maxPages ?? 0) + 1);
734
- while (pageIndex < pageLimit) {
735
- const page = yield* listDirectMessageEventsViaXurlEffect({
736
- maxResults: limit,
737
- username,
738
- ...(paginationToken ? { paginationToken } : {}),
739
- });
740
- pages.push(page);
741
- pageIndex += 1;
742
- paginationToken = getMetaNextToken(page.meta);
743
- if (!paginationToken) break;
744
- yield* sleepEffect(pageDelayMs);
745
- }
746
- return mergeXurlDmPages(pages);
675
+ const result = yield* runSyncPlanEffect({
676
+ fetchPage: ({ cursor }) =>
677
+ liveTransportGateway.xurl.listDirectMessages({
678
+ maxResults: limit,
679
+ username,
680
+ ...(cursor ? { paginationToken: cursor } : {}),
681
+ }),
682
+ getNextCursor: (page) => getMetaNextToken(page.meta),
683
+ maxPages: pageLimit,
684
+ pageDelayMs,
685
+ });
686
+ return mergeXurlDmPages(result.pages);
747
687
  });
748
688
  }
749
689
 
@@ -780,7 +720,7 @@ export function syncDirectMessagesViaCachedBirdEffect({
780
720
  );
781
721
  }
782
722
  const db = getNativeDb();
783
- const resolvedAccount = resolveAccount(db, account);
723
+ const resolvedAccount = resolveLiveSyncAccount(db, account);
784
724
  const pageKey = allPages
785
725
  ? "all-pages"
786
726
  : `max-pages:${String(maxPages ?? 0)}`;
@@ -813,7 +753,7 @@ export function syncDirectMessagesViaCachedBirdEffect({
813
753
  if (tryXurl) {
814
754
  const xurlPayload = yield* Effect.gen(function* () {
815
755
  const authenticated = getAuthenticatedXurlAccount(
816
- yield* lookupAuthenticatedOAuth2UserEffect(
756
+ yield* liveTransportGateway.xurl.lookupAuthenticatedOAuth2User(
817
757
  resolvedAccount.username,
818
758
  ),
819
759
  );
@@ -824,9 +764,7 @@ export function syncDirectMessagesViaCachedBirdEffect({
824
764
  }
825
765
  assertAuthenticatedBirdAccountMatches({
826
766
  source: "xurl",
827
- accountId: resolvedAccount.accountId,
828
- username: resolvedAccount.username,
829
- externalUserId: resolvedAccount.externalUserId,
767
+ account: resolvedAccount,
830
768
  liveUsername: authenticated.username ?? resolvedAccount.username,
831
769
  liveExternalUserId: authenticated.id,
832
770
  });
@@ -874,12 +812,11 @@ export function syncDirectMessagesViaCachedBirdEffect({
874
812
  }
875
813
  }
876
814
  if (!payload) {
877
- const authenticated = yield* getAuthenticatedBirdAccountEffect();
815
+ const authenticated =
816
+ yield* liveTransportGateway.bird.getAuthenticatedAccount();
878
817
  assertAuthenticatedBirdAccountMatches({
879
818
  source: "bird",
880
- accountId: resolvedAccount.accountId,
881
- username: resolvedAccount.username,
882
- externalUserId: resolvedAccount.externalUserId,
819
+ account: resolvedAccount,
883
820
  liveUsername: authenticated.username,
884
821
  liveExternalUserId: authenticated.id,
885
822
  });
@@ -891,7 +828,7 @@ export function syncDirectMessagesViaCachedBirdEffect({
891
828
  accountExternalUserId,
892
829
  );
893
830
  }
894
- payload = yield* listDirectMessagesViaBirdEffect({
831
+ payload = yield* liveTransportGateway.bird.listDirectMessages({
895
832
  maxResults: limit,
896
833
  ...(inbox !== "all" ? { inbox } : {}),
897
834
  ...(typeof maxPages === "number" ? { maxPages } : {}),
@@ -1,10 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { Effect } from "effect";
3
3
  import { getNativeDb } from "./db";
4
- import { listFollowUsersViaBirdEffect } from "./bird";
5
- import { runEffectPromise, tryPromise } from "./effect-runtime";
4
+ import { runEffectPromise } from "./effect-runtime";
5
+ import { liveTransportGateway } from "./live-transport-gateway";
6
6
  import type { Database } from "./sqlite";
7
7
  import { readSyncCache, writeSyncCache } from "./sync-cache";
8
+ import { runSyncPlanEffect } from "./sync-plan";
8
9
  import type {
9
10
  FollowEventKind,
10
11
  FollowDirection,
@@ -16,7 +17,6 @@ import type {
16
17
  XurlPublicMetrics,
17
18
  } from "./types";
18
19
  import { upsertProfileFromXUser } from "./x-profile";
19
- import { listFollowUsersViaXurl } from "./xurl";
20
20
 
21
21
  const DEFAULT_FOLLOW_CACHE_TTL_MS = 24 * 60 * 60_000;
22
22
  const DEFAULT_FOLLOW_PAGE_LIMIT = 1000;
@@ -267,35 +267,25 @@ function fetchFollowGraphViaXurlEffect({
267
267
  maxResources?: number;
268
268
  }): Effect.Effect<MergedFollowPayload, unknown> {
269
269
  return Effect.gen(function* () {
270
- const pages: XurlFollowUsersResponse[] = [];
271
- let nextToken: string | undefined;
272
- let pageCount = 0;
273
- let collectedCount = 0;
274
-
275
- do {
276
- const payload = yield* tryPromise(() =>
277
- listFollowUsersViaXurl({
270
+ const result = yield* runSyncPlanEffect({
271
+ fetchPage: ({ cursor }) =>
272
+ liveTransportGateway.xurl.listFollowUsers({
278
273
  direction,
279
274
  username,
280
275
  userId,
281
276
  maxResults: limit,
282
- paginationToken: nextToken,
277
+ ...(cursor ? { paginationToken: cursor } : {}),
283
278
  }),
284
- );
285
- pages.push(payload);
286
- collectedCount += payload.data.length;
287
- nextToken =
288
- typeof payload.meta?.next_token === "string"
289
- ? String(payload.meta.next_token)
290
- : undefined;
291
- pageCount += 1;
292
- } while (
293
- nextToken &&
294
- (maxPages === undefined || pageCount < maxPages) &&
295
- (maxResources === undefined || collectedCount < maxResources)
296
- );
279
+ getItemCount: (page) => page.data.length,
280
+ getNextCursor: (page) =>
281
+ typeof page.meta?.next_token === "string"
282
+ ? String(page.meta.next_token)
283
+ : undefined,
284
+ maxItems: maxResources,
285
+ maxPages,
286
+ });
297
287
 
298
- return mergePages(pages, nextToken, maxResources);
288
+ return mergePages(result.pages, result.nextCursor, maxResources);
299
289
  });
300
290
  }
301
291
 
@@ -321,7 +311,7 @@ function fetchFollowGraphViaBirdEffect({
321
311
  maxPages ?? Number.POSITIVE_INFINITY,
322
312
  Math.ceil(maxResources / birdLimit),
323
313
  );
324
- const payload = yield* listFollowUsersViaBirdEffect({
314
+ const payload = yield* liveTransportGateway.bird.listFollowUsers({
325
315
  direction,
326
316
  userId,
327
317
  maxResults: Math.min(birdLimit, maxResources ?? birdLimit),
@@ -0,0 +1,138 @@
1
+ import type { Database } from "./sqlite";
2
+
3
+ export type ImportRow = Record<string, unknown>;
4
+ export type ImportFtsTable =
5
+ | { table: "tweets_fts"; idColumn: "tweet_id" }
6
+ | { table: "dm_fts"; idColumn: "message_id" };
7
+
8
+ export class ImportRepository {
9
+ constructor(readonly db: Database) {}
10
+
11
+ readRows<T>(sql: string, ...parameters: unknown[]) {
12
+ return this.db.prepare(sql).all(...parameters) as T[];
13
+ }
14
+
15
+ readRow<T>(sql: string, ...parameters: unknown[]) {
16
+ return this.db.prepare(sql).get(...parameters) as T | undefined;
17
+ }
18
+
19
+ insertRows(sql: string, rows: readonly ImportRow[], keys: readonly string[]) {
20
+ const statement = this.db.prepare(sql);
21
+ for (const row of rows) {
22
+ statement.run(...keys.map((key) => row[key] ?? null));
23
+ }
24
+ }
25
+
26
+ readFtsIds({ table, idColumn }: ImportFtsTable) {
27
+ const rows = this.db
28
+ .prepare(`select ${idColumn} as id from ${table}`)
29
+ .all() as { id: string }[];
30
+ return new Set(rows.map((row) => row.id));
31
+ }
32
+
33
+ insertFtsRows({
34
+ target,
35
+ rows,
36
+ idKey,
37
+ textKey,
38
+ existingIds = new Set<string>(),
39
+ }: {
40
+ target: ImportFtsTable;
41
+ rows: readonly ImportRow[];
42
+ idKey: string;
43
+ textKey: string;
44
+ existingIds?: Set<string>;
45
+ }) {
46
+ const statement = this.db.prepare(
47
+ `insert into ${target.table} (${target.idColumn}, text) values (?, ?)`,
48
+ );
49
+ for (const row of rows) {
50
+ const id = row[idKey];
51
+ if (typeof id !== "string" || existingIds.has(id)) continue;
52
+ const text = row[textKey];
53
+ statement.run(id, typeof text === "string" ? text : "");
54
+ existingIds.add(id);
55
+ }
56
+ }
57
+
58
+ clearArchiveImport() {
59
+ this.db.exec(`
60
+ delete from ai_scores;
61
+ delete from tweet_actions;
62
+ delete from tweet_account_edges;
63
+ delete from tweet_collections;
64
+ delete from link_occurrences;
65
+ delete from url_expansions;
66
+ delete from dm_fts;
67
+ delete from tweets_fts;
68
+ delete from dm_messages;
69
+ delete from dm_conversations;
70
+ delete from tweets;
71
+ delete from profiles;
72
+ delete from accounts;
73
+ `);
74
+ this.clearAuthoredSyncCursors();
75
+ }
76
+
77
+ clearAuthoredSyncCursors(accountId?: string) {
78
+ if (accountId) {
79
+ this.db
80
+ .prepare("delete from sync_cache where cache_key = ?")
81
+ .run(`authored:xurl:${accountId}:cursor`);
82
+ return;
83
+ }
84
+ this.db
85
+ .prepare(
86
+ "delete from sync_cache where cache_key like 'authored:xurl:%:cursor'",
87
+ )
88
+ .run();
89
+ }
90
+
91
+ clearMentionSyncState() {
92
+ this.db
93
+ .prepare("delete from sync_cache where cache_key like 'mentions:sync:%'")
94
+ .run();
95
+ }
96
+
97
+ clearBackupImport() {
98
+ this.db.exec(`
99
+ delete from follow_events;
100
+ delete from follow_edges;
101
+ delete from follow_snapshot_members;
102
+ delete from follow_snapshots;
103
+ delete from ai_scores;
104
+ delete from tweet_actions;
105
+ delete from tweet_account_edges;
106
+ delete from tweet_collections;
107
+ delete from link_occurrences;
108
+ delete from url_expansions;
109
+ delete from blocks;
110
+ delete from mutes;
111
+ delete from dm_fts;
112
+ delete from tweets_fts;
113
+ delete from dm_messages;
114
+ delete from dm_conversations;
115
+ delete from tweets;
116
+ delete from profile_bio_entities;
117
+ delete from profile_snapshots;
118
+ delete from profile_affiliations;
119
+ delete from profiles;
120
+ delete from accounts;
121
+ delete from sync_cache;
122
+ `);
123
+ }
124
+ }
125
+
126
+ let repositories = new WeakMap<Database, ImportRepository>();
127
+
128
+ export function getImportRepository(db: Database) {
129
+ const existing = repositories.get(db);
130
+ if (existing) return existing;
131
+ const repository = new ImportRepository(db);
132
+ repositories.set(db, repository);
133
+ return repository;
134
+ }
135
+
136
+ export function resetImportRepositoriesForTests() {
137
+ repositories = new WeakMap();
138
+ }
package/src/lib/inbox.ts CHANGED
@@ -2,7 +2,8 @@ import { Effect } from "effect";
2
2
  import { getNativeDb } from "./db";
3
3
  import { runEffectPromise } from "./effect-runtime";
4
4
  import { scoreInboxItemWithOpenAIEffect } from "./openai";
5
- import { listDmConversations, listTimelineItems } from "./queries";
5
+ import { listDmConversations } from "./dm-read-model";
6
+ import { listTimelineItems } from "./timeline-read-model";
6
7
  import type { InboxItem, InboxQuery, InboxResponse } from "./types";
7
8
 
8
9
  function heuristicSummary(kind: InboxItem["entityKind"]) {
@@ -0,0 +1,209 @@
1
+ import { Effect } from "effect";
2
+ import { databaseWriteEffect } from "./database-writer";
3
+ import type { Database } from "./sqlite";
4
+ import { readSyncCache, writeSyncCache } from "./sync-cache";
5
+
6
+ export interface LiveTransportAdapter<Source extends string, Payload> {
7
+ source: Source;
8
+ fetch: Effect.Effect<Payload, Error>;
9
+ }
10
+
11
+ export interface LiveSyncAccount {
12
+ accountId: string;
13
+ username: string;
14
+ externalUserId?: string;
15
+ isDefault: boolean;
16
+ }
17
+
18
+ interface CachedLiveSyncOptions<Source extends string, Payload, Persisted> {
19
+ db: Database;
20
+ cacheKey: string;
21
+ refresh: boolean;
22
+ cacheTtlMs: number;
23
+ transports: readonly LiveTransportAdapter<Source, Payload>[];
24
+ persistLive: (db: Database, payload: Payload, source: Source) => Persisted;
25
+ persistCached?: (db: Database, payload: Payload) => Persisted;
26
+ }
27
+
28
+ export interface CachedLiveSyncResult<
29
+ Source extends string,
30
+ Payload,
31
+ Persisted,
32
+ > {
33
+ source: Source | "cache";
34
+ payload: Payload;
35
+ persisted: Persisted | undefined;
36
+ }
37
+
38
+ function toError(error: unknown) {
39
+ return error instanceof Error ? error : new Error(String(error));
40
+ }
41
+
42
+ export function resolveLiveSyncAccount(
43
+ db: Database,
44
+ accountId?: string,
45
+ ): LiveSyncAccount {
46
+ const row = accountId
47
+ ? (db
48
+ .prepare(
49
+ "select id, handle, external_user_id, is_default from accounts where id = ?",
50
+ )
51
+ .get(accountId) as
52
+ | {
53
+ id: string;
54
+ handle: string;
55
+ external_user_id: string | null;
56
+ is_default: number;
57
+ }
58
+ | undefined)
59
+ : (db
60
+ .prepare(
61
+ `
62
+ select id, handle, external_user_id, is_default
63
+ from accounts
64
+ order by is_default desc, created_at asc
65
+ limit 1
66
+ `,
67
+ )
68
+ .get() as
69
+ | {
70
+ id: string;
71
+ handle: string;
72
+ external_user_id: string | null;
73
+ is_default: number;
74
+ }
75
+ | undefined);
76
+
77
+ if (!row) {
78
+ throw new Error(`Unknown account: ${accountId ?? "default"}`);
79
+ }
80
+
81
+ const externalUserId = row.external_user_id?.trim();
82
+ return {
83
+ accountId: row.id,
84
+ username: row.handle.replace(/^@/, ""),
85
+ ...(externalUserId ? { externalUserId } : {}),
86
+ isDefault: row.is_default === 1,
87
+ };
88
+ }
89
+
90
+ export function createLiveTransportAdapter<Source extends string, Payload>(
91
+ source: Source,
92
+ fetch: Effect.Effect<Payload, unknown>,
93
+ ): LiveTransportAdapter<Source, Payload> {
94
+ return {
95
+ source,
96
+ fetch: fetch.pipe(Effect.mapError(toError)),
97
+ };
98
+ }
99
+
100
+ export function assertLiveAccountMatches({
101
+ source,
102
+ account,
103
+ liveUsername,
104
+ liveExternalUserId,
105
+ }: {
106
+ source: string;
107
+ account: LiveSyncAccount;
108
+ liveUsername: string;
109
+ liveExternalUserId?: string;
110
+ }) {
111
+ if (
112
+ account.externalUserId &&
113
+ liveExternalUserId &&
114
+ account.externalUserId === liveExternalUserId
115
+ ) {
116
+ return;
117
+ }
118
+ if (account.externalUserId && liveExternalUserId) {
119
+ throw new Error(
120
+ `${source} is authenticated as user ${liveExternalUserId}; refusing to sync into ${account.accountId} (${account.externalUserId})`,
121
+ );
122
+ }
123
+ if (liveUsername.toLowerCase() !== account.username.toLowerCase()) {
124
+ throw new Error(
125
+ `${source} is authenticated as @${liveUsername}; refusing to sync into ${account.accountId} (@${account.username})`,
126
+ );
127
+ }
128
+ }
129
+
130
+ export function normalizeCacheTtlMs(
131
+ value: number | undefined,
132
+ defaultValue: number,
133
+ ) {
134
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
135
+ return defaultValue;
136
+ }
137
+ return Math.floor(value);
138
+ }
139
+
140
+ export function fetchWithTransportFallbackEffect<
141
+ Source extends string,
142
+ Payload,
143
+ >(
144
+ transports: readonly LiveTransportAdapter<Source, Payload>[],
145
+ ): Effect.Effect<{ source: Source; payload: Payload }, Error> {
146
+ const [first, ...rest] = transports;
147
+ if (!first) {
148
+ return Effect.fail(new Error("No live transport adapters configured"));
149
+ }
150
+ return first.fetch.pipe(
151
+ Effect.map((payload) => ({ source: first.source, payload })),
152
+ Effect.catchAll((error) =>
153
+ rest.length > 0
154
+ ? fetchWithTransportFallbackEffect(rest)
155
+ : Effect.fail(toError(error)),
156
+ ),
157
+ );
158
+ }
159
+
160
+ export function runCachedLiveSyncEffect<
161
+ Source extends string,
162
+ Payload,
163
+ Persisted,
164
+ >({
165
+ db,
166
+ cacheKey,
167
+ refresh,
168
+ cacheTtlMs,
169
+ transports,
170
+ persistLive,
171
+ persistCached,
172
+ }: CachedLiveSyncOptions<Source, Payload, Persisted>): Effect.Effect<
173
+ CachedLiveSyncResult<Source, Payload, Persisted>,
174
+ Error
175
+ > {
176
+ return Effect.gen(function* () {
177
+ const cached = yield* Effect.try({
178
+ try: () => readSyncCache<Payload>(cacheKey, db),
179
+ catch: toError,
180
+ });
181
+ const cacheAgeMs = cached
182
+ ? Date.now() - new Date(cached.updatedAt).getTime()
183
+ : Number.POSITIVE_INFINITY;
184
+ if (!refresh && cached && cacheAgeMs <= cacheTtlMs) {
185
+ const persisted = persistCached
186
+ ? yield* databaseWriteEffect((writeDb) =>
187
+ persistCached(writeDb, cached.value),
188
+ )
189
+ : undefined;
190
+ return {
191
+ source: "cache",
192
+ payload: cached.value,
193
+ persisted,
194
+ };
195
+ }
196
+
197
+ const fetched = yield* fetchWithTransportFallbackEffect(transports);
198
+ const persisted = yield* databaseWriteEffect((writeDb) => {
199
+ const value = persistLive(writeDb, fetched.payload, fetched.source);
200
+ writeSyncCache(cacheKey, fetched.payload, writeDb);
201
+ return value;
202
+ });
203
+ return {
204
+ source: fetched.source,
205
+ payload: fetched.payload,
206
+ persisted,
207
+ };
208
+ });
209
+ }