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