birdclaw 0.8.3 → 0.8.4

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 (284) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +10 -8
  3. package/bin/birdclaw.mjs +4 -66
  4. package/dist/cli/birdclaw.js +28165 -0
  5. package/dist/client/assets/AvatarChip-DLoqM5xD.js +1 -0
  6. package/dist/client/assets/ProfilePreview-BejAuYsJ.js +1 -0
  7. package/dist/client/assets/SavedTimelineView-BIS7HKpp.js +1 -0
  8. package/dist/client/assets/SmartTimestamp-BR0-9se2.js +1 -0
  9. package/dist/client/assets/TimelineRouteFrame-CyGPj0eM.js +1 -0
  10. package/dist/client/assets/blocks-7UDKQp8H.js +1 -0
  11. package/dist/client/assets/bookmarks-CbBkMvXk.js +1 -0
  12. package/dist/client/assets/chunk-C0phanGV.js +1 -0
  13. package/dist/client/assets/circle-check-C_Vq3ElD.js +1 -0
  14. package/dist/client/assets/client-stream-contracts-ys3XhWG4.js +5 -0
  15. package/dist/client/assets/createLucideIcon-JV8isFFR.js +1 -0
  16. package/dist/client/assets/data-sources-Cm2bpNvg.js +1 -0
  17. package/dist/client/assets/discuss-DTjaOugw.js +1 -0
  18. package/dist/client/assets/dms-CGd6Eogq.js +1 -0
  19. package/dist/client/assets/inbox-BHlGuOy2.js +1 -0
  20. package/dist/client/assets/index-CEIG7S6s.js +31 -0
  21. package/dist/client/assets/index-bpFrQ5yG.css +1 -0
  22. package/dist/client/assets/likes-cLrcAQem.js +1 -0
  23. package/dist/client/assets/links-CM9QO1LC.js +1 -0
  24. package/dist/client/assets/mapbox-BxTlqXt9.js +2 -0
  25. package/dist/client/assets/mapbox-gl-dgf_hX3a.js +3215 -0
  26. package/dist/client/assets/mentions-CxKCgzIf.js +1 -0
  27. package/dist/client/assets/network-map-B5a3whqj.js +2 -0
  28. package/dist/client/assets/profile-analyze-CRtNEwlb.js +1 -0
  29. package/dist/client/assets/profile-hydration-client-YEtJsNTv.js +1 -0
  30. package/dist/client/assets/profiles._handle-C82_HoKX.js +1 -0
  31. package/dist/client/assets/query-client-fYu2rzRC.js +17 -0
  32. package/dist/client/assets/rate-limits-ScaEwdVK.js +1 -0
  33. package/dist/client/assets/react-dom-DHl6Ug7F.js +1 -0
  34. package/dist/client/assets/react-nBylq1U5.js +1 -0
  35. package/dist/client/assets/repeat-2-xCwHM9hD.js +1 -0
  36. package/dist/client/assets/routes-q7ZaW4En.js +1 -0
  37. package/dist/client/assets/schemas-I9r6F6ya.js +64 -0
  38. package/dist/client/assets/search-Dq5kYsxA.js +1 -0
  39. package/dist/client/assets/sparkles-CQVlvHsZ.js +1 -0
  40. package/dist/client/assets/styles-B8s3tt3r.css +2 -0
  41. package/dist/client/assets/today-CeG1O7Py.js +1 -0
  42. package/dist/client/assets/triangle-alert-CBL7s2pf.js +1 -0
  43. package/dist/client/assets/useTimelineRouteData-1HsqgvUi.js +1 -0
  44. package/dist/client/assets/users-CHbKlqUr.js +1 -0
  45. package/dist/server/assets/AvatarChip-zUDAEmjL.js +105 -0
  46. package/dist/server/assets/FeedState-ntW0Q2GB.js +214 -0
  47. package/dist/server/assets/ProfileAnalysisStream-BFvhVdhn.js +198 -0
  48. package/dist/server/assets/ProfilePreview-C6gAor_j.js +752 -0
  49. package/dist/server/assets/SavedTimelineView-BG1jnBp1.js +64 -0
  50. package/dist/server/assets/SmartTimestamp-DfwE1NGf.js +45 -0
  51. package/dist/server/assets/SyncNowButton-BOn_IBXS.js +76 -0
  52. package/dist/server/assets/TimelineRouteFrame-CU7ilM7U.js +85 -0
  53. package/dist/server/assets/TweetRichText-DhCVv1-6.js +74 -0
  54. package/dist/server/assets/_tanstack-start-manifest_v-C-ZRnG_n.js +148 -0
  55. package/dist/server/assets/account-selection-CTdFGWRk.js +44 -0
  56. package/dist/server/assets/blocks-C1mgGTB3.js +299 -0
  57. package/dist/server/assets/blocks-DSgQghqP.js +17 -0
  58. package/dist/server/assets/bookmarks-BJN8tykY.js +14 -0
  59. package/dist/server/assets/client-stream-contracts-C2xbWVjm.js +769 -0
  60. package/dist/server/assets/data-sources-BnA1Q94p.js +180 -0
  61. package/dist/server/assets/discuss-CJ1dau1j.js +15 -0
  62. package/dist/server/assets/discuss-QTo08NCN.js +338 -0
  63. package/dist/server/assets/dms-BuLj4bp8.js +368 -0
  64. package/dist/server/assets/dms-DAyQrVM2.js +240 -0
  65. package/dist/server/assets/empty-plugin-adapters-DzrEkdBF.js +5 -0
  66. package/dist/server/assets/inbox-CkGOaW1q.js +214 -0
  67. package/dist/server/assets/inbox-DpWd1EzC.js +146 -0
  68. package/dist/server/assets/likes-DBOAtsJU.js +14 -0
  69. package/dist/server/assets/links-DAhSjynu.js +240 -0
  70. package/dist/server/assets/links-DmH5lxHO.js +468 -0
  71. package/dist/server/assets/mentions-BH2MzKtB.js +26 -0
  72. package/dist/server/assets/network-map-D3CzRcC4.js +224 -0
  73. package/dist/server/assets/network-map-D5DoLOwR.js +582 -0
  74. package/dist/server/assets/profile-analyze-BP65PED0.js +90 -0
  75. package/dist/server/assets/profile-analyze-C1NoM0tV.js +9 -0
  76. package/dist/server/assets/profile-hydration-client-GJ4kwJS8.js +133 -0
  77. package/dist/server/assets/profiles._handle-DjwlN_ac.js +184 -0
  78. package/dist/server/assets/profiles._handle-DsLNj2Ca.js +12 -0
  79. package/dist/server/assets/query-client-BdauHBgd.js +923 -0
  80. package/dist/server/assets/rate-limits-VAr2qzYD.js +262 -0
  81. package/dist/server/assets/route-search-BUlQ5lGM.js +117 -0
  82. package/dist/server/assets/router-DqkwSPiM.js +17026 -0
  83. package/dist/server/assets/routes-A9c9ZhwN.js +26 -0
  84. package/dist/server/assets/start-FaDOTmto.js +4 -0
  85. package/dist/server/assets/today-B5hVM57Z.js +17 -0
  86. package/dist/server/assets/today-fvGiZ7ud.js +303 -0
  87. package/dist/server/assets/ui-qbmcMSSX.js +137 -0
  88. package/{src/components/useDebouncedValue.ts → dist/server/assets/useDebouncedValue-DOnA6Xcy.js} +4 -4
  89. package/dist/server/assets/useTimelineRouteData-27cxynxa.js +1101 -0
  90. package/dist/server/server.js +5520 -0
  91. package/package.json +26 -34
  92. package/playwright.config.ts +0 -35
  93. package/scripts/browser-perf.mjs +0 -427
  94. package/scripts/build-docs-site.mjs +0 -967
  95. package/scripts/docs-site-assets.mjs +0 -311
  96. package/scripts/run-vitest.mjs +0 -21
  97. package/scripts/sanitize-node-options.mjs +0 -23
  98. package/scripts/start-test-server.mjs +0 -42
  99. package/src/cli/command-context.ts +0 -17
  100. package/src/cli/register-analysis.ts +0 -500
  101. package/src/cli/register-compose.ts +0 -40
  102. package/src/cli/register-graph.ts +0 -132
  103. package/src/cli/register-inbox.ts +0 -41
  104. package/src/cli/register-storage.ts +0 -106
  105. package/src/cli-moderation.ts +0 -210
  106. package/src/cli.ts +0 -1616
  107. package/src/components/AccountSwitcher.tsx +0 -178
  108. package/src/components/AppNav.tsx +0 -118
  109. package/src/components/AvatarChip.tsx +0 -58
  110. package/src/components/AvatarPreload.ts +0 -149
  111. package/src/components/BrandMark.tsx +0 -67
  112. package/src/components/ConversationThread.tsx +0 -131
  113. package/src/components/DmWorkspace.tsx +0 -286
  114. package/src/components/EmbeddedTweetCard.tsx +0 -55
  115. package/src/components/FeedState.tsx +0 -147
  116. package/src/components/FloatingPreview.tsx +0 -382
  117. package/src/components/InboxCard.tsx +0 -152
  118. package/src/components/LinkPreviewCard.tsx +0 -292
  119. package/src/components/MarkdownCitations.tsx +0 -680
  120. package/src/components/MarkdownViewer.tsx +0 -111
  121. package/src/components/ProfileAnalysisClient.ts +0 -191
  122. package/src/components/ProfileAnalysisStream.tsx +0 -259
  123. package/src/components/ProfilePreview.tsx +0 -131
  124. package/src/components/SavedTimelineView.tsx +0 -155
  125. package/src/components/SmartTimestamp.tsx +0 -72
  126. package/src/components/SyncNowButton.tsx +0 -140
  127. package/src/components/ThemeSlider.tsx +0 -80
  128. package/src/components/TimelineCard.tsx +0 -533
  129. package/src/components/TimelineRouteFrame.tsx +0 -186
  130. package/src/components/TweetArticleCard.tsx +0 -66
  131. package/src/components/TweetMediaGrid.tsx +0 -185
  132. package/src/components/TweetRichText.tsx +0 -159
  133. package/src/components/account-selection.ts +0 -64
  134. package/src/components/links-controller.ts +0 -162
  135. package/src/components/links-model.ts +0 -198
  136. package/src/components/network-map-controller.ts +0 -84
  137. package/src/components/network-map-model.ts +0 -255
  138. package/src/components/useTimelineRouteData.ts +0 -193
  139. package/src/lib/account-sync-job.ts +0 -666
  140. package/src/lib/actions-transport.ts +0 -300
  141. package/src/lib/analysis-runtime.ts +0 -238
  142. package/src/lib/api-client.ts +0 -220
  143. package/src/lib/api-contracts.ts +0 -328
  144. package/src/lib/archive-finder.ts +0 -185
  145. package/src/lib/archive-import-plan.ts +0 -102
  146. package/src/lib/archive-import.ts +0 -2695
  147. package/src/lib/authored-live.ts +0 -1027
  148. package/src/lib/avatar-cache.ts +0 -349
  149. package/src/lib/backup.ts +0 -2652
  150. package/src/lib/bird-actions.ts +0 -294
  151. package/src/lib/bird-command.ts +0 -130
  152. package/src/lib/bird.ts +0 -1140
  153. package/src/lib/blocklist.ts +0 -136
  154. package/src/lib/blocks-write.ts +0 -171
  155. package/src/lib/blocks.ts +0 -387
  156. package/src/lib/bookmark-sync-job.ts +0 -536
  157. package/src/lib/config.ts +0 -224
  158. package/src/lib/conversation-surface.ts +0 -205
  159. package/src/lib/data-sources.ts +0 -219
  160. package/src/lib/database-metrics.ts +0 -88
  161. package/src/lib/database-migrations.ts +0 -34
  162. package/src/lib/database-schema.ts +0 -312
  163. package/src/lib/database-writer.ts +0 -69
  164. package/src/lib/db.ts +0 -889
  165. package/src/lib/dm-read-model.ts +0 -533
  166. package/src/lib/dms-live.ts +0 -870
  167. package/src/lib/effect-runtime.ts +0 -45
  168. package/src/lib/follow-graph.ts +0 -1087
  169. package/src/lib/geocoding.ts +0 -296
  170. package/src/lib/http-effect.ts +0 -222
  171. package/src/lib/identity-search-index.ts +0 -369
  172. package/src/lib/import-repository.ts +0 -138
  173. package/src/lib/inbox.ts +0 -242
  174. package/src/lib/link-index.ts +0 -641
  175. package/src/lib/link-insights.ts +0 -860
  176. package/src/lib/link-preview-metadata.ts +0 -754
  177. package/src/lib/live-sync-engine.ts +0 -209
  178. package/src/lib/live-transport-gateway.ts +0 -128
  179. package/src/lib/location.ts +0 -137
  180. package/src/lib/media-fetch.ts +0 -860
  181. package/src/lib/media-includes.ts +0 -165
  182. package/src/lib/mention-threads-live.ts +0 -830
  183. package/src/lib/mentions-export.ts +0 -147
  184. package/src/lib/mentions-live.ts +0 -1114
  185. package/src/lib/moderation-target.ts +0 -227
  186. package/src/lib/moderation-write.ts +0 -131
  187. package/src/lib/mutes-write.ts +0 -171
  188. package/src/lib/mutes.ts +0 -84
  189. package/src/lib/ndjson-stream.ts +0 -106
  190. package/src/lib/network-map.ts +0 -382
  191. package/src/lib/openai-response-runtime.ts +0 -251
  192. package/src/lib/openai.ts +0 -117
  193. package/src/lib/paginated-sync.ts +0 -93
  194. package/src/lib/period-digest.ts +0 -1364
  195. package/src/lib/present.ts +0 -118
  196. package/src/lib/profile-affiliation-hydration.ts +0 -228
  197. package/src/lib/profile-affiliations.ts +0 -262
  198. package/src/lib/profile-analysis.ts +0 -1198
  199. package/src/lib/profile-bio-entities.ts +0 -318
  200. package/src/lib/profile-history.ts +0 -164
  201. package/src/lib/profile-hydration.ts +0 -180
  202. package/src/lib/profile-replies.ts +0 -77
  203. package/src/lib/profile-resolver.ts +0 -650
  204. package/src/lib/queries.ts +0 -6
  205. package/src/lib/query-actions.ts +0 -437
  206. package/src/lib/query-client.tsx +0 -47
  207. package/src/lib/query-read-model-shared.ts +0 -52
  208. package/src/lib/query-read-models.ts +0 -5
  209. package/src/lib/query-resource.ts +0 -41
  210. package/src/lib/query-status.ts +0 -164
  211. package/src/lib/research.ts +0 -652
  212. package/src/lib/runtime-services.ts +0 -20
  213. package/src/lib/search-discussion.ts +0 -812
  214. package/src/lib/seed.ts +0 -613
  215. package/src/lib/server-runtime-services.ts +0 -30
  216. package/src/lib/sqlite.ts +0 -183
  217. package/src/lib/streaming-ingestion.ts +0 -240
  218. package/src/lib/sync-cache.ts +0 -70
  219. package/src/lib/sync-plan.ts +0 -175
  220. package/src/lib/theme-transition.ts +0 -113
  221. package/src/lib/theme.tsx +0 -165
  222. package/src/lib/timeline-collections-live.ts +0 -407
  223. package/src/lib/timeline-live.ts +0 -302
  224. package/src/lib/timeline-read-model.ts +0 -1191
  225. package/src/lib/tweet-account-edges.ts +0 -48
  226. package/src/lib/tweet-lookup.ts +0 -46
  227. package/src/lib/tweet-render.ts +0 -430
  228. package/src/lib/tweet-repository.ts +0 -156
  229. package/src/lib/tweet-search-live.ts +0 -462
  230. package/src/lib/types.ts +0 -723
  231. package/src/lib/ui.ts +0 -529
  232. package/src/lib/url-expansion-store.ts +0 -110
  233. package/src/lib/url-expansion.ts +0 -343
  234. package/src/lib/url-safety.ts +0 -220
  235. package/src/lib/web-sync.ts +0 -533
  236. package/src/lib/whois.ts +0 -1080
  237. package/src/lib/x-profile.ts +0 -365
  238. package/src/lib/x-web.ts +0 -199
  239. package/src/lib/xurl-rate-limits.ts +0 -267
  240. package/src/lib/xurl.ts +0 -1677
  241. package/src/routeTree.gen.ts +0 -723
  242. package/src/router.tsx +0 -20
  243. package/src/routes/__root.tsx +0 -82
  244. package/src/routes/api/action.tsx +0 -147
  245. package/src/routes/api/avatar.tsx +0 -50
  246. package/src/routes/api/blocks.tsx +0 -35
  247. package/src/routes/api/conversation.tsx +0 -45
  248. package/src/routes/api/data-sources.tsx +0 -24
  249. package/src/routes/api/inbox.tsx +0 -43
  250. package/src/routes/api/link-insights.tsx +0 -85
  251. package/src/routes/api/link-preview.tsx +0 -47
  252. package/src/routes/api/network-map.tsx +0 -55
  253. package/src/routes/api/period-digest.tsx +0 -101
  254. package/src/routes/api/profile-analysis.tsx +0 -97
  255. package/src/routes/api/profile-hydrate.tsx +0 -54
  256. package/src/routes/api/query.tsx +0 -121
  257. package/src/routes/api/search-discussion.tsx +0 -113
  258. package/src/routes/api/status.tsx +0 -30
  259. package/src/routes/api/sync.tsx +0 -108
  260. package/src/routes/api/xurl-rate-limits.tsx +0 -24
  261. package/src/routes/blocks.tsx +0 -343
  262. package/src/routes/bookmarks.tsx +0 -18
  263. package/src/routes/data-sources.tsx +0 -249
  264. package/src/routes/discuss.tsx +0 -419
  265. package/src/routes/dms.tsx +0 -415
  266. package/src/routes/inbox.tsx +0 -232
  267. package/src/routes/index.tsx +0 -32
  268. package/src/routes/likes.tsx +0 -18
  269. package/src/routes/links.tsx +0 -631
  270. package/src/routes/mentions.tsx +0 -32
  271. package/src/routes/network-map.tsx +0 -732
  272. package/src/routes/profile-analyze.tsx +0 -112
  273. package/src/routes/profiles.$handle.tsx +0 -228
  274. package/src/routes/rate-limits.tsx +0 -305
  275. package/src/routes/today.tsx +0 -511
  276. package/src/styles.css +0 -257
  277. package/tsconfig.json +0 -29
  278. package/vite.config.ts +0 -29
  279. /package/{public → dist/client}/birdclaw-mark.png +0 -0
  280. /package/{public → dist/client}/favicon.ico +0 -0
  281. /package/{public → dist/client}/logo192.png +0 -0
  282. /package/{public → dist/client}/logo512.png +0 -0
  283. /package/{public → dist/client}/manifest.json +0 -0
  284. /package/{public → dist/client}/robots.txt +0 -0
package/src/lib/backup.ts DELETED
@@ -1,2652 +0,0 @@
1
- import { execFile } from "node:child_process";
2
- import { createHash } from "node:crypto";
3
- import { existsSync } from "node:fs";
4
- import fs from "node:fs/promises";
5
- import path from "node:path";
6
- import { promisify } from "node:util";
7
- import { Data, Effect } from "effect";
8
- import type { Database } from "./sqlite";
9
- import { getBirdclawConfig } from "./config";
10
- import { getNativeDb } from "./db";
11
- import { databaseWriteEffect } from "./database-writer";
12
- import { runEffectPromise, tryPromise } from "./effect-runtime";
13
- import { getImportRepository } from "./import-repository";
14
- import {
15
- collectIngestionSourcesEffect,
16
- streamJsonLines,
17
- } from "./streaming-ingestion";
18
- import { safeHttpUrl } from "./url-safety";
19
-
20
- const execFileAsync = promisify(execFile);
21
- const BACKUP_SCHEMA_VERSION = 1;
22
- const MANIFEST_PATH = "manifest.json";
23
- const DATA_DIR = "data";
24
- const AUTO_SYNC_CACHE_KEY = "backup:auto-sync";
25
- const DEFAULT_STALE_AFTER_SECONDS = 15 * 60;
26
- let autoUpdateInFlight: Promise<BackupAutoUpdateResult> | null = null;
27
-
28
- type JsonValue =
29
- | null
30
- | boolean
31
- | number
32
- | string
33
- | JsonValue[]
34
- | { [key: string]: JsonValue };
35
-
36
- type JsonRecord = Record<string, JsonValue>;
37
-
38
- export interface BackupFileManifest {
39
- path: string;
40
- rows: number;
41
- sha256: string;
42
- bytes: number;
43
- }
44
-
45
- export interface BackupManifest {
46
- app: "birdclaw";
47
- schemaVersion: number;
48
- generatedAt: string;
49
- counts: Record<string, number>;
50
- files: BackupFileManifest[];
51
- backupHash: string;
52
- }
53
-
54
- export interface BackupExportResult {
55
- ok: true;
56
- repoPath: string;
57
- manifest: BackupManifest;
58
- validation: BackupValidationResult;
59
- git?: {
60
- committed: boolean;
61
- pushed: boolean;
62
- commit?: string;
63
- };
64
- }
65
-
66
- export interface BackupImportResult {
67
- ok: true;
68
- repoPath: string;
69
- mode: BackupImportMode;
70
- manifest: BackupManifest;
71
- validation?: BackupValidationResult;
72
- fingerprint: BackupDatabaseFingerprint;
73
- }
74
-
75
- export interface BackupSyncResult {
76
- ok: true;
77
- repoPath: string;
78
- remote?: string;
79
- pulled: boolean;
80
- imported: boolean;
81
- importResult?: BackupImportResult;
82
- exportResult: BackupExportResult;
83
- }
84
-
85
- export interface BackupAutoUpdateResult {
86
- ok: boolean;
87
- enabled: boolean;
88
- skipped: boolean;
89
- reason?: string;
90
- repoPath?: string;
91
- remote?: string;
92
- pulled?: boolean;
93
- imported?: boolean;
94
- error?: string;
95
- }
96
-
97
- export interface BackupValidationResult {
98
- ok: boolean;
99
- repoPath: string;
100
- files: BackupFileManifest[];
101
- counts: Record<string, number>;
102
- backupHash: string;
103
- errors: string[];
104
- }
105
-
106
- export interface BackupDatabaseFingerprint {
107
- counts: Record<string, number>;
108
- hash: string;
109
- }
110
-
111
- export type BackupImportMode = "merge" | "replace";
112
-
113
- export interface BackupImportOptions {
114
- repoPath: string;
115
- db?: Database;
116
- validate?: boolean;
117
- mode?: BackupImportMode;
118
- }
119
-
120
- export class BackupGitCommandError extends Data.TaggedError(
121
- "BackupGitCommandError",
122
- )<{
123
- readonly message: string;
124
- readonly args: readonly string[];
125
- readonly stdout?: string;
126
- readonly stderr?: string;
127
- readonly cause?: unknown;
128
- }> {}
129
-
130
- function toError(error: unknown) {
131
- return error instanceof Error ? error : new Error(String(error));
132
- }
133
-
134
- function trySync<T>(try_: () => T) {
135
- return Effect.try({
136
- try: try_,
137
- catch: toError,
138
- });
139
- }
140
-
141
- function getErrorOutput(error: unknown, key: "stdout" | "stderr") {
142
- if (!error || typeof error !== "object" || !(key in error)) {
143
- return undefined;
144
- }
145
- const output = (error as Record<"stdout" | "stderr", unknown>)[key];
146
- return typeof output === "string" ? output : undefined;
147
- }
148
-
149
- function redactSecretUrl(value: string) {
150
- return value.replace(
151
- /([a-z][a-z0-9+.-]*:\/\/)([^/@:\s]+)(?::([^/@\s]+))?@/gi,
152
- (_match, protocol: string, username: string, password?: string) =>
153
- `${protocol}${username ? "REDACTED" : ""}${password ? ":REDACTED" : ""}@`,
154
- );
155
- }
156
-
157
- function gitCommandError(args: readonly string[], cause: unknown) {
158
- const redactedArgs = args.map((arg) => redactSecretUrl(arg));
159
- const command = `git ${redactedArgs.join(" ")}`;
160
- const message = redactSecretUrl(
161
- cause instanceof Error ? cause.message : `${command} failed`,
162
- );
163
- return new BackupGitCommandError({
164
- message,
165
- args: redactedArgs,
166
- stdout: redactSecretUrl(getErrorOutput(cause, "stdout") ?? ""),
167
- stderr: redactSecretUrl(getErrorOutput(cause, "stderr") ?? ""),
168
- cause,
169
- });
170
- }
171
-
172
- function gitEffect(args: string[]) {
173
- return Effect.tryPromise({
174
- try: () => execFileAsync("git", args),
175
- catch: (cause) => gitCommandError(args, cause),
176
- });
177
- }
178
-
179
- function canonicalStringify(value: JsonValue): string {
180
- if (value === null || typeof value !== "object") {
181
- return JSON.stringify(value);
182
- }
183
- if (Array.isArray(value)) {
184
- return `[${value.map((item) => canonicalStringify(item)).join(",")}]`;
185
- }
186
- const keys = Object.keys(value).sort();
187
- return `{${keys
188
- .map((key) => `${JSON.stringify(key)}:${canonicalStringify(value[key])}`)
189
- .join(",")}}`;
190
- }
191
-
192
- function toJsonRecord(row: Record<string, unknown>): JsonRecord {
193
- const result: JsonRecord = {};
194
- for (const [key, value] of Object.entries(row)) {
195
- if (
196
- value === null ||
197
- typeof value === "string" ||
198
- typeof value === "number" ||
199
- typeof value === "boolean"
200
- ) {
201
- result[key] = value;
202
- continue;
203
- }
204
- result[key] = JSON.parse(JSON.stringify(value)) as JsonValue;
205
- }
206
- return result;
207
- }
208
-
209
- function sha256(content: string | Buffer) {
210
- return createHash("sha256").update(content).digest("hex");
211
- }
212
-
213
- const jsonlKeyOrderCache = new Map<string, string[]>();
214
-
215
- function jsonlStringify(row: JsonRecord): string {
216
- const keys = Object.keys(row);
217
- const signature = keys.join("\0");
218
- let sortedKeys = jsonlKeyOrderCache.get(signature);
219
- if (!sortedKeys) {
220
- sortedKeys = [...keys].sort();
221
- jsonlKeyOrderCache.set(signature, sortedKeys);
222
- }
223
- return `{${sortedKeys
224
- .map((key) => `${JSON.stringify(key)}:${JSON.stringify(row[key])}`)
225
- .join(",")}}`;
226
- }
227
-
228
- function yearFromTimestamp(value: unknown) {
229
- if (typeof value !== "string") {
230
- return "unknown";
231
- }
232
- const match = /^(\d{4})/.exec(value);
233
- if (!match || match[1] === "1970") {
234
- return "unknown";
235
- }
236
- return match[1];
237
- }
238
-
239
- function rowsForQuery(db: Database, sql: string, params: unknown[] = []) {
240
- return (db.prepare(sql).all(...params) as Record<string, unknown>[]).map(
241
- toJsonRecord,
242
- );
243
- }
244
-
245
- function getExportRowSets(db: Database) {
246
- const rowSets: Array<{ logicalName: string; rows: JsonRecord[] }> = [
247
- {
248
- logicalName: "accounts",
249
- rows: rowsForQuery(
250
- db,
251
- `
252
- select id, name, handle, external_user_id, transport, is_default, created_at
253
- from accounts
254
- order by id
255
- `,
256
- ),
257
- },
258
- {
259
- logicalName: "profiles",
260
- rows: rowsForQuery(
261
- db,
262
- `
263
- select id, handle, display_name, bio, followers_count,
264
- following_count, public_metrics_json, avatar_hue, avatar_url,
265
- location, url, verified_type, entities_json, raw_json, created_at
266
- from profiles
267
- order by id
268
- `,
269
- ),
270
- },
271
- {
272
- logicalName: "profile_affiliations",
273
- rows: rowsForQuery(
274
- db,
275
- `
276
- select subject_profile_id, organization_profile_id, organization_name,
277
- organization_handle, badge_url, url, label, source, is_active,
278
- first_seen_at, last_seen_at, raw_json, updated_at
279
- from profile_affiliations
280
- order by subject_profile_id, organization_profile_id
281
- `,
282
- ),
283
- },
284
- {
285
- logicalName: "profile_snapshots",
286
- rows: rowsForQuery(
287
- db,
288
- `
289
- select profile_id, snapshot_hash, observed_at, last_seen_at, source,
290
- handle, display_name, bio, location, url, verified_type,
291
- followers_count, following_count, affiliations_json, raw_json
292
- from profile_snapshots
293
- order by profile_id, last_seen_at, snapshot_hash
294
- `,
295
- ),
296
- },
297
- {
298
- logicalName: "profile_bio_entities",
299
- rows: rowsForQuery(
300
- db,
301
- `
302
- select profile_id, kind, value, source, is_active, first_seen_at,
303
- last_seen_at, raw_json
304
- from profile_bio_entities
305
- order by profile_id, kind, value
306
- `,
307
- ),
308
- },
309
- {
310
- logicalName: "tweets",
311
- rows: rowsForQuery(
312
- db,
313
- `
314
- select id, account_id, author_profile_id, kind, text, created_at, is_replied,
315
- reply_to_id, like_count, media_count, bookmarked, liked, entities_json,
316
- media_json, quoted_tweet_id
317
- from tweets
318
- order by created_at, id
319
- `,
320
- ),
321
- },
322
- {
323
- logicalName: "tweet_collections",
324
- rows: rowsForQuery(
325
- db,
326
- `
327
- select account_id, tweet_id, kind, collected_at, source, raw_json, updated_at
328
- from tweet_collections
329
- order by kind, account_id, coalesce(collected_at, ''), tweet_id
330
- `,
331
- ),
332
- },
333
- {
334
- logicalName: "tweet_account_edges",
335
- rows: rowsForQuery(
336
- db,
337
- `
338
- select account_id, tweet_id, kind, first_seen_at, last_seen_at,
339
- seen_count, source, raw_json, updated_at
340
- from tweet_account_edges
341
- order by kind, account_id, last_seen_at, tweet_id
342
- `,
343
- ),
344
- },
345
- {
346
- logicalName: "dm_conversations",
347
- rows: rowsForQuery(
348
- db,
349
- `
350
- select id, account_id, participant_profile_id, title, inbox_kind,
351
- last_message_at, unread_count, needs_reply
352
- from dm_conversations
353
- order by last_message_at, id
354
- `,
355
- ),
356
- },
357
- {
358
- logicalName: "dm_messages",
359
- rows: rowsForQuery(
360
- db,
361
- `
362
- select id, conversation_id, sender_profile_id, text, created_at, direction,
363
- is_replied, media_count
364
- from dm_messages
365
- order by conversation_id, created_at, id
366
- `,
367
- ),
368
- },
369
- {
370
- logicalName: "url_expansions",
371
- rows: rowsForQuery(
372
- db,
373
- `
374
- select short_url, expanded_url, final_url, status, expanded_tweet_id,
375
- expanded_handle, title, description, image_url, site_name, error,
376
- source, updated_at
377
- from url_expansions
378
- order by short_url
379
- `,
380
- ),
381
- },
382
- {
383
- logicalName: "link_occurrences",
384
- rows: rowsForQuery(
385
- db,
386
- `
387
- select source_kind, source_id, source_position, short_url, account_id,
388
- conversation_id, direction, created_at
389
- from link_occurrences
390
- order by source_kind, source_id, source_position, short_url
391
- `,
392
- ),
393
- },
394
- {
395
- logicalName: "blocks",
396
- rows: rowsForQuery(
397
- db,
398
- `
399
- select account_id, profile_id, source, created_at
400
- from blocks
401
- order by account_id, profile_id
402
- `,
403
- ),
404
- },
405
- {
406
- logicalName: "mutes",
407
- rows: rowsForQuery(
408
- db,
409
- `
410
- select account_id, profile_id, source, created_at
411
- from mutes
412
- order by account_id, profile_id
413
- `,
414
- ),
415
- },
416
- {
417
- logicalName: "tweet_actions",
418
- rows: rowsForQuery(
419
- db,
420
- `
421
- select id, account_id, tweet_id, kind, body, created_at
422
- from tweet_actions
423
- order by created_at, id
424
- `,
425
- ),
426
- },
427
- {
428
- logicalName: "ai_scores",
429
- rows: rowsForQuery(
430
- db,
431
- `
432
- select entity_kind, entity_id, model, score, summary, reasoning, updated_at
433
- from ai_scores
434
- order by entity_kind, entity_id, model
435
- `,
436
- ),
437
- },
438
- {
439
- logicalName: "follow_snapshots",
440
- rows: rowsForQuery(
441
- db,
442
- `
443
- select id, account_id, direction, source, status, page_count,
444
- result_count, started_at, completed_at, raw_meta_json
445
- from follow_snapshots
446
- order by account_id, direction, completed_at, id
447
- `,
448
- ),
449
- },
450
- {
451
- logicalName: "follow_snapshot_members",
452
- rows: rowsForQuery(
453
- db,
454
- `
455
- select snapshot_id, profile_id, external_user_id, position
456
- from follow_snapshot_members
457
- order by snapshot_id, position, profile_id
458
- `,
459
- ),
460
- },
461
- {
462
- logicalName: "follow_edges",
463
- rows: rowsForQuery(
464
- db,
465
- `
466
- select account_id, direction, profile_id, external_user_id, source,
467
- current, first_seen_at, last_seen_at, ended_at, updated_at
468
- from follow_edges
469
- order by account_id, direction, profile_id
470
- `,
471
- ),
472
- },
473
- {
474
- logicalName: "follow_events",
475
- rows: rowsForQuery(
476
- db,
477
- `
478
- select id, account_id, direction, profile_id, external_user_id, kind,
479
- event_at, snapshot_id
480
- from follow_events
481
- order by account_id, direction, event_at, kind, profile_id, id
482
- `,
483
- ),
484
- },
485
- ];
486
- return rowSets;
487
- }
488
-
489
- function addRows(
490
- shards: Map<string, JsonRecord[]>,
491
- relativePath: string,
492
- rows: JsonRecord[],
493
- ) {
494
- if (rows.length === 0) {
495
- return;
496
- }
497
- const existing = shards.get(relativePath) ?? [];
498
- existing.push(...rows);
499
- shards.set(relativePath, existing);
500
- }
501
-
502
- function buildShards(db: Database) {
503
- const shards = new Map<string, JsonRecord[]>();
504
- const rowSets = getExportRowSets(db);
505
-
506
- for (const rowSet of rowSets) {
507
- switch (rowSet.logicalName) {
508
- case "accounts":
509
- addRows(shards, "data/accounts.jsonl", rowSet.rows);
510
- break;
511
- case "profiles":
512
- addRows(shards, "data/profiles.jsonl", rowSet.rows);
513
- break;
514
- case "profile_affiliations":
515
- addRows(shards, "data/profile_affiliations.jsonl", rowSet.rows);
516
- break;
517
- case "profile_snapshots":
518
- addRows(shards, "data/profile_snapshots.jsonl", rowSet.rows);
519
- break;
520
- case "profile_bio_entities":
521
- addRows(shards, "data/profile_bio_entities.jsonl", rowSet.rows);
522
- break;
523
- case "tweets":
524
- for (const row of rowSet.rows) {
525
- addRows(
526
- shards,
527
- `data/tweets/${yearFromTimestamp(row.created_at)}.jsonl`,
528
- [row],
529
- );
530
- }
531
- break;
532
- case "tweet_collections":
533
- for (const row of rowSet.rows) {
534
- const kind =
535
- row.kind === "likes" || row.kind === "bookmarks"
536
- ? row.kind
537
- : "unknown";
538
- addRows(shards, `data/collections/${kind}.jsonl`, [row]);
539
- }
540
- break;
541
- case "tweet_account_edges":
542
- for (const row of rowSet.rows) {
543
- const kind =
544
- row.kind === "home" ||
545
- row.kind === "mention" ||
546
- row.kind === "authored" ||
547
- row.kind === "search"
548
- ? row.kind
549
- : "unknown";
550
- addRows(shards, `data/timeline_edges/${kind}.jsonl`, [row]);
551
- }
552
- break;
553
- case "dm_conversations":
554
- addRows(shards, "data/dms/conversations.jsonl", rowSet.rows);
555
- break;
556
- case "dm_messages":
557
- for (const row of rowSet.rows) {
558
- addRows(
559
- shards,
560
- `data/dms/${yearFromTimestamp(row.created_at)}.jsonl`,
561
- [row],
562
- );
563
- }
564
- break;
565
- case "url_expansions":
566
- addRows(shards, "data/links/url_expansions.jsonl", rowSet.rows);
567
- break;
568
- case "link_occurrences":
569
- addRows(shards, "data/links/occurrences.jsonl", rowSet.rows);
570
- break;
571
- case "blocks":
572
- case "mutes":
573
- addRows(
574
- shards,
575
- `data/moderation/${rowSet.logicalName}.jsonl`,
576
- rowSet.rows,
577
- );
578
- break;
579
- case "tweet_actions":
580
- addRows(shards, "data/actions/tweet_actions.jsonl", rowSet.rows);
581
- break;
582
- case "ai_scores":
583
- addRows(shards, "data/ai_scores.jsonl", rowSet.rows);
584
- break;
585
- case "follow_snapshots":
586
- addRows(shards, "data/follow_snapshots.jsonl", rowSet.rows);
587
- break;
588
- case "follow_snapshot_members":
589
- addRows(shards, "data/follow_snapshot_members.jsonl", rowSet.rows);
590
- break;
591
- case "follow_edges":
592
- addRows(shards, "data/follow_edges.jsonl", rowSet.rows);
593
- break;
594
- case "follow_events":
595
- addRows(shards, "data/follow_events.jsonl", rowSet.rows);
596
- break;
597
- }
598
- }
599
-
600
- return shards;
601
- }
602
-
603
- function writeJsonlFileEffect(
604
- repoPath: string,
605
- relativePath: string,
606
- rows: JsonRecord[],
607
- ): Effect.Effect<BackupFileManifest, unknown> {
608
- return Effect.gen(function* () {
609
- const fullPath = yield* trySync(() =>
610
- resolveBackupFilePath(repoPath, relativePath),
611
- );
612
- const content = yield* trySync(
613
- () => `${rows.map((row) => jsonlStringify(row)).join("\n")}\n`,
614
- );
615
- yield* assertNoSymlinkAncestorEffect(repoPath, path.dirname(fullPath));
616
- yield* tryPromise(() =>
617
- fs.mkdir(path.dirname(fullPath), { recursive: true }),
618
- );
619
- yield* assertNoSymlinkAncestorEffect(repoPath, path.dirname(fullPath));
620
- yield* assertBackupPathInsideRealRootEffect(
621
- repoPath,
622
- path.dirname(fullPath),
623
- );
624
- const outputStat = yield* tryPromise(() => fs.lstat(fullPath)).pipe(
625
- Effect.option,
626
- );
627
- if (outputStat._tag === "Some" && !outputStat.value.isFile()) {
628
- return yield* Effect.fail(
629
- new Error(`Backup output path is not a regular file: ${relativePath}`),
630
- );
631
- }
632
- const current = yield* tryPromise(() => fs.readFile(fullPath, "utf8")).pipe(
633
- Effect.option,
634
- );
635
- if (current._tag === "None" || current.value !== content) {
636
- yield* tryPromise(() => fs.writeFile(fullPath, content, "utf8"));
637
- }
638
- return {
639
- path: relativePath,
640
- rows: rows.length,
641
- sha256: sha256(content),
642
- bytes: Buffer.byteLength(content),
643
- };
644
- });
645
- }
646
-
647
- function removeStaleBackupFilesEffect(
648
- repoPath: string,
649
- expectedPaths: Set<string>,
650
- directory = DATA_DIR,
651
- ): Effect.Effect<void, unknown> {
652
- return Effect.gen(function* () {
653
- const fullDirectory = yield* trySync(() =>
654
- resolveBackupFilePath(repoPath, directory),
655
- );
656
- const directoryStat = yield* tryPromise(() => fs.lstat(fullDirectory)).pipe(
657
- Effect.option,
658
- );
659
- if (directoryStat._tag === "None" || !directoryStat.value.isDirectory()) {
660
- return;
661
- }
662
- yield* assertBackupPathInsideRealRootEffect(repoPath, fullDirectory);
663
- const entries = yield* tryPromise(() =>
664
- fs.readdir(fullDirectory, { withFileTypes: true }),
665
- ).pipe(Effect.catchAll(() => Effect.succeed([])));
666
-
667
- yield* Effect.forEach(
668
- entries,
669
- (entry) =>
670
- Effect.gen(function* () {
671
- const relativePath = path.posix.join(directory, entry.name);
672
- const fullPath = yield* trySync(() =>
673
- resolveBackupFilePath(repoPath, relativePath),
674
- );
675
- if (entry.isDirectory()) {
676
- yield* removeStaleBackupFilesEffect(
677
- repoPath,
678
- expectedPaths,
679
- relativePath,
680
- );
681
- const remaining = yield* tryPromise(() => fs.readdir(fullPath));
682
- if (remaining.length === 0) {
683
- yield* tryPromise(() => fs.rmdir(fullPath));
684
- }
685
- return;
686
- }
687
- if (
688
- relativePath.endsWith(".jsonl") &&
689
- !expectedPaths.has(relativePath)
690
- ) {
691
- const stat = yield* tryPromise(() => fs.lstat(fullPath)).pipe(
692
- Effect.option,
693
- );
694
- if (stat._tag === "Some" && !stat.value.isFile()) return;
695
- yield* tryPromise(() => fs.rm(fullPath, { force: true }));
696
- }
697
- }),
698
- { concurrency: "unbounded" },
699
- );
700
- });
701
- }
702
-
703
- function computeBackupHash(files: BackupFileManifest[]) {
704
- const content = files
705
- .map((file) => `${file.path}\t${file.rows}\t${file.bytes}\t${file.sha256}`)
706
- .sort()
707
- .join("\n");
708
- return sha256(content);
709
- }
710
-
711
- function computeCounts(files: BackupFileManifest[]) {
712
- const counts: Record<string, number> = {};
713
- for (const file of files) {
714
- const [first, second, third] = file.path.split("/");
715
- if (first !== "data") {
716
- continue;
717
- }
718
- const key =
719
- second === "tweets"
720
- ? "tweets"
721
- : second === "collections"
722
- ? `collections_${third?.replace(/\.jsonl$/, "") ?? "unknown"}`
723
- : second === "timeline_edges"
724
- ? `timeline_edges_${third?.replace(/\.jsonl$/, "") ?? "unknown"}`
725
- : second === "dms" && third === "conversations.jsonl"
726
- ? "dm_conversations"
727
- : second === "dms"
728
- ? "dm_messages"
729
- : second === "links"
730
- ? third === "url_expansions.jsonl"
731
- ? "url_expansions"
732
- : third === "occurrences.jsonl"
733
- ? "link_occurrences"
734
- : "links"
735
- : second === "moderation"
736
- ? third?.replace(/\.jsonl$/, "") || "moderation"
737
- : second === "actions"
738
- ? third?.replace(/\.jsonl$/, "") || "actions"
739
- : second?.replace(/\.jsonl$/, "") || "unknown";
740
- counts[key] = (counts[key] ?? 0) + file.rows;
741
- }
742
- return counts;
743
- }
744
-
745
- function ensureBackupReadmeEffect(
746
- repoPath: string,
747
- ): Effect.Effect<void, unknown> {
748
- return Effect.gen(function* () {
749
- const readmePath = yield* trySync(() =>
750
- resolveBackupFilePath(repoPath, "README.md"),
751
- );
752
- yield* assertNoSymlinkAncestorEffect(repoPath, readmePath);
753
- if (yield* trySync(() => existsSync(readmePath))) {
754
- return;
755
- }
756
- yield* tryPromise(() =>
757
- fs.writeFile(
758
- readmePath,
759
- `# Birdclaw Store
760
-
761
- Private text backup for Birdclaw data. The committed files are canonical JSONL shards that can rebuild the local SQLite index.
762
-
763
- ## Layout
764
-
765
- \`\`\`text
766
- manifest.json
767
- data/accounts.jsonl
768
- data/profiles.jsonl
769
- data/profile_affiliations.jsonl
770
- data/profile_snapshots.jsonl
771
- data/profile_bio_entities.jsonl
772
- data/tweets/YYYY.jsonl
773
- data/tweets/unknown.jsonl
774
- data/timeline_edges/home.jsonl
775
- data/timeline_edges/mention.jsonl
776
- data/collections/likes.jsonl
777
- data/collections/bookmarks.jsonl
778
- data/dms/conversations.jsonl
779
- data/dms/YYYY.jsonl
780
- data/links/url_expansions.jsonl
781
- data/links/occurrences.jsonl
782
- data/moderation/blocks.jsonl
783
- data/moderation/mutes.jsonl
784
- data/follow_snapshots.jsonl
785
- data/follow_snapshot_members.jsonl
786
- data/follow_edges.jsonl
787
- data/follow_events.jsonl
788
- \`\`\`
789
-
790
- Tweets are sharded by creation year. Collection-only tweets whose creation date is unknown live in \`data/tweets/unknown.jsonl\`. Timeline edges keep account-scoped home/mention membership separate from canonical tweet content. DMs are sharded by year and keep \`conversation_id\` in each row.
791
- The links shard stores expanded short URLs and their source tweet/DM occurrences so linked-tweet search can be rebuilt without re-expanding every \`t.co\` URL.
792
-
793
- Never commit live tokens, browser cookies, raw SQLite WAL/SHM sidecars, or temporary cache files here.
794
- `,
795
- "utf8",
796
- ),
797
- );
798
- });
799
- }
800
-
801
- function writeManifestEffect(
802
- repoPath: string,
803
- manifest: BackupManifest,
804
- ): Effect.Effect<void, unknown> {
805
- return Effect.gen(function* () {
806
- const manifestPath = yield* trySync(() =>
807
- resolveBackupFilePath(repoPath, MANIFEST_PATH),
808
- );
809
- yield* assertNoSymlinkAncestorEffect(repoPath, manifestPath);
810
- const content = yield* trySync(
811
- () => `${canonicalStringify(manifest as unknown as JsonRecord)}\n`,
812
- );
813
- const current = yield* tryPromise(() =>
814
- fs.readFile(manifestPath, "utf8"),
815
- ).pipe(Effect.option);
816
- if (current._tag === "Some" && current.value === content) {
817
- return;
818
- }
819
- yield* tryPromise(() => fs.writeFile(manifestPath, content, "utf8"));
820
- });
821
- }
822
-
823
- function readPreviousManifestEffect(
824
- repoPath: string,
825
- ): Effect.Effect<BackupManifest | undefined, never> {
826
- return readManifestEffect(repoPath).pipe(
827
- Effect.catchAll(() => Effect.succeed(undefined)),
828
- );
829
- }
830
-
831
- function maybeCommitAndPushEffect({
832
- repoPath,
833
- message,
834
- commit,
835
- push,
836
- }: {
837
- repoPath: string;
838
- message: string;
839
- commit: boolean;
840
- push: boolean;
841
- }) {
842
- if (!commit && !push) {
843
- return Effect.succeed(undefined);
844
- }
845
-
846
- return Effect.gen(function* () {
847
- yield* gitEffect([
848
- "-C",
849
- repoPath,
850
- "rev-parse",
851
- "--is-inside-work-tree",
852
- ]).pipe(
853
- Effect.catchAll(() =>
854
- gitEffect(["-C", repoPath, "init"]).pipe(Effect.asVoid),
855
- ),
856
- );
857
-
858
- yield* gitEffect([
859
- "-C",
860
- repoPath,
861
- "add",
862
- "README.md",
863
- MANIFEST_PATH,
864
- DATA_DIR,
865
- ]);
866
-
867
- yield* gitEffect(["-C", repoPath, "config", "user.email"]).pipe(
868
- Effect.catchAll(() =>
869
- gitEffect([
870
- "-C",
871
- repoPath,
872
- "config",
873
- "user.email",
874
- "birdclaw@example.invalid",
875
- ]),
876
- ),
877
- );
878
- yield* gitEffect(["-C", repoPath, "config", "user.name"]).pipe(
879
- Effect.catchAll(() =>
880
- gitEffect(["-C", repoPath, "config", "user.name", "Birdclaw Backup"]),
881
- ),
882
- );
883
-
884
- const commitResult = yield* gitEffect([
885
- "-C",
886
- repoPath,
887
- "diff",
888
- "--cached",
889
- "--quiet",
890
- ]).pipe(
891
- Effect.as({ committed: false as const, commitHash: undefined }),
892
- Effect.catchAll(() =>
893
- Effect.gen(function* () {
894
- yield* gitEffect([
895
- "-C",
896
- repoPath,
897
- "-c",
898
- "commit.gpgsign=false",
899
- "commit",
900
- "-m",
901
- message,
902
- ]);
903
- const { stdout } = yield* gitEffect([
904
- "-C",
905
- repoPath,
906
- "rev-parse",
907
- "HEAD",
908
- ]);
909
- return {
910
- committed: true as const,
911
- commitHash: stdout.trim(),
912
- };
913
- }),
914
- ),
915
- );
916
-
917
- if (push) {
918
- yield* gitEffect(["-C", repoPath, "push"]).pipe(
919
- Effect.catchAll(() =>
920
- gitEffect(["-C", repoPath, "push", "-u", "origin", "HEAD:main"]),
921
- ),
922
- );
923
- }
924
-
925
- return {
926
- committed: commitResult.committed,
927
- pushed: push,
928
- commit: commitResult.commitHash,
929
- };
930
- });
931
- }
932
-
933
- function isGitRepoEffect(repoPath: string) {
934
- return gitEffect(["-C", repoPath, "rev-parse", "--is-inside-work-tree"]).pipe(
935
- Effect.as(true),
936
- Effect.catchAll(() => Effect.succeed(false)),
937
- );
938
- }
939
-
940
- function hasGitCommitsEffect(repoPath: string) {
941
- return gitEffect(["-C", repoPath, "rev-parse", "--verify", "HEAD"]).pipe(
942
- Effect.as(true),
943
- Effect.catchAll(() => Effect.succeed(false)),
944
- );
945
- }
946
-
947
- function ensureBackupGitRepoEffect({
948
- repoPath,
949
- remote,
950
- }: {
951
- repoPath: string;
952
- remote?: string;
953
- }) {
954
- return Effect.gen(function* () {
955
- if (!(yield* isGitRepoEffect(repoPath))) {
956
- if (remote && !existsSync(repoPath)) {
957
- yield* gitEffect(["clone", remote, repoPath]);
958
- } else {
959
- yield* tryPromise(() => fs.mkdir(repoPath, { recursive: true }));
960
- yield* gitEffect(["-C", repoPath, "init"]);
961
- }
962
- }
963
-
964
- if (remote) {
965
- const origin = yield* gitEffect([
966
- "-C",
967
- repoPath,
968
- "remote",
969
- "get-url",
970
- "origin",
971
- ]).pipe(
972
- Effect.map(({ stdout }) => ({ ok: true as const, stdout })),
973
- Effect.catchAll(() => Effect.succeed({ ok: false as const })),
974
- );
975
- if (origin.ok) {
976
- if (origin.stdout.trim() !== remote) {
977
- yield* gitEffect([
978
- "-C",
979
- repoPath,
980
- "remote",
981
- "set-url",
982
- "origin",
983
- remote,
984
- ]);
985
- }
986
- } else {
987
- yield* gitEffect(["-C", repoPath, "remote", "add", "origin", remote]);
988
- }
989
- }
990
-
991
- if (remote && !(yield* hasGitCommitsEffect(repoPath))) {
992
- const fetched = yield* gitEffect([
993
- "-C",
994
- repoPath,
995
- "fetch",
996
- "origin",
997
- "main",
998
- ]).pipe(
999
- Effect.flatMap(() =>
1000
- gitEffect(["-C", repoPath, "checkout", "-B", "main", "origin/main"]),
1001
- ),
1002
- Effect.as(true),
1003
- Effect.catchAll(() => Effect.succeed(false)),
1004
- );
1005
- if (fetched) return;
1006
- }
1007
-
1008
- if (!(yield* hasGitCommitsEffect(repoPath))) {
1009
- yield* gitEffect(["-C", repoPath, "checkout", "-B", "main"]);
1010
- }
1011
- });
1012
- }
1013
-
1014
- function pullBackupGitRepoEffect(repoPath: string) {
1015
- return Effect.gen(function* () {
1016
- if (
1017
- !(yield* isGitRepoEffect(repoPath)) ||
1018
- !(yield* hasGitCommitsEffect(repoPath))
1019
- ) {
1020
- return false;
1021
- }
1022
- return yield* gitEffect(["-C", repoPath, "pull", "--ff-only"]).pipe(
1023
- Effect.as(true),
1024
- Effect.catchAll(() =>
1025
- gitEffect(["-C", repoPath, "pull", "--ff-only", "origin", "main"]).pipe(
1026
- Effect.as(true),
1027
- Effect.catchAll(() => Effect.succeed(false)),
1028
- ),
1029
- ),
1030
- );
1031
- });
1032
- }
1033
-
1034
- export function exportBackupEffect({
1035
- repoPath,
1036
- db,
1037
- commit = false,
1038
- push = false,
1039
- message = "archive: update birdclaw backup",
1040
- validate = true,
1041
- }: {
1042
- repoPath: string;
1043
- db?: Database;
1044
- commit?: boolean;
1045
- push?: boolean;
1046
- message?: string;
1047
- validate?: boolean;
1048
- }): Effect.Effect<BackupExportResult, unknown> {
1049
- return Effect.gen(function* () {
1050
- const resolvedRepoPath = yield* trySync(() => path.resolve(repoPath));
1051
- const database =
1052
- db ?? (yield* trySync(() => getNativeDb({ seedDemoData: false })));
1053
- yield* tryPromise(() => fs.mkdir(resolvedRepoPath, { recursive: true }));
1054
- const repoStat = yield* tryPromise(() => fs.lstat(resolvedRepoPath));
1055
- if (!repoStat.isDirectory() || repoStat.isSymbolicLink()) {
1056
- return yield* Effect.fail(
1057
- new Error("Backup repository path must be a real directory"),
1058
- );
1059
- }
1060
- yield* ensureBackupReadmeEffect(resolvedRepoPath);
1061
-
1062
- const shards = yield* trySync(() => buildShards(database));
1063
- const shardEntries = yield* trySync(() =>
1064
- [...shards.entries()].sort(([left], [right]) =>
1065
- left.localeCompare(right),
1066
- ),
1067
- );
1068
- const expectedPaths = yield* trySync(
1069
- () => new Set(shardEntries.map(([relativePath]) => relativePath)),
1070
- );
1071
- const files = yield* Effect.forEach(
1072
- shardEntries,
1073
- ([relativePath, rows]) =>
1074
- writeJsonlFileEffect(resolvedRepoPath, relativePath, rows),
1075
- { concurrency: "unbounded" },
1076
- );
1077
- yield* removeStaleBackupFilesEffect(resolvedRepoPath, expectedPaths);
1078
-
1079
- const counts = yield* trySync(() => computeCounts(files));
1080
- const backupHash = yield* trySync(() => computeBackupHash(files));
1081
- const previousManifest =
1082
- yield* readPreviousManifestEffect(resolvedRepoPath);
1083
- const manifest: BackupManifest = {
1084
- app: "birdclaw",
1085
- schemaVersion: BACKUP_SCHEMA_VERSION,
1086
- generatedAt:
1087
- previousManifest?.backupHash === backupHash
1088
- ? previousManifest.generatedAt
1089
- : new Date().toISOString(),
1090
- counts,
1091
- files,
1092
- backupHash,
1093
- };
1094
- yield* writeManifestEffect(resolvedRepoPath, manifest);
1095
-
1096
- const validation = validate
1097
- ? yield* validateBackupEffect(resolvedRepoPath)
1098
- : {
1099
- ok: true,
1100
- repoPath: resolvedRepoPath,
1101
- files,
1102
- counts,
1103
- backupHash: manifest.backupHash,
1104
- errors: [],
1105
- };
1106
- if (!validation.ok) {
1107
- return yield* Effect.fail(
1108
- new Error(`Backup validation failed: ${validation.errors.join("; ")}`),
1109
- );
1110
- }
1111
-
1112
- const git = yield* maybeCommitAndPushEffect({
1113
- repoPath: resolvedRepoPath,
1114
- message,
1115
- commit,
1116
- push,
1117
- });
1118
-
1119
- return {
1120
- ok: true,
1121
- repoPath: resolvedRepoPath,
1122
- manifest,
1123
- validation,
1124
- ...(git ? { git } : {}),
1125
- };
1126
- });
1127
- }
1128
-
1129
- export function exportBackup(
1130
- options: Parameters<typeof exportBackupEffect>[0],
1131
- ): Promise<BackupExportResult> {
1132
- return runEffectPromise(exportBackupEffect(options));
1133
- }
1134
-
1135
- function readManifestEffect(
1136
- repoPath: string,
1137
- ): Effect.Effect<BackupManifest, unknown> {
1138
- return Effect.gen(function* () {
1139
- const manifestPath = yield* trySync(() =>
1140
- resolveBackupFilePath(repoPath, MANIFEST_PATH),
1141
- );
1142
- yield* assertReadableBackupFileEffect(
1143
- repoPath,
1144
- manifestPath,
1145
- MANIFEST_PATH,
1146
- );
1147
- const content = yield* tryPromise(() => fs.readFile(manifestPath, "utf8"));
1148
- const parsed = yield* trySync(() => JSON.parse(content) as BackupManifest);
1149
- if (parsed.app !== "birdclaw") {
1150
- return yield* Effect.fail(
1151
- new Error("Backup manifest is not a birdclaw backup"),
1152
- );
1153
- }
1154
- if (parsed.schemaVersion !== BACKUP_SCHEMA_VERSION) {
1155
- return yield* Effect.fail(
1156
- new Error(
1157
- `Unsupported backup schema version ${String(parsed.schemaVersion)}`,
1158
- ),
1159
- );
1160
- }
1161
- return parsed;
1162
- });
1163
- }
1164
-
1165
- function resolveBackupFilePath(repoPath: string, relativePath: string) {
1166
- if (path.isAbsolute(relativePath)) {
1167
- throw new Error(`Backup manifest path must be relative: ${relativePath}`);
1168
- }
1169
- const normalized = path.normalize(relativePath);
1170
- if (
1171
- normalized === "." ||
1172
- normalized.startsWith("..") ||
1173
- path.isAbsolute(normalized)
1174
- ) {
1175
- throw new Error(`Backup manifest path escapes repository: ${relativePath}`);
1176
- }
1177
- const root = path.resolve(repoPath);
1178
- const resolved = path.resolve(root, normalized);
1179
- const relative = path.relative(root, resolved);
1180
- if (relative.startsWith("..") || path.isAbsolute(relative)) {
1181
- throw new Error(`Backup manifest path escapes repository: ${relativePath}`);
1182
- }
1183
- return resolved;
1184
- }
1185
-
1186
- function isPathInsideRoot(root: string, candidate: string) {
1187
- const relative = path.relative(root, candidate);
1188
- return (
1189
- relative === "" ||
1190
- (!relative.startsWith("..") && !path.isAbsolute(relative))
1191
- );
1192
- }
1193
-
1194
- function assertBackupPathInsideRealRootEffect(
1195
- repoPath: string,
1196
- fullPath: string,
1197
- ): Effect.Effect<void, unknown> {
1198
- return Effect.gen(function* () {
1199
- const realRoot = yield* tryPromise(() => fs.realpath(repoPath));
1200
- const realPath = yield* tryPromise(() => fs.realpath(fullPath));
1201
- if (!isPathInsideRoot(realRoot, realPath)) {
1202
- return yield* Effect.fail(new Error("Backup path escapes repository"));
1203
- }
1204
- });
1205
- }
1206
-
1207
- function assertReadableBackupFileEffect(
1208
- repoPath: string,
1209
- fullPath: string,
1210
- label: string,
1211
- ) {
1212
- return Effect.gen(function* () {
1213
- yield* assertNoSymlinkAncestorEffect(repoPath, fullPath);
1214
- const stat = yield* tryPromise(() => fs.lstat(fullPath));
1215
- if (!stat.isFile()) {
1216
- return yield* Effect.fail(
1217
- new Error(`Backup path is not a regular file: ${label}`),
1218
- );
1219
- }
1220
- yield* assertBackupPathInsideRealRootEffect(repoPath, fullPath);
1221
- return stat;
1222
- });
1223
- }
1224
-
1225
- function assertNoSymlinkAncestorEffect(
1226
- repoPath: string,
1227
- fullPath: string,
1228
- ): Effect.Effect<void, unknown> {
1229
- return Effect.gen(function* () {
1230
- const root = path.resolve(repoPath);
1231
- const target = path.resolve(fullPath);
1232
- if (!isPathInsideRoot(root, target)) {
1233
- return yield* Effect.fail(new Error("Backup path escapes repository"));
1234
- }
1235
- const relative = path.relative(root, target);
1236
- let current = root;
1237
- for (const part of relative.split(path.sep).filter(Boolean)) {
1238
- current = path.join(current, part);
1239
- const stat = yield* tryPromise(() => fs.lstat(current)).pipe(
1240
- Effect.catchAll((error) =>
1241
- error &&
1242
- typeof error === "object" &&
1243
- "code" in error &&
1244
- error.code === "ENOENT"
1245
- ? Effect.succeed(null)
1246
- : Effect.fail(error),
1247
- ),
1248
- );
1249
- if (!stat) return;
1250
- if (stat.isSymbolicLink()) {
1251
- return yield* Effect.fail(
1252
- new Error(
1253
- `Backup path contains symlink: ${path.relative(root, current)}`,
1254
- ),
1255
- );
1256
- }
1257
- }
1258
- });
1259
- }
1260
-
1261
- function readJsonlFilesEffect(
1262
- repoPath: string,
1263
- relativePaths: string[],
1264
- ): Effect.Effect<JsonRecord[], unknown> {
1265
- return Effect.gen(function* () {
1266
- const sources = yield* Effect.forEach(
1267
- relativePaths,
1268
- (relativePath) =>
1269
- Effect.gen(function* () {
1270
- const filePath = yield* trySync(() =>
1271
- resolveBackupFilePath(repoPath, relativePath),
1272
- );
1273
- yield* assertReadableBackupFileEffect(
1274
- repoPath,
1275
- filePath,
1276
- relativePath,
1277
- );
1278
- return {
1279
- id: relativePath,
1280
- stream: async function* () {
1281
- for await (const row of streamJsonLines(filePath)) {
1282
- yield row.value as JsonRecord;
1283
- }
1284
- },
1285
- };
1286
- }),
1287
- { concurrency: "unbounded" },
1288
- );
1289
- return yield* collectIngestionSourcesEffect(sources);
1290
- });
1291
- }
1292
-
1293
- function readBackupImportRowsEffect(
1294
- resolvedRepoPath: string,
1295
- manifest: BackupManifest,
1296
- ): Effect.Effect<JsonRecord[][], unknown> {
1297
- const readRows = (predicate: (relativePath: string) => boolean) =>
1298
- readJsonlFilesEffect(
1299
- resolvedRepoPath,
1300
- rowsForManifestPath(manifest, predicate),
1301
- );
1302
-
1303
- return Effect.all(
1304
- [
1305
- readRows((file) => file === "data/accounts.jsonl"),
1306
- readRows((file) => file === "data/profiles.jsonl"),
1307
- readRows((file) => file === "data/profile_affiliations.jsonl"),
1308
- readRows((file) => file === "data/profile_snapshots.jsonl"),
1309
- readRows((file) => file === "data/profile_bio_entities.jsonl"),
1310
- readRows((file) => file.startsWith("data/tweets/")),
1311
- readRows((file) => file.startsWith("data/collections/")),
1312
- readRows((file) => file.startsWith("data/timeline_edges/")),
1313
- readRows((file) => file === "data/dms/conversations.jsonl"),
1314
- readRows(
1315
- (file) =>
1316
- file.startsWith("data/dms/") &&
1317
- file !== "data/dms/conversations.jsonl",
1318
- ),
1319
- readRows((file) => file === "data/moderation/blocks.jsonl"),
1320
- readRows((file) => file === "data/moderation/mutes.jsonl"),
1321
- readRows((file) => file === "data/actions/tweet_actions.jsonl"),
1322
- readRows((file) => file === "data/ai_scores.jsonl"),
1323
- readRows((file) => file === "data/links/url_expansions.jsonl"),
1324
- readRows((file) => file === "data/links/occurrences.jsonl"),
1325
- readRows((file) => file === "data/follow_snapshots.jsonl"),
1326
- readRows((file) => file === "data/follow_snapshot_members.jsonl"),
1327
- readRows((file) => file === "data/follow_edges.jsonl"),
1328
- readRows((file) => file === "data/follow_events.jsonl"),
1329
- ],
1330
- { concurrency: "unbounded" },
1331
- );
1332
- }
1333
-
1334
- function rowsForManifestPath(
1335
- manifest: BackupManifest,
1336
- predicate: (relativePath: string) => boolean,
1337
- ) {
1338
- return manifest.files
1339
- .map((file) => file.path)
1340
- .filter(predicate)
1341
- .sort();
1342
- }
1343
-
1344
- const JSON_URL_KEYS = new Set([
1345
- "url",
1346
- "expandedUrl",
1347
- "expanded_url",
1348
- "imageUrl",
1349
- "image_url",
1350
- "mediaUrl",
1351
- "media_url",
1352
- "media_url_https",
1353
- "thumbnailUrl",
1354
- "thumbnail_url",
1355
- "previewImageUrl",
1356
- "preview_image_url",
1357
- ]);
1358
-
1359
- function sanitizeJsonUrlValue(key: string, value: JsonValue): JsonValue {
1360
- if (!JSON_URL_KEYS.has(key)) return value;
1361
- if (typeof value !== "string" || value.length === 0) return value;
1362
- return safeHttpUrl(value) ?? "";
1363
- }
1364
-
1365
- function sanitizeJsonUrls(value: JsonValue, key = ""): JsonValue {
1366
- if (Array.isArray(value)) {
1367
- return value.map((item) => sanitizeJsonUrls(item));
1368
- }
1369
- if (value && typeof value === "object") {
1370
- return Object.fromEntries(
1371
- Object.entries(value).map(([entryKey, entryValue]) => [
1372
- entryKey,
1373
- sanitizeJsonUrls(entryValue, entryKey),
1374
- ]),
1375
- );
1376
- }
1377
- return sanitizeJsonUrlValue(key, value);
1378
- }
1379
-
1380
- function sanitizeJsonTextUrls(value: JsonValue, fallback: JsonValue) {
1381
- if (typeof value !== "string" || value.length === 0) return value;
1382
- try {
1383
- return JSON.stringify(sanitizeJsonUrls(JSON.parse(value) as JsonValue));
1384
- } catch {
1385
- return JSON.stringify(fallback);
1386
- }
1387
- }
1388
-
1389
- function sanitizeImportedTweets(rows: JsonRecord[]) {
1390
- return rows.map((row) => ({
1391
- ...row,
1392
- entities_json: sanitizeJsonTextUrls(row.entities_json, {}),
1393
- media_json: sanitizeJsonTextUrls(row.media_json, []),
1394
- }));
1395
- }
1396
-
1397
- function sanitizeImportedUrlExpansions(rows: JsonRecord[]) {
1398
- return rows.map((row) => {
1399
- const shortUrl =
1400
- typeof row.short_url === "string" ? safeHttpUrl(row.short_url) : null;
1401
- const expandedUrl =
1402
- typeof row.expanded_url === "string"
1403
- ? safeHttpUrl(row.expanded_url)
1404
- : null;
1405
- const finalUrl =
1406
- typeof row.final_url === "string" ? safeHttpUrl(row.final_url) : null;
1407
- const safe = Boolean(shortUrl || expandedUrl || finalUrl);
1408
- return {
1409
- ...row,
1410
- short_url: shortUrl ?? "",
1411
- expanded_url: expandedUrl ?? shortUrl ?? "",
1412
- final_url: finalUrl ?? expandedUrl ?? shortUrl ?? "",
1413
- status: safe ? row.status : "error",
1414
- error: safe ? row.error : "unsafe URL stripped from backup import",
1415
- image_url:
1416
- typeof row.image_url === "string"
1417
- ? (safeHttpUrl(row.image_url) ?? "")
1418
- : row.image_url,
1419
- };
1420
- });
1421
- }
1422
-
1423
- export function importBackupEffect({
1424
- repoPath,
1425
- db: providedDb,
1426
- validate = true,
1427
- mode = "merge",
1428
- }: BackupImportOptions): Effect.Effect<BackupImportResult, unknown> {
1429
- return Effect.gen(function* () {
1430
- const resolvedRepoPath = yield* trySync(() => path.resolve(repoPath));
1431
- const db =
1432
- providedDb ??
1433
- (yield* trySync(() => getNativeDb({ seedDemoData: false })));
1434
- const manifest = yield* readManifestEffect(resolvedRepoPath);
1435
- const validation = validate
1436
- ? yield* validateBackupEffect(resolvedRepoPath)
1437
- : undefined;
1438
- if (validation && !validation.ok) {
1439
- return yield* Effect.fail(
1440
- new Error(`Backup validation failed: ${validation.errors.join("; ")}`),
1441
- );
1442
- }
1443
-
1444
- const [
1445
- accounts,
1446
- profiles,
1447
- profileAffiliations,
1448
- profileSnapshots,
1449
- profileBioEntities,
1450
- tweets,
1451
- collections,
1452
- timelineEdges,
1453
- conversations,
1454
- messages,
1455
- blocks,
1456
- mutes,
1457
- actions,
1458
- scores,
1459
- urlExpansions,
1460
- linkOccurrences,
1461
- followSnapshots,
1462
- followSnapshotMembers,
1463
- followEdges,
1464
- followEvents,
1465
- ] = yield* readBackupImportRowsEffect(resolvedRepoPath, manifest);
1466
- const sanitizedTweets = yield* trySync(() =>
1467
- sanitizeImportedTweets(tweets),
1468
- );
1469
- const sanitizedUrlExpansions = yield* trySync(() =>
1470
- sanitizeImportedUrlExpansions(urlExpansions),
1471
- );
1472
- const fingerprint = yield* databaseWriteEffect((writeDb) => {
1473
- const repository = getImportRepository(writeDb);
1474
- if (mode === "replace") {
1475
- repository.clearBackupImport();
1476
- }
1477
- const tweetFtsIds =
1478
- mode === "replace"
1479
- ? new Set<string>()
1480
- : repository.readFtsIds({
1481
- table: "tweets_fts",
1482
- idColumn: "tweet_id",
1483
- });
1484
- const dmFtsIds =
1485
- mode === "replace"
1486
- ? new Set<string>()
1487
- : repository.readFtsIds({
1488
- table: "dm_fts",
1489
- idColumn: "message_id",
1490
- });
1491
- repository.insertRows(
1492
- `
1493
- insert into accounts (id, name, handle, external_user_id, transport, is_default, created_at)
1494
- values (?, ?, ?, ?, ?, ?, ?)
1495
- on conflict(id) do update set
1496
- name = coalesce(nullif(excluded.name, ''), accounts.name),
1497
- handle = coalesce(nullif(excluded.handle, ''), accounts.handle),
1498
- external_user_id = coalesce(excluded.external_user_id, accounts.external_user_id),
1499
- transport = coalesce(nullif(excluded.transport, ''), accounts.transport),
1500
- is_default = max(accounts.is_default, excluded.is_default),
1501
- created_at = min(accounts.created_at, excluded.created_at)
1502
- `,
1503
- accounts,
1504
- [
1505
- "id",
1506
- "name",
1507
- "handle",
1508
- "external_user_id",
1509
- "transport",
1510
- "is_default",
1511
- "created_at",
1512
- ],
1513
- );
1514
- repository.insertRows(
1515
- `
1516
- insert into profile_snapshots (
1517
- profile_id, snapshot_hash, observed_at, last_seen_at, source, handle,
1518
- display_name, bio, location, url, verified_type, followers_count,
1519
- following_count, affiliations_json, raw_json
1520
- ) values (?, ?, ?, ?, coalesce(?, 'backup'), ?, ?, ?, ?, ?, ?, coalesce(?, 0), coalesce(?, 0), coalesce(?, '[]'), coalesce(?, '{}'))
1521
- on conflict(profile_id, snapshot_hash) do update set
1522
- last_seen_at = max(profile_snapshots.last_seen_at, excluded.last_seen_at),
1523
- source = excluded.source,
1524
- raw_json = case
1525
- when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
1526
- else profile_snapshots.raw_json
1527
- end
1528
- `,
1529
- profileSnapshots,
1530
- [
1531
- "profile_id",
1532
- "snapshot_hash",
1533
- "observed_at",
1534
- "last_seen_at",
1535
- "source",
1536
- "handle",
1537
- "display_name",
1538
- "bio",
1539
- "location",
1540
- "url",
1541
- "verified_type",
1542
- "followers_count",
1543
- "following_count",
1544
- "affiliations_json",
1545
- "raw_json",
1546
- ],
1547
- );
1548
- repository.insertRows(
1549
- `
1550
- insert into profile_bio_entities (
1551
- profile_id, kind, value, source, is_active, first_seen_at, last_seen_at, raw_json
1552
- ) values (?, ?, ?, coalesce(?, 'backup'), coalesce(?, 1), ?, ?, coalesce(?, '{}'))
1553
- on conflict(profile_id, kind, value) do update set
1554
- source = excluded.source,
1555
- is_active = excluded.is_active,
1556
- last_seen_at = max(profile_bio_entities.last_seen_at, excluded.last_seen_at),
1557
- raw_json = case
1558
- when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
1559
- else profile_bio_entities.raw_json
1560
- end
1561
- `,
1562
- profileBioEntities,
1563
- [
1564
- "profile_id",
1565
- "kind",
1566
- "value",
1567
- "source",
1568
- "is_active",
1569
- "first_seen_at",
1570
- "last_seen_at",
1571
- "raw_json",
1572
- ],
1573
- );
1574
- repository.insertRows(
1575
- `
1576
- insert into profiles (
1577
- id, handle, display_name, bio, followers_count, following_count,
1578
- public_metrics_json, avatar_hue, avatar_url, location, url,
1579
- verified_type, entities_json, raw_json, created_at
1580
- ) values (?, ?, ?, ?, ?, coalesce(?, 0), coalesce(?, '{}'), ?, ?, ?, ?, ?, coalesce(?, '{}'), coalesce(?, '{}'), ?)
1581
- on conflict(id) do update set
1582
- handle = coalesce(nullif(excluded.handle, ''), profiles.handle),
1583
- display_name = coalesce(nullif(excluded.display_name, ''), profiles.display_name),
1584
- bio = coalesce(nullif(excluded.bio, ''), profiles.bio),
1585
- followers_count = max(profiles.followers_count, excluded.followers_count),
1586
- following_count = max(profiles.following_count, excluded.following_count),
1587
- public_metrics_json = case
1588
- when excluded.public_metrics_json not in ('', '{}', 'null') then excluded.public_metrics_json
1589
- else profiles.public_metrics_json
1590
- end,
1591
- avatar_hue = case when profiles.avatar_hue = 0 then excluded.avatar_hue else profiles.avatar_hue end,
1592
- avatar_url = coalesce(excluded.avatar_url, profiles.avatar_url),
1593
- location = coalesce(excluded.location, profiles.location),
1594
- url = coalesce(excluded.url, profiles.url),
1595
- verified_type = coalesce(excluded.verified_type, profiles.verified_type),
1596
- entities_json = case
1597
- when excluded.entities_json not in ('', '{}', 'null') then excluded.entities_json
1598
- else profiles.entities_json
1599
- end,
1600
- raw_json = case
1601
- when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
1602
- else profiles.raw_json
1603
- end,
1604
- created_at = min(profiles.created_at, excluded.created_at)
1605
- `,
1606
- profiles,
1607
- [
1608
- "id",
1609
- "handle",
1610
- "display_name",
1611
- "bio",
1612
- "followers_count",
1613
- "following_count",
1614
- "public_metrics_json",
1615
- "avatar_hue",
1616
- "avatar_url",
1617
- "location",
1618
- "url",
1619
- "verified_type",
1620
- "entities_json",
1621
- "raw_json",
1622
- "created_at",
1623
- ],
1624
- );
1625
- repository.insertRows(
1626
- `
1627
- insert into profile_affiliations (
1628
- subject_profile_id, organization_profile_id, organization_name,
1629
- organization_handle, badge_url, url, label, source, is_active,
1630
- first_seen_at, last_seen_at, raw_json, updated_at
1631
- ) values (?, ?, ?, ?, ?, ?, ?, coalesce(?, 'backup'), coalesce(?, 1), ?, ?, coalesce(?, '{}'), ?)
1632
- on conflict(subject_profile_id, organization_profile_id) do update set
1633
- organization_name = coalesce(excluded.organization_name, profile_affiliations.organization_name),
1634
- organization_handle = coalesce(excluded.organization_handle, profile_affiliations.organization_handle),
1635
- badge_url = coalesce(excluded.badge_url, profile_affiliations.badge_url),
1636
- url = coalesce(excluded.url, profile_affiliations.url),
1637
- label = coalesce(excluded.label, profile_affiliations.label),
1638
- source = excluded.source,
1639
- is_active = excluded.is_active,
1640
- last_seen_at = max(profile_affiliations.last_seen_at, excluded.last_seen_at),
1641
- raw_json = case
1642
- when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
1643
- else profile_affiliations.raw_json
1644
- end,
1645
- updated_at = excluded.updated_at
1646
- `,
1647
- profileAffiliations,
1648
- [
1649
- "subject_profile_id",
1650
- "organization_profile_id",
1651
- "organization_name",
1652
- "organization_handle",
1653
- "badge_url",
1654
- "url",
1655
- "label",
1656
- "source",
1657
- "is_active",
1658
- "first_seen_at",
1659
- "last_seen_at",
1660
- "raw_json",
1661
- "updated_at",
1662
- ],
1663
- );
1664
- repository.insertRows(
1665
- `
1666
- insert into follow_snapshots (
1667
- id, account_id, direction, source, status, page_count, result_count,
1668
- started_at, completed_at, raw_meta_json
1669
- ) values (?, ?, ?, coalesce(?, 'backup'), ?, coalesce(?, 0), coalesce(?, 0), ?, ?, coalesce(?, '{}'))
1670
- on conflict(id) do update set
1671
- account_id = coalesce(nullif(excluded.account_id, ''), follow_snapshots.account_id),
1672
- direction = coalesce(nullif(excluded.direction, ''), follow_snapshots.direction),
1673
- source = coalesce(nullif(excluded.source, ''), follow_snapshots.source),
1674
- status = coalesce(nullif(excluded.status, ''), follow_snapshots.status),
1675
- page_count = max(follow_snapshots.page_count, excluded.page_count),
1676
- result_count = max(follow_snapshots.result_count, excluded.result_count),
1677
- started_at = min(follow_snapshots.started_at, excluded.started_at),
1678
- completed_at = max(follow_snapshots.completed_at, excluded.completed_at),
1679
- raw_meta_json = case
1680
- when excluded.raw_meta_json not in ('', '{}', 'null') then excluded.raw_meta_json
1681
- else follow_snapshots.raw_meta_json
1682
- end
1683
- `,
1684
- followSnapshots,
1685
- [
1686
- "id",
1687
- "account_id",
1688
- "direction",
1689
- "source",
1690
- "status",
1691
- "page_count",
1692
- "result_count",
1693
- "started_at",
1694
- "completed_at",
1695
- "raw_meta_json",
1696
- ],
1697
- );
1698
- repository.insertRows(
1699
- `
1700
- insert into follow_snapshot_members (
1701
- snapshot_id, profile_id, external_user_id, position
1702
- ) values (?, ?, ?, coalesce(?, 0))
1703
- on conflict(snapshot_id, profile_id) do update set
1704
- external_user_id = coalesce(nullif(excluded.external_user_id, ''), follow_snapshot_members.external_user_id),
1705
- position = excluded.position
1706
- `,
1707
- followSnapshotMembers,
1708
- ["snapshot_id", "profile_id", "external_user_id", "position"],
1709
- );
1710
- repository.insertRows(
1711
- `
1712
- insert into follow_edges (
1713
- account_id, direction, profile_id, external_user_id, source, current,
1714
- first_seen_at, last_seen_at, ended_at, updated_at
1715
- ) values (?, ?, ?, ?, coalesce(?, 'backup'), coalesce(?, 1), ?, ?, ?, ?)
1716
- on conflict(account_id, direction, profile_id) do update set
1717
- external_user_id = coalesce(nullif(excluded.external_user_id, ''), follow_edges.external_user_id),
1718
- source = coalesce(nullif(excluded.source, ''), follow_edges.source),
1719
- current = case
1720
- when excluded.updated_at >= follow_edges.updated_at then excluded.current
1721
- else follow_edges.current
1722
- end,
1723
- first_seen_at = min(follow_edges.first_seen_at, excluded.first_seen_at),
1724
- last_seen_at = max(follow_edges.last_seen_at, excluded.last_seen_at),
1725
- ended_at = case
1726
- when excluded.updated_at >= follow_edges.updated_at then excluded.ended_at
1727
- else follow_edges.ended_at
1728
- end,
1729
- updated_at = max(follow_edges.updated_at, excluded.updated_at)
1730
- `,
1731
- followEdges,
1732
- [
1733
- "account_id",
1734
- "direction",
1735
- "profile_id",
1736
- "external_user_id",
1737
- "source",
1738
- "current",
1739
- "first_seen_at",
1740
- "last_seen_at",
1741
- "ended_at",
1742
- "updated_at",
1743
- ],
1744
- );
1745
- repository.insertRows(
1746
- `
1747
- insert into follow_events (
1748
- id, account_id, direction, profile_id, external_user_id, kind, event_at,
1749
- snapshot_id
1750
- ) values (?, ?, ?, ?, ?, ?, ?, ?)
1751
- on conflict(id) do update set
1752
- account_id = coalesce(nullif(excluded.account_id, ''), follow_events.account_id),
1753
- direction = coalesce(nullif(excluded.direction, ''), follow_events.direction),
1754
- profile_id = coalesce(nullif(excluded.profile_id, ''), follow_events.profile_id),
1755
- external_user_id = coalesce(nullif(excluded.external_user_id, ''), follow_events.external_user_id),
1756
- kind = coalesce(nullif(excluded.kind, ''), follow_events.kind),
1757
- event_at = coalesce(nullif(excluded.event_at, ''), follow_events.event_at),
1758
- snapshot_id = coalesce(nullif(excluded.snapshot_id, ''), follow_events.snapshot_id)
1759
- `,
1760
- followEvents,
1761
- [
1762
- "id",
1763
- "account_id",
1764
- "direction",
1765
- "profile_id",
1766
- "external_user_id",
1767
- "kind",
1768
- "event_at",
1769
- "snapshot_id",
1770
- ],
1771
- );
1772
- repository.insertRows(
1773
- `
1774
- insert into tweets (
1775
- id, account_id, author_profile_id, kind, text, created_at, is_replied,
1776
- reply_to_id, like_count, media_count, bookmarked, liked, entities_json,
1777
- media_json, quoted_tweet_id
1778
- ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1779
- on conflict(id) do update set
1780
- account_id = coalesce(nullif(excluded.account_id, ''), tweets.account_id),
1781
- author_profile_id = coalesce(nullif(excluded.author_profile_id, ''), tweets.author_profile_id),
1782
- kind = case
1783
- when tweets.kind in ('home', 'mention', 'authored', 'search') then tweets.kind
1784
- when excluded.kind in ('home', 'mention', 'authored', 'search') then excluded.kind
1785
- else coalesce(nullif(excluded.kind, ''), tweets.kind)
1786
- end,
1787
- text = coalesce(nullif(excluded.text, ''), tweets.text),
1788
- created_at = min(tweets.created_at, excluded.created_at),
1789
- is_replied = max(tweets.is_replied, excluded.is_replied),
1790
- reply_to_id = coalesce(excluded.reply_to_id, tweets.reply_to_id),
1791
- like_count = max(tweets.like_count, excluded.like_count),
1792
- media_count = max(tweets.media_count, excluded.media_count),
1793
- bookmarked = max(tweets.bookmarked, excluded.bookmarked),
1794
- liked = max(tweets.liked, excluded.liked),
1795
- entities_json = case
1796
- when excluded.entities_json not in ('', '{}', 'null') then excluded.entities_json
1797
- else tweets.entities_json
1798
- end,
1799
- media_json = case
1800
- when excluded.media_json not in ('', '[]', 'null') then excluded.media_json
1801
- else tweets.media_json
1802
- end,
1803
- quoted_tweet_id = coalesce(excluded.quoted_tweet_id, tweets.quoted_tweet_id)
1804
- `,
1805
- sanitizedTweets,
1806
- [
1807
- "id",
1808
- "account_id",
1809
- "author_profile_id",
1810
- "kind",
1811
- "text",
1812
- "created_at",
1813
- "is_replied",
1814
- "reply_to_id",
1815
- "like_count",
1816
- "media_count",
1817
- "bookmarked",
1818
- "liked",
1819
- "entities_json",
1820
- "media_json",
1821
- "quoted_tweet_id",
1822
- ],
1823
- );
1824
- repository.insertFtsRows({
1825
- target: { table: "tweets_fts", idColumn: "tweet_id" },
1826
- rows: sanitizedTweets,
1827
- idKey: "id",
1828
- textKey: "text",
1829
- existingIds: tweetFtsIds,
1830
- });
1831
- repository.insertRows(
1832
- `
1833
- insert into tweet_collections (
1834
- account_id, tweet_id, kind, collected_at, source, raw_json, updated_at
1835
- ) values (?, ?, ?, ?, ?, ?, ?)
1836
- on conflict(account_id, tweet_id, kind) do update set
1837
- collected_at = coalesce(tweet_collections.collected_at, excluded.collected_at),
1838
- source = coalesce(nullif(excluded.source, ''), tweet_collections.source),
1839
- raw_json = case
1840
- when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
1841
- else tweet_collections.raw_json
1842
- end,
1843
- updated_at = max(tweet_collections.updated_at, excluded.updated_at)
1844
- `,
1845
- collections,
1846
- [
1847
- "account_id",
1848
- "tweet_id",
1849
- "kind",
1850
- "collected_at",
1851
- "source",
1852
- "raw_json",
1853
- "updated_at",
1854
- ],
1855
- );
1856
- repository.insertRows(
1857
- `
1858
- insert into tweet_account_edges (
1859
- account_id, tweet_id, kind, first_seen_at, last_seen_at, seen_count,
1860
- source, raw_json, updated_at
1861
- ) values (?, ?, ?, ?, ?, coalesce(?, 1), coalesce(?, 'backup'), coalesce(?, '{}'), ?)
1862
- on conflict(account_id, tweet_id, kind) do update set
1863
- first_seen_at = min(tweet_account_edges.first_seen_at, excluded.first_seen_at),
1864
- last_seen_at = max(tweet_account_edges.last_seen_at, excluded.last_seen_at),
1865
- seen_count = max(tweet_account_edges.seen_count, excluded.seen_count),
1866
- source = coalesce(nullif(excluded.source, ''), tweet_account_edges.source),
1867
- raw_json = case
1868
- when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
1869
- else tweet_account_edges.raw_json
1870
- end,
1871
- updated_at = max(tweet_account_edges.updated_at, excluded.updated_at)
1872
- `,
1873
- timelineEdges,
1874
- [
1875
- "account_id",
1876
- "tweet_id",
1877
- "kind",
1878
- "first_seen_at",
1879
- "last_seen_at",
1880
- "seen_count",
1881
- "source",
1882
- "raw_json",
1883
- "updated_at",
1884
- ],
1885
- );
1886
- repository.insertRows(
1887
- `
1888
- insert into dm_conversations (
1889
- id, account_id, participant_profile_id, title, inbox_kind, last_message_at, unread_count, needs_reply
1890
- ) values (?, ?, ?, ?, coalesce(?, 'accepted'), ?, ?, ?)
1891
- on conflict(id) do update set
1892
- account_id = coalesce(nullif(excluded.account_id, ''), dm_conversations.account_id),
1893
- participant_profile_id = coalesce(nullif(excluded.participant_profile_id, ''), dm_conversations.participant_profile_id),
1894
- title = coalesce(nullif(excluded.title, ''), dm_conversations.title),
1895
- inbox_kind = case
1896
- when excluded.last_message_at > dm_conversations.last_message_at
1897
- then coalesce(nullif(excluded.inbox_kind, ''), dm_conversations.inbox_kind)
1898
- else dm_conversations.inbox_kind
1899
- end,
1900
- last_message_at = max(dm_conversations.last_message_at, excluded.last_message_at),
1901
- unread_count = max(dm_conversations.unread_count, excluded.unread_count),
1902
- needs_reply = max(dm_conversations.needs_reply, excluded.needs_reply)
1903
- `,
1904
- conversations,
1905
- [
1906
- "id",
1907
- "account_id",
1908
- "participant_profile_id",
1909
- "title",
1910
- "inbox_kind",
1911
- "last_message_at",
1912
- "unread_count",
1913
- "needs_reply",
1914
- ],
1915
- );
1916
- repository.insertRows(
1917
- `
1918
- insert into dm_messages (
1919
- id, conversation_id, sender_profile_id, text, created_at, direction, is_replied, media_count
1920
- ) values (?, ?, ?, ?, ?, ?, ?, ?)
1921
- on conflict(id) do update set
1922
- conversation_id = coalesce(nullif(excluded.conversation_id, ''), dm_messages.conversation_id),
1923
- sender_profile_id = coalesce(nullif(excluded.sender_profile_id, ''), dm_messages.sender_profile_id),
1924
- text = coalesce(nullif(excluded.text, ''), dm_messages.text),
1925
- created_at = min(dm_messages.created_at, excluded.created_at),
1926
- direction = coalesce(nullif(excluded.direction, ''), dm_messages.direction),
1927
- is_replied = max(dm_messages.is_replied, excluded.is_replied),
1928
- media_count = max(dm_messages.media_count, excluded.media_count)
1929
- `,
1930
- messages,
1931
- [
1932
- "id",
1933
- "conversation_id",
1934
- "sender_profile_id",
1935
- "text",
1936
- "created_at",
1937
- "direction",
1938
- "is_replied",
1939
- "media_count",
1940
- ],
1941
- );
1942
- repository.insertFtsRows({
1943
- target: { table: "dm_fts", idColumn: "message_id" },
1944
- rows: messages,
1945
- idKey: "id",
1946
- textKey: "text",
1947
- existingIds: dmFtsIds,
1948
- });
1949
- repository.insertRows(
1950
- `
1951
- insert into url_expansions (
1952
- short_url, expanded_url, final_url, status, expanded_tweet_id,
1953
- expanded_handle, title, description, image_url, site_name, error, source,
1954
- updated_at
1955
- ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1956
- on conflict(short_url) do update set
1957
- expanded_url = excluded.expanded_url,
1958
- final_url = excluded.final_url,
1959
- status = excluded.status,
1960
- expanded_tweet_id = excluded.expanded_tweet_id,
1961
- expanded_handle = excluded.expanded_handle,
1962
- title = excluded.title,
1963
- description = excluded.description,
1964
- image_url = excluded.image_url,
1965
- site_name = excluded.site_name,
1966
- error = excluded.error,
1967
- source = excluded.source,
1968
- updated_at = excluded.updated_at
1969
- `,
1970
- sanitizedUrlExpansions,
1971
- [
1972
- "short_url",
1973
- "expanded_url",
1974
- "final_url",
1975
- "status",
1976
- "expanded_tweet_id",
1977
- "expanded_handle",
1978
- "title",
1979
- "description",
1980
- "image_url",
1981
- "site_name",
1982
- "error",
1983
- "source",
1984
- "updated_at",
1985
- ],
1986
- );
1987
- repository.insertRows(
1988
- `
1989
- insert into link_occurrences (
1990
- source_kind, source_id, source_position, short_url, account_id,
1991
- conversation_id, direction, created_at
1992
- ) values (?, ?, ?, ?, ?, ?, ?, ?)
1993
- on conflict(source_kind, source_id, source_position, short_url) do update set
1994
- account_id = excluded.account_id,
1995
- conversation_id = excluded.conversation_id,
1996
- direction = excluded.direction,
1997
- created_at = excluded.created_at
1998
- `,
1999
- linkOccurrences,
2000
- [
2001
- "source_kind",
2002
- "source_id",
2003
- "source_position",
2004
- "short_url",
2005
- "account_id",
2006
- "conversation_id",
2007
- "direction",
2008
- "created_at",
2009
- ],
2010
- );
2011
- repository.insertRows(
2012
- `
2013
- insert into blocks (account_id, profile_id, source, created_at)
2014
- values (?, ?, ?, ?)
2015
- on conflict(account_id, profile_id) do update set
2016
- source = coalesce(nullif(excluded.source, ''), blocks.source),
2017
- created_at = min(blocks.created_at, excluded.created_at)
2018
- `,
2019
- blocks,
2020
- ["account_id", "profile_id", "source", "created_at"],
2021
- );
2022
- repository.insertRows(
2023
- `
2024
- insert into mutes (account_id, profile_id, source, created_at)
2025
- values (?, ?, ?, ?)
2026
- on conflict(account_id, profile_id) do update set
2027
- source = coalesce(nullif(excluded.source, ''), mutes.source),
2028
- created_at = min(mutes.created_at, excluded.created_at)
2029
- `,
2030
- mutes,
2031
- ["account_id", "profile_id", "source", "created_at"],
2032
- );
2033
- repository.insertRows(
2034
- `
2035
- insert into tweet_actions (id, account_id, tweet_id, kind, body, created_at)
2036
- values (?, ?, ?, ?, ?, ?)
2037
- on conflict(id) do update set
2038
- account_id = coalesce(nullif(excluded.account_id, ''), tweet_actions.account_id),
2039
- tweet_id = coalesce(excluded.tweet_id, tweet_actions.tweet_id),
2040
- kind = coalesce(nullif(excluded.kind, ''), tweet_actions.kind),
2041
- body = coalesce(nullif(excluded.body, ''), tweet_actions.body),
2042
- created_at = min(tweet_actions.created_at, excluded.created_at)
2043
- `,
2044
- actions,
2045
- ["id", "account_id", "tweet_id", "kind", "body", "created_at"],
2046
- );
2047
- repository.insertRows(
2048
- `
2049
- insert into ai_scores (
2050
- entity_kind, entity_id, model, score, summary, reasoning, updated_at
2051
- ) values (?, ?, ?, ?, ?, ?, ?)
2052
- on conflict(entity_kind, entity_id) do update set
2053
- model = coalesce(nullif(excluded.model, ''), ai_scores.model),
2054
- score = max(ai_scores.score, excluded.score),
2055
- summary = coalesce(nullif(excluded.summary, ''), ai_scores.summary),
2056
- reasoning = coalesce(nullif(excluded.reasoning, ''), ai_scores.reasoning),
2057
- updated_at = max(ai_scores.updated_at, excluded.updated_at)
2058
- `,
2059
- scores,
2060
- [
2061
- "entity_kind",
2062
- "entity_id",
2063
- "model",
2064
- "score",
2065
- "summary",
2066
- "reasoning",
2067
- "updated_at",
2068
- ],
2069
- );
2070
- return getBackupDatabaseFingerprint(writeDb);
2071
- }, db);
2072
-
2073
- return {
2074
- ok: true,
2075
- repoPath: resolvedRepoPath,
2076
- mode,
2077
- manifest,
2078
- ...(validation ? { validation } : {}),
2079
- fingerprint,
2080
- };
2081
- });
2082
- }
2083
-
2084
- export function importBackup(
2085
- options: BackupImportOptions,
2086
- ): Promise<BackupImportResult> {
2087
- return runEffectPromise(importBackupEffect(options));
2088
- }
2089
-
2090
- export interface SyncBackupOptions {
2091
- repoPath: string;
2092
- remote?: string;
2093
- db?: Database;
2094
- message?: string;
2095
- }
2096
-
2097
- export function syncBackupEffect({
2098
- repoPath,
2099
- remote,
2100
- message = "archive: sync birdclaw backup",
2101
- db,
2102
- }: SyncBackupOptions): Effect.Effect<BackupSyncResult, unknown> {
2103
- return Effect.gen(function* () {
2104
- const resolvedRepoPath = path.resolve(repoPath);
2105
- const database =
2106
- db ?? (yield* trySync(() => getNativeDb({ seedDemoData: false })));
2107
- yield* ensureBackupGitRepoEffect({ repoPath: resolvedRepoPath, remote });
2108
- const pulled = yield* pullBackupGitRepoEffect(resolvedRepoPath);
2109
- const manifestExists = yield* trySync(() =>
2110
- existsSync(path.join(resolvedRepoPath, MANIFEST_PATH)),
2111
- );
2112
- const importResult = manifestExists
2113
- ? yield* importBackupEffect({
2114
- repoPath: resolvedRepoPath,
2115
- db: database,
2116
- mode: "merge",
2117
- })
2118
- : undefined;
2119
- const exportResult = yield* exportBackupEffect({
2120
- repoPath: resolvedRepoPath,
2121
- db: database,
2122
- commit: true,
2123
- push: true,
2124
- message,
2125
- });
2126
-
2127
- return {
2128
- ok: true,
2129
- repoPath: resolvedRepoPath,
2130
- ...(remote ? { remote: redactSecretUrl(remote) } : {}),
2131
- pulled,
2132
- imported: Boolean(importResult),
2133
- ...(importResult ? { importResult } : {}),
2134
- exportResult,
2135
- };
2136
- });
2137
- }
2138
-
2139
- export function syncBackup(
2140
- options: SyncBackupOptions,
2141
- ): Promise<BackupSyncResult> {
2142
- return runEffectPromise(syncBackupEffect(options));
2143
- }
2144
-
2145
- export interface UpdateBackupFromGitOptions {
2146
- repoPath: string;
2147
- remote?: string;
2148
- db?: Database;
2149
- }
2150
-
2151
- export interface UpdateBackupFromGitResult {
2152
- ok: true;
2153
- repoPath: string;
2154
- remote?: string;
2155
- pulled: boolean;
2156
- imported: boolean;
2157
- importResult?: BackupImportResult;
2158
- }
2159
-
2160
- export function updateBackupFromGitEffect({
2161
- repoPath,
2162
- remote,
2163
- db,
2164
- }: UpdateBackupFromGitOptions): Effect.Effect<
2165
- UpdateBackupFromGitResult,
2166
- unknown
2167
- > {
2168
- return Effect.gen(function* () {
2169
- const resolvedRepoPath = path.resolve(repoPath);
2170
- const database =
2171
- db ?? (yield* trySync(() => getNativeDb({ seedDemoData: false })));
2172
- yield* ensureBackupGitRepoEffect({ repoPath: resolvedRepoPath, remote });
2173
- const pulled = yield* pullBackupGitRepoEffect(resolvedRepoPath);
2174
- const manifestExists = yield* trySync(() =>
2175
- existsSync(path.join(resolvedRepoPath, MANIFEST_PATH)),
2176
- );
2177
- const importResult = manifestExists
2178
- ? yield* importBackupEffect({
2179
- repoPath: resolvedRepoPath,
2180
- db: database,
2181
- mode: "merge",
2182
- })
2183
- : undefined;
2184
-
2185
- return {
2186
- ok: true,
2187
- repoPath: resolvedRepoPath,
2188
- ...(remote ? { remote: redactSecretUrl(remote) } : {}),
2189
- pulled,
2190
- imported: Boolean(importResult),
2191
- ...(importResult ? { importResult } : {}),
2192
- };
2193
- });
2194
- }
2195
-
2196
- export function updateBackupFromGit(
2197
- options: UpdateBackupFromGitOptions,
2198
- ): Promise<UpdateBackupFromGitResult> {
2199
- return runEffectPromise(updateBackupFromGitEffect(options));
2200
- }
2201
-
2202
- function readAutoSyncState(db: Database) {
2203
- const row = db
2204
- .prepare("select value_json from sync_cache where cache_key = ?")
2205
- .get(AUTO_SYNC_CACHE_KEY) as { value_json: string } | undefined;
2206
- if (!row) {
2207
- return null;
2208
- }
2209
- try {
2210
- return JSON.parse(row.value_json) as {
2211
- checkedAt?: string;
2212
- ok?: boolean;
2213
- error?: string;
2214
- };
2215
- } catch {
2216
- return null;
2217
- }
2218
- }
2219
-
2220
- function writeAutoSyncState(
2221
- db: Database,
2222
- value: { checkedAt: string; ok: boolean; error?: string },
2223
- ) {
2224
- db.prepare(
2225
- `
2226
- insert into sync_cache (cache_key, value_json, updated_at)
2227
- values (?, ?, ?)
2228
- on conflict(cache_key) do update set
2229
- value_json = excluded.value_json,
2230
- updated_at = excluded.updated_at
2231
- `,
2232
- ).run(AUTO_SYNC_CACHE_KEY, JSON.stringify(value), value.checkedAt);
2233
- }
2234
-
2235
- function resolveAutoSyncConfig() {
2236
- const backup = getBirdclawConfig().backup;
2237
- if (!backup || backup.autoSync === false) {
2238
- return null;
2239
- }
2240
- const repoPath = backup.repoPath?.trim();
2241
- const remote = backup.remote?.trim();
2242
- if (!repoPath && !remote) {
2243
- return null;
2244
- }
2245
- const staleAfterSeconds =
2246
- typeof backup.staleAfterSeconds === "number" &&
2247
- Number.isFinite(backup.staleAfterSeconds) &&
2248
- backup.staleAfterSeconds >= 0
2249
- ? Math.floor(backup.staleAfterSeconds)
2250
- : DEFAULT_STALE_AFTER_SECONDS;
2251
-
2252
- return {
2253
- repoPath:
2254
- repoPath ||
2255
- path.join(process.env.HOME || ".", "Projects", "backup-birdclaw"),
2256
- remote,
2257
- staleAfterSeconds,
2258
- };
2259
- }
2260
-
2261
- function autoSyncConfigError(error: unknown): BackupAutoUpdateResult {
2262
- return {
2263
- ok: false,
2264
- enabled: true,
2265
- skipped: false,
2266
- error: error instanceof Error ? error.message : String(error),
2267
- };
2268
- }
2269
-
2270
- function runMaybeAutoUpdateBackupEffect(
2271
- db?: Database,
2272
- ): Effect.Effect<BackupAutoUpdateResult, never> {
2273
- return Effect.gen(function* () {
2274
- if (process.env.BIRDCLAW_BACKUP_AUTO_SYNC === "0") {
2275
- return {
2276
- ok: true,
2277
- enabled: false,
2278
- skipped: true,
2279
- reason: "disabled by BIRDCLAW_BACKUP_AUTO_SYNC=0",
2280
- };
2281
- }
2282
- const configResult = yield* trySync(() => resolveAutoSyncConfig()).pipe(
2283
- Effect.map((config) => ({ ok: true as const, config })),
2284
- Effect.catchAll((error) => Effect.succeed({ ok: false as const, error })),
2285
- );
2286
- if (!configResult.ok) return autoSyncConfigError(configResult.error);
2287
- const { config } = configResult;
2288
- if (!config) {
2289
- return {
2290
- ok: true,
2291
- enabled: false,
2292
- skipped: true,
2293
- reason: "backup auto-sync is not configured",
2294
- };
2295
- }
2296
-
2297
- const database = yield* trySync(
2298
- () => db ?? getNativeDb({ seedDemoData: false }),
2299
- ).pipe(Effect.orDie);
2300
- const state = yield* trySync(() => readAutoSyncState(database)).pipe(
2301
- Effect.catchAll(() => Effect.succeed(null)),
2302
- );
2303
- const checkedAt = state?.checkedAt
2304
- ? new Date(state.checkedAt).getTime()
2305
- : 0;
2306
- const ageMs = Date.now() - checkedAt;
2307
- if (ageMs >= 0 && ageMs < config.staleAfterSeconds * 1000) {
2308
- return {
2309
- ok: true,
2310
- enabled: true,
2311
- skipped: true,
2312
- reason: "backup auto-sync is fresh",
2313
- repoPath: config.repoPath,
2314
- ...(config.remote ? { remote: redactSecretUrl(config.remote) } : {}),
2315
- };
2316
- }
2317
-
2318
- const now = new Date().toISOString();
2319
- const result = yield* updateBackupFromGitEffect({
2320
- repoPath: config.repoPath,
2321
- remote: config.remote,
2322
- db: database,
2323
- }).pipe(
2324
- Effect.map((value) => ({ ok: true as const, value })),
2325
- Effect.catchAll((error) => Effect.succeed({ ok: false as const, error })),
2326
- );
2327
-
2328
- if (result.ok) {
2329
- yield* trySync(() =>
2330
- writeAutoSyncState(database, { checkedAt: now, ok: true }),
2331
- ).pipe(Effect.orDie);
2332
- return {
2333
- ok: true,
2334
- enabled: true,
2335
- skipped: false,
2336
- repoPath: result.value.repoPath,
2337
- ...(result.value.remote
2338
- ? { remote: redactSecretUrl(result.value.remote) }
2339
- : {}),
2340
- pulled: result.value.pulled,
2341
- imported: result.value.imported,
2342
- };
2343
- }
2344
-
2345
- const message =
2346
- result.error instanceof Error
2347
- ? result.error.message
2348
- : String(result.error);
2349
- yield* trySync(() =>
2350
- writeAutoSyncState(database, {
2351
- checkedAt: now,
2352
- ok: false,
2353
- error: message,
2354
- }),
2355
- ).pipe(Effect.orDie);
2356
- return {
2357
- ok: false,
2358
- enabled: true,
2359
- skipped: false,
2360
- repoPath: config.repoPath,
2361
- ...(config.remote ? { remote: redactSecretUrl(config.remote) } : {}),
2362
- error: redactSecretUrl(message),
2363
- };
2364
- });
2365
- }
2366
-
2367
- export function maybeAutoUpdateBackupEffect(
2368
- db?: Database,
2369
- ): Effect.Effect<BackupAutoUpdateResult, never> {
2370
- if (autoUpdateInFlight) {
2371
- return Effect.promise(() => autoUpdateInFlight!);
2372
- }
2373
-
2374
- return Effect.promise(() => {
2375
- const promise = runEffectPromise(
2376
- runMaybeAutoUpdateBackupEffect(db),
2377
- ).finally(() => {
2378
- if (autoUpdateInFlight === promise) {
2379
- autoUpdateInFlight = null;
2380
- }
2381
- });
2382
- autoUpdateInFlight = promise;
2383
- return promise;
2384
- });
2385
- }
2386
-
2387
- export function maybeAutoUpdateBackup(
2388
- db?: Database,
2389
- ): Promise<BackupAutoUpdateResult> {
2390
- return runEffectPromise(maybeAutoUpdateBackupEffect(db));
2391
- }
2392
-
2393
- export function maybeAutoSyncBackupEffect(
2394
- db?: Database,
2395
- ): Effect.Effect<BackupAutoUpdateResult, never> {
2396
- return Effect.gen(function* () {
2397
- if (process.env.BIRDCLAW_BACKUP_AUTO_SYNC === "0") {
2398
- return {
2399
- ok: true,
2400
- enabled: false,
2401
- skipped: true,
2402
- reason: "disabled by BIRDCLAW_BACKUP_AUTO_SYNC=0",
2403
- };
2404
- }
2405
- const configResult = yield* trySync(() => resolveAutoSyncConfig()).pipe(
2406
- Effect.map((config) => ({ ok: true as const, config })),
2407
- Effect.catchAll((error) => Effect.succeed({ ok: false as const, error })),
2408
- );
2409
- if (!configResult.ok) return autoSyncConfigError(configResult.error);
2410
- const { config } = configResult;
2411
- if (!config) {
2412
- return {
2413
- ok: true,
2414
- enabled: false,
2415
- skipped: true,
2416
- reason: "backup auto-sync is not configured",
2417
- };
2418
- }
2419
- const database = yield* trySync(
2420
- () => db ?? getNativeDb({ seedDemoData: false }),
2421
- ).pipe(Effect.orDie);
2422
- const now = new Date().toISOString();
2423
- const result = yield* syncBackupEffect({
2424
- repoPath: config.repoPath,
2425
- remote: config.remote,
2426
- db: database,
2427
- }).pipe(
2428
- Effect.map((value) => ({ ok: true as const, value })),
2429
- Effect.catchAll((error) => Effect.succeed({ ok: false as const, error })),
2430
- );
2431
-
2432
- if (result.ok) {
2433
- yield* trySync(() =>
2434
- writeAutoSyncState(database, { checkedAt: now, ok: true }),
2435
- ).pipe(Effect.orDie);
2436
- return {
2437
- ok: true,
2438
- enabled: true,
2439
- skipped: false,
2440
- repoPath: result.value.repoPath,
2441
- ...(result.value.remote ? { remote: result.value.remote } : {}),
2442
- pulled: result.value.pulled,
2443
- imported: result.value.imported,
2444
- };
2445
- }
2446
-
2447
- const message =
2448
- result.error instanceof Error
2449
- ? result.error.message
2450
- : String(result.error);
2451
- yield* trySync(() =>
2452
- writeAutoSyncState(database, {
2453
- checkedAt: now,
2454
- ok: false,
2455
- error: message,
2456
- }),
2457
- ).pipe(Effect.orDie);
2458
- return {
2459
- ok: false,
2460
- enabled: true,
2461
- skipped: false,
2462
- repoPath: config.repoPath,
2463
- ...(config.remote ? { remote: config.remote } : {}),
2464
- error: message,
2465
- };
2466
- });
2467
- }
2468
-
2469
- export function maybeAutoSyncBackup(
2470
- db?: Database,
2471
- ): Promise<BackupAutoUpdateResult> {
2472
- return runEffectPromise(maybeAutoSyncBackupEffect(db));
2473
- }
2474
-
2475
- export function validateBackupEffect(
2476
- repoPath: string,
2477
- ): Effect.Effect<BackupValidationResult, unknown> {
2478
- return Effect.gen(function* () {
2479
- const resolvedRepoPath = yield* trySync(() => path.resolve(repoPath));
2480
- const errors: string[] = [];
2481
- const manifestResult = yield* readManifestEffect(resolvedRepoPath).pipe(
2482
- Effect.match({
2483
- onFailure: (error) => ({ ok: false as const, error }),
2484
- onSuccess: (manifest) => ({ ok: true as const, manifest }),
2485
- }),
2486
- );
2487
- if (!manifestResult.ok) {
2488
- return {
2489
- ok: false,
2490
- repoPath: resolvedRepoPath,
2491
- files: [],
2492
- counts: {},
2493
- backupHash: "",
2494
- errors: [
2495
- manifestResult.error instanceof Error
2496
- ? manifestResult.error.message
2497
- : String(manifestResult.error),
2498
- ],
2499
- };
2500
- }
2501
- const { manifest } = manifestResult;
2502
-
2503
- const results = yield* Effect.forEach(
2504
- manifest.files,
2505
- (expected) =>
2506
- Effect.gen(function* () {
2507
- const fileErrors: string[] = [];
2508
- let file: BackupFileManifest | undefined;
2509
- const filePath = yield* trySync(() =>
2510
- resolveBackupFilePath(resolvedRepoPath, expected.path),
2511
- ).pipe(
2512
- Effect.match({
2513
- onFailure: (error) => {
2514
- fileErrors.push(
2515
- `${expected.path}: ${error instanceof Error ? error.message : String(error)}`,
2516
- );
2517
- return undefined;
2518
- },
2519
- onSuccess: (value) => value,
2520
- }),
2521
- );
2522
- if (!filePath) {
2523
- return { file, errors: fileErrors };
2524
- }
2525
- const stat = yield* assertReadableBackupFileEffect(
2526
- resolvedRepoPath,
2527
- filePath,
2528
- expected.path,
2529
- ).pipe(
2530
- Effect.match({
2531
- onFailure: (error) => {
2532
- fileErrors.push(
2533
- `${expected.path}: ${error instanceof Error ? error.message : String(error)}`,
2534
- );
2535
- return undefined;
2536
- },
2537
- onSuccess: (value) => value,
2538
- }),
2539
- );
2540
- if (!stat) {
2541
- return { file, errors: fileErrors };
2542
- }
2543
- const content = yield* tryPromise(() => fs.readFile(filePath)).pipe(
2544
- Effect.match({
2545
- onFailure: (error) => {
2546
- fileErrors.push(
2547
- `${expected.path}: ${error instanceof Error ? error.message : String(error)}`,
2548
- );
2549
- return undefined;
2550
- },
2551
- onSuccess: (value) => value,
2552
- }),
2553
- );
2554
- if (content) {
2555
- const text = content.toString("utf8");
2556
- const rows = text.split("\n").filter((line) => line.length > 0);
2557
- for (const [index, line] of rows.entries()) {
2558
- const parseError = yield* trySync(() => JSON.parse(line)).pipe(
2559
- Effect.match({
2560
- onFailure: (error) => error,
2561
- onSuccess: () => undefined,
2562
- }),
2563
- );
2564
- if (parseError) {
2565
- fileErrors.push(
2566
- `${expected.path}:${index + 1}: ${
2567
- parseError instanceof Error
2568
- ? parseError.message
2569
- : String(parseError)
2570
- }`,
2571
- );
2572
- }
2573
- }
2574
- file = {
2575
- path: expected.path,
2576
- rows: rows.length,
2577
- sha256: sha256(content),
2578
- bytes: content.byteLength,
2579
- };
2580
- }
2581
- return { file, errors: fileErrors };
2582
- }),
2583
- { concurrency: "unbounded" },
2584
- );
2585
-
2586
- const files: BackupFileManifest[] = [];
2587
- for (const result of results) {
2588
- errors.push(...result.errors);
2589
- if (result.file) {
2590
- files.push(result.file);
2591
- }
2592
- }
2593
-
2594
- for (const expected of manifest.files) {
2595
- const file = files.find((entry) => entry.path === expected.path);
2596
- if (!file) {
2597
- continue;
2598
- }
2599
- if (file.rows !== expected.rows) {
2600
- errors.push(`${file.path}: row count ${file.rows} != ${expected.rows}`);
2601
- }
2602
- if (file.sha256 !== expected.sha256) {
2603
- errors.push(
2604
- `${file.path}: sha256 ${file.sha256} != ${expected.sha256}`,
2605
- );
2606
- }
2607
- if (file.bytes !== expected.bytes) {
2608
- errors.push(`${file.path}: bytes ${file.bytes} != ${expected.bytes}`);
2609
- }
2610
- }
2611
-
2612
- const counts = computeCounts(files);
2613
- const backupHash = computeBackupHash(files);
2614
- if (backupHash !== manifest.backupHash) {
2615
- errors.push(`backup hash ${backupHash} != ${manifest.backupHash}`);
2616
- }
2617
- if (canonicalStringify(counts) !== canonicalStringify(manifest.counts)) {
2618
- errors.push("manifest counts do not match backup files");
2619
- }
2620
-
2621
- return {
2622
- ok: errors.length === 0,
2623
- repoPath: resolvedRepoPath,
2624
- files,
2625
- counts,
2626
- backupHash,
2627
- errors,
2628
- };
2629
- });
2630
- }
2631
-
2632
- export function validateBackup(
2633
- repoPath: string,
2634
- ): Promise<BackupValidationResult> {
2635
- return runEffectPromise(validateBackupEffect(repoPath));
2636
- }
2637
-
2638
- export function getBackupDatabaseFingerprint(
2639
- db = getNativeDb({ seedDemoData: false }),
2640
- ): BackupDatabaseFingerprint {
2641
- const counts: Record<string, number> = {};
2642
- const hash = createHash("sha256");
2643
- for (const rowSet of getExportRowSets(db)) {
2644
- counts[rowSet.logicalName] = rowSet.rows.length;
2645
- hash.update(`${rowSet.logicalName}\n`);
2646
- for (const row of rowSet.rows) {
2647
- hash.update(canonicalStringify(row));
2648
- hash.update("\n");
2649
- }
2650
- }
2651
- return { counts, hash: hash.digest("hex") };
2652
- }