birdclaw 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1617 @@
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 type Database from "better-sqlite3";
8
+ import { getBirdclawConfig } from "./config";
9
+ import { getNativeDb } from "./db";
10
+
11
+ const execFileAsync = promisify(execFile);
12
+ const BACKUP_SCHEMA_VERSION = 1;
13
+ const MANIFEST_PATH = "manifest.json";
14
+ const DATA_DIR = "data";
15
+ const AUTO_SYNC_CACHE_KEY = "backup:auto-sync";
16
+ const DEFAULT_STALE_AFTER_SECONDS = 15 * 60;
17
+
18
+ type JsonValue =
19
+ | null
20
+ | boolean
21
+ | number
22
+ | string
23
+ | JsonValue[]
24
+ | { [key: string]: JsonValue };
25
+
26
+ type JsonRecord = Record<string, JsonValue>;
27
+
28
+ export interface BackupFileManifest {
29
+ path: string;
30
+ rows: number;
31
+ sha256: string;
32
+ bytes: number;
33
+ }
34
+
35
+ export interface BackupManifest {
36
+ app: "birdclaw";
37
+ schemaVersion: number;
38
+ generatedAt: string;
39
+ counts: Record<string, number>;
40
+ files: BackupFileManifest[];
41
+ backupHash: string;
42
+ }
43
+
44
+ export interface BackupExportResult {
45
+ ok: true;
46
+ repoPath: string;
47
+ manifest: BackupManifest;
48
+ validation: BackupValidationResult;
49
+ git?: {
50
+ committed: boolean;
51
+ pushed: boolean;
52
+ commit?: string;
53
+ };
54
+ }
55
+
56
+ export interface BackupImportResult {
57
+ ok: true;
58
+ repoPath: string;
59
+ mode: BackupImportMode;
60
+ manifest: BackupManifest;
61
+ validation?: BackupValidationResult;
62
+ fingerprint: BackupDatabaseFingerprint;
63
+ }
64
+
65
+ export interface BackupSyncResult {
66
+ ok: true;
67
+ repoPath: string;
68
+ remote?: string;
69
+ pulled: boolean;
70
+ imported: boolean;
71
+ importResult?: BackupImportResult;
72
+ exportResult: BackupExportResult;
73
+ }
74
+
75
+ export interface BackupAutoUpdateResult {
76
+ ok: boolean;
77
+ enabled: boolean;
78
+ skipped: boolean;
79
+ reason?: string;
80
+ repoPath?: string;
81
+ remote?: string;
82
+ pulled?: boolean;
83
+ imported?: boolean;
84
+ error?: string;
85
+ }
86
+
87
+ export interface BackupValidationResult {
88
+ ok: boolean;
89
+ repoPath: string;
90
+ files: BackupFileManifest[];
91
+ counts: Record<string, number>;
92
+ backupHash: string;
93
+ errors: string[];
94
+ }
95
+
96
+ export interface BackupDatabaseFingerprint {
97
+ counts: Record<string, number>;
98
+ hash: string;
99
+ }
100
+
101
+ export type BackupImportMode = "merge" | "replace";
102
+
103
+ function canonicalStringify(value: JsonValue): string {
104
+ if (value === null || typeof value !== "object") {
105
+ return JSON.stringify(value);
106
+ }
107
+ if (Array.isArray(value)) {
108
+ return `[${value.map((item) => canonicalStringify(item)).join(",")}]`;
109
+ }
110
+ const keys = Object.keys(value).sort();
111
+ return `{${keys
112
+ .map((key) => `${JSON.stringify(key)}:${canonicalStringify(value[key])}`)
113
+ .join(",")}}`;
114
+ }
115
+
116
+ function toJsonRecord(row: Record<string, unknown>): JsonRecord {
117
+ const result: JsonRecord = {};
118
+ for (const [key, value] of Object.entries(row)) {
119
+ if (
120
+ value === null ||
121
+ typeof value === "string" ||
122
+ typeof value === "number" ||
123
+ typeof value === "boolean"
124
+ ) {
125
+ result[key] = value;
126
+ continue;
127
+ }
128
+ result[key] = JSON.parse(JSON.stringify(value)) as JsonValue;
129
+ }
130
+ return result;
131
+ }
132
+
133
+ function sha256(content: string | Buffer) {
134
+ return createHash("sha256").update(content).digest("hex");
135
+ }
136
+
137
+ const jsonlKeyOrderCache = new Map<string, string[]>();
138
+
139
+ function jsonlStringify(row: JsonRecord): string {
140
+ const keys = Object.keys(row);
141
+ const signature = keys.join("\0");
142
+ let sortedKeys = jsonlKeyOrderCache.get(signature);
143
+ if (!sortedKeys) {
144
+ sortedKeys = [...keys].sort();
145
+ jsonlKeyOrderCache.set(signature, sortedKeys);
146
+ }
147
+ return `{${sortedKeys
148
+ .map((key) => `${JSON.stringify(key)}:${JSON.stringify(row[key])}`)
149
+ .join(",")}}`;
150
+ }
151
+
152
+ function yearFromTimestamp(value: unknown) {
153
+ if (typeof value !== "string") {
154
+ return "unknown";
155
+ }
156
+ const match = /^(\d{4})/.exec(value);
157
+ if (!match || match[1] === "1970") {
158
+ return "unknown";
159
+ }
160
+ return match[1];
161
+ }
162
+
163
+ function rowsForQuery(
164
+ db: Database.Database,
165
+ sql: string,
166
+ params: unknown[] = [],
167
+ ) {
168
+ return (db.prepare(sql).all(...params) as Record<string, unknown>[]).map(
169
+ toJsonRecord,
170
+ );
171
+ }
172
+
173
+ function getExportRowSets(db: Database.Database) {
174
+ const rowSets: Array<{ logicalName: string; rows: JsonRecord[] }> = [
175
+ {
176
+ logicalName: "accounts",
177
+ rows: rowsForQuery(
178
+ db,
179
+ `
180
+ select id, name, handle, external_user_id, transport, is_default, created_at
181
+ from accounts
182
+ order by id
183
+ `,
184
+ ),
185
+ },
186
+ {
187
+ logicalName: "profiles",
188
+ rows: rowsForQuery(
189
+ db,
190
+ `
191
+ select id, handle, display_name, bio, followers_count, avatar_hue, avatar_url, created_at
192
+ from profiles
193
+ order by id
194
+ `,
195
+ ),
196
+ },
197
+ {
198
+ logicalName: "tweets",
199
+ rows: rowsForQuery(
200
+ db,
201
+ `
202
+ select id, account_id, author_profile_id, kind, text, created_at, is_replied,
203
+ reply_to_id, like_count, media_count, bookmarked, liked, entities_json,
204
+ media_json, quoted_tweet_id
205
+ from tweets
206
+ order by created_at, id
207
+ `,
208
+ ),
209
+ },
210
+ {
211
+ logicalName: "tweet_collections",
212
+ rows: rowsForQuery(
213
+ db,
214
+ `
215
+ select account_id, tweet_id, kind, collected_at, source, raw_json, updated_at
216
+ from tweet_collections
217
+ order by kind, account_id, coalesce(collected_at, ''), tweet_id
218
+ `,
219
+ ),
220
+ },
221
+ {
222
+ logicalName: "dm_conversations",
223
+ rows: rowsForQuery(
224
+ db,
225
+ `
226
+ select id, account_id, participant_profile_id, title, last_message_at,
227
+ unread_count, needs_reply
228
+ from dm_conversations
229
+ order by last_message_at, id
230
+ `,
231
+ ),
232
+ },
233
+ {
234
+ logicalName: "dm_messages",
235
+ rows: rowsForQuery(
236
+ db,
237
+ `
238
+ select id, conversation_id, sender_profile_id, text, created_at, direction,
239
+ is_replied, media_count
240
+ from dm_messages
241
+ order by conversation_id, created_at, id
242
+ `,
243
+ ),
244
+ },
245
+ {
246
+ logicalName: "blocks",
247
+ rows: rowsForQuery(
248
+ db,
249
+ `
250
+ select account_id, profile_id, source, created_at
251
+ from blocks
252
+ order by account_id, profile_id
253
+ `,
254
+ ),
255
+ },
256
+ {
257
+ logicalName: "mutes",
258
+ rows: rowsForQuery(
259
+ db,
260
+ `
261
+ select account_id, profile_id, source, created_at
262
+ from mutes
263
+ order by account_id, profile_id
264
+ `,
265
+ ),
266
+ },
267
+ {
268
+ logicalName: "tweet_actions",
269
+ rows: rowsForQuery(
270
+ db,
271
+ `
272
+ select id, account_id, tweet_id, kind, body, created_at
273
+ from tweet_actions
274
+ order by created_at, id
275
+ `,
276
+ ),
277
+ },
278
+ {
279
+ logicalName: "ai_scores",
280
+ rows: rowsForQuery(
281
+ db,
282
+ `
283
+ select entity_kind, entity_id, model, score, summary, reasoning, updated_at
284
+ from ai_scores
285
+ order by entity_kind, entity_id, model
286
+ `,
287
+ ),
288
+ },
289
+ ];
290
+ return rowSets;
291
+ }
292
+
293
+ function addRows(
294
+ shards: Map<string, JsonRecord[]>,
295
+ relativePath: string,
296
+ rows: JsonRecord[],
297
+ ) {
298
+ if (rows.length === 0) {
299
+ return;
300
+ }
301
+ const existing = shards.get(relativePath) ?? [];
302
+ existing.push(...rows);
303
+ shards.set(relativePath, existing);
304
+ }
305
+
306
+ function buildShards(db: Database.Database) {
307
+ const shards = new Map<string, JsonRecord[]>();
308
+ const rowSets = getExportRowSets(db);
309
+
310
+ for (const rowSet of rowSets) {
311
+ switch (rowSet.logicalName) {
312
+ case "accounts":
313
+ addRows(shards, "data/accounts.jsonl", rowSet.rows);
314
+ break;
315
+ case "profiles":
316
+ addRows(shards, "data/profiles.jsonl", rowSet.rows);
317
+ break;
318
+ case "tweets":
319
+ for (const row of rowSet.rows) {
320
+ addRows(
321
+ shards,
322
+ `data/tweets/${yearFromTimestamp(row.created_at)}.jsonl`,
323
+ [row],
324
+ );
325
+ }
326
+ break;
327
+ case "tweet_collections":
328
+ for (const row of rowSet.rows) {
329
+ const kind =
330
+ row.kind === "likes" || row.kind === "bookmarks"
331
+ ? row.kind
332
+ : "unknown";
333
+ addRows(shards, `data/collections/${kind}.jsonl`, [row]);
334
+ }
335
+ break;
336
+ case "dm_conversations":
337
+ addRows(shards, "data/dms/conversations.jsonl", rowSet.rows);
338
+ break;
339
+ case "dm_messages":
340
+ for (const row of rowSet.rows) {
341
+ addRows(
342
+ shards,
343
+ `data/dms/${yearFromTimestamp(row.created_at)}.jsonl`,
344
+ [row],
345
+ );
346
+ }
347
+ break;
348
+ case "blocks":
349
+ case "mutes":
350
+ addRows(
351
+ shards,
352
+ `data/moderation/${rowSet.logicalName}.jsonl`,
353
+ rowSet.rows,
354
+ );
355
+ break;
356
+ case "tweet_actions":
357
+ addRows(shards, "data/actions/tweet_actions.jsonl", rowSet.rows);
358
+ break;
359
+ case "ai_scores":
360
+ addRows(shards, "data/ai_scores.jsonl", rowSet.rows);
361
+ break;
362
+ }
363
+ }
364
+
365
+ return shards;
366
+ }
367
+
368
+ async function writeJsonlFile(
369
+ repoPath: string,
370
+ relativePath: string,
371
+ rows: JsonRecord[],
372
+ ) {
373
+ const fullPath = path.join(repoPath, relativePath);
374
+ const content = `${rows.map((row) => jsonlStringify(row)).join("\n")}\n`;
375
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
376
+ let shouldWrite = true;
377
+ try {
378
+ shouldWrite = (await fs.readFile(fullPath, "utf8")) !== content;
379
+ } catch {
380
+ shouldWrite = true;
381
+ }
382
+ if (shouldWrite) {
383
+ await fs.writeFile(fullPath, content, "utf8");
384
+ }
385
+ return {
386
+ path: relativePath,
387
+ rows: rows.length,
388
+ sha256: sha256(content),
389
+ bytes: Buffer.byteLength(content),
390
+ };
391
+ }
392
+
393
+ async function removeStaleBackupFiles(
394
+ repoPath: string,
395
+ expectedPaths: Set<string>,
396
+ directory = DATA_DIR,
397
+ ) {
398
+ const fullDirectory = path.join(repoPath, directory);
399
+ let entries: Array<{ name: string; isDirectory: () => boolean }> = [];
400
+ try {
401
+ entries = await fs.readdir(fullDirectory, { withFileTypes: true });
402
+ } catch {
403
+ return;
404
+ }
405
+
406
+ await Promise.all(
407
+ entries.map(async (entry) => {
408
+ const relativePath = path.posix.join(directory, entry.name);
409
+ const fullPath = path.join(repoPath, relativePath);
410
+ if (entry.isDirectory()) {
411
+ await removeStaleBackupFiles(repoPath, expectedPaths, relativePath);
412
+ const remaining = await fs.readdir(fullPath);
413
+ if (remaining.length === 0) {
414
+ await fs.rmdir(fullPath);
415
+ }
416
+ return;
417
+ }
418
+ if (relativePath.endsWith(".jsonl") && !expectedPaths.has(relativePath)) {
419
+ await fs.rm(fullPath, { force: true });
420
+ }
421
+ }),
422
+ );
423
+ }
424
+
425
+ function computeBackupHash(files: BackupFileManifest[]) {
426
+ const content = files
427
+ .map((file) => `${file.path}\t${file.rows}\t${file.bytes}\t${file.sha256}`)
428
+ .sort()
429
+ .join("\n");
430
+ return sha256(content);
431
+ }
432
+
433
+ function computeCounts(files: BackupFileManifest[]) {
434
+ const counts: Record<string, number> = {};
435
+ for (const file of files) {
436
+ const [first, second, third] = file.path.split("/");
437
+ if (first !== "data") {
438
+ continue;
439
+ }
440
+ const key =
441
+ second === "tweets"
442
+ ? "tweets"
443
+ : second === "collections"
444
+ ? `collections_${third?.replace(/\.jsonl$/, "") ?? "unknown"}`
445
+ : second === "dms" && third === "conversations.jsonl"
446
+ ? "dm_conversations"
447
+ : second === "dms"
448
+ ? "dm_messages"
449
+ : second === "moderation"
450
+ ? third?.replace(/\.jsonl$/, "") || "moderation"
451
+ : second === "actions"
452
+ ? third?.replace(/\.jsonl$/, "") || "actions"
453
+ : second?.replace(/\.jsonl$/, "") || "unknown";
454
+ counts[key] = (counts[key] ?? 0) + file.rows;
455
+ }
456
+ return counts;
457
+ }
458
+
459
+ async function ensureBackupReadme(repoPath: string) {
460
+ const readmePath = path.join(repoPath, "README.md");
461
+ if (existsSync(readmePath)) {
462
+ return;
463
+ }
464
+ await fs.writeFile(
465
+ readmePath,
466
+ `# Birdclaw Store
467
+
468
+ Private text backup for Birdclaw data. The committed files are canonical JSONL shards that can rebuild the local SQLite index.
469
+
470
+ ## Layout
471
+
472
+ \`\`\`text
473
+ manifest.json
474
+ data/accounts.jsonl
475
+ data/profiles.jsonl
476
+ data/tweets/YYYY.jsonl
477
+ data/tweets/unknown.jsonl
478
+ data/collections/likes.jsonl
479
+ data/collections/bookmarks.jsonl
480
+ data/dms/conversations.jsonl
481
+ data/dms/YYYY.jsonl
482
+ data/moderation/blocks.jsonl
483
+ data/moderation/mutes.jsonl
484
+ \`\`\`
485
+
486
+ Tweets are sharded by creation year. Collection-only tweets whose creation date is unknown live in \`data/tweets/unknown.jsonl\`. DMs are sharded by year and keep \`conversation_id\` in each row.
487
+
488
+ Never commit live tokens, browser cookies, raw SQLite WAL/SHM sidecars, or temporary cache files here.
489
+ `,
490
+ "utf8",
491
+ );
492
+ }
493
+
494
+ async function writeManifest(repoPath: string, manifest: BackupManifest) {
495
+ const manifestPath = path.join(repoPath, MANIFEST_PATH);
496
+ const content = `${canonicalStringify(manifest as unknown as JsonRecord)}\n`;
497
+ try {
498
+ if ((await fs.readFile(manifestPath, "utf8")) === content) {
499
+ return;
500
+ }
501
+ } catch {
502
+ // New backup repo.
503
+ }
504
+ await fs.writeFile(manifestPath, content, "utf8");
505
+ }
506
+
507
+ async function readPreviousManifest(repoPath: string) {
508
+ try {
509
+ return await readManifest(repoPath);
510
+ } catch {
511
+ return undefined;
512
+ }
513
+ }
514
+
515
+ async function maybeCommitAndPush({
516
+ repoPath,
517
+ message,
518
+ commit,
519
+ push,
520
+ }: {
521
+ repoPath: string;
522
+ message: string;
523
+ commit: boolean;
524
+ push: boolean;
525
+ }) {
526
+ if (!commit && !push) {
527
+ return undefined;
528
+ }
529
+
530
+ try {
531
+ await execFileAsync("git", [
532
+ "-C",
533
+ repoPath,
534
+ "rev-parse",
535
+ "--is-inside-work-tree",
536
+ ]);
537
+ } catch {
538
+ await execFileAsync("git", ["-C", repoPath, "init"]);
539
+ }
540
+
541
+ await execFileAsync("git", [
542
+ "-C",
543
+ repoPath,
544
+ "add",
545
+ "README.md",
546
+ MANIFEST_PATH,
547
+ DATA_DIR,
548
+ ]);
549
+
550
+ try {
551
+ await execFileAsync("git", ["-C", repoPath, "config", "user.email"]);
552
+ } catch {
553
+ await execFileAsync("git", [
554
+ "-C",
555
+ repoPath,
556
+ "config",
557
+ "user.email",
558
+ "birdclaw@example.invalid",
559
+ ]);
560
+ }
561
+ try {
562
+ await execFileAsync("git", ["-C", repoPath, "config", "user.name"]);
563
+ } catch {
564
+ await execFileAsync("git", [
565
+ "-C",
566
+ repoPath,
567
+ "config",
568
+ "user.name",
569
+ "Birdclaw Backup",
570
+ ]);
571
+ }
572
+
573
+ let committed = false;
574
+ let commitHash: string | undefined;
575
+ try {
576
+ await execFileAsync("git", ["-C", repoPath, "diff", "--cached", "--quiet"]);
577
+ } catch {
578
+ await execFileAsync("git", ["-C", repoPath, "commit", "-m", message]);
579
+ committed = true;
580
+ const { stdout } = await execFileAsync("git", [
581
+ "-C",
582
+ repoPath,
583
+ "rev-parse",
584
+ "HEAD",
585
+ ]);
586
+ commitHash = stdout.trim();
587
+ }
588
+
589
+ if (push) {
590
+ try {
591
+ await execFileAsync("git", ["-C", repoPath, "push"]);
592
+ } catch {
593
+ await execFileAsync("git", [
594
+ "-C",
595
+ repoPath,
596
+ "push",
597
+ "-u",
598
+ "origin",
599
+ "HEAD:main",
600
+ ]);
601
+ }
602
+ }
603
+
604
+ return { committed, pushed: push, commit: commitHash };
605
+ }
606
+
607
+ async function isGitRepo(repoPath: string) {
608
+ try {
609
+ await execFileAsync("git", [
610
+ "-C",
611
+ repoPath,
612
+ "rev-parse",
613
+ "--is-inside-work-tree",
614
+ ]);
615
+ return true;
616
+ } catch {
617
+ return false;
618
+ }
619
+ }
620
+
621
+ async function hasGitCommits(repoPath: string) {
622
+ try {
623
+ await execFileAsync("git", [
624
+ "-C",
625
+ repoPath,
626
+ "rev-parse",
627
+ "--verify",
628
+ "HEAD",
629
+ ]);
630
+ return true;
631
+ } catch {
632
+ return false;
633
+ }
634
+ }
635
+
636
+ async function ensureBackupGitRepo({
637
+ repoPath,
638
+ remote,
639
+ }: {
640
+ repoPath: string;
641
+ remote?: string;
642
+ }) {
643
+ if (!(await isGitRepo(repoPath))) {
644
+ if (remote && !existsSync(repoPath)) {
645
+ await execFileAsync("git", ["clone", remote, repoPath]);
646
+ } else {
647
+ await fs.mkdir(repoPath, { recursive: true });
648
+ await execFileAsync("git", ["-C", repoPath, "init"]);
649
+ }
650
+ }
651
+
652
+ if (remote) {
653
+ try {
654
+ const { stdout } = await execFileAsync("git", [
655
+ "-C",
656
+ repoPath,
657
+ "remote",
658
+ "get-url",
659
+ "origin",
660
+ ]);
661
+ if (stdout.trim() !== remote) {
662
+ await execFileAsync("git", [
663
+ "-C",
664
+ repoPath,
665
+ "remote",
666
+ "set-url",
667
+ "origin",
668
+ remote,
669
+ ]);
670
+ }
671
+ } catch {
672
+ await execFileAsync("git", [
673
+ "-C",
674
+ repoPath,
675
+ "remote",
676
+ "add",
677
+ "origin",
678
+ remote,
679
+ ]);
680
+ }
681
+ }
682
+
683
+ if (remote && !(await hasGitCommits(repoPath))) {
684
+ try {
685
+ await execFileAsync("git", ["-C", repoPath, "fetch", "origin", "main"]);
686
+ await execFileAsync("git", [
687
+ "-C",
688
+ repoPath,
689
+ "checkout",
690
+ "-B",
691
+ "main",
692
+ "origin/main",
693
+ ]);
694
+ return;
695
+ } catch {
696
+ // Empty remote or no main branch yet; create the first main commit locally.
697
+ }
698
+ }
699
+
700
+ if (!(await hasGitCommits(repoPath))) {
701
+ await execFileAsync("git", ["-C", repoPath, "checkout", "-B", "main"]);
702
+ }
703
+ }
704
+
705
+ async function pullBackupGitRepo(repoPath: string) {
706
+ if (!(await isGitRepo(repoPath)) || !(await hasGitCommits(repoPath))) {
707
+ return false;
708
+ }
709
+ try {
710
+ await execFileAsync("git", ["-C", repoPath, "pull", "--ff-only"]);
711
+ return true;
712
+ } catch {
713
+ try {
714
+ await execFileAsync("git", [
715
+ "-C",
716
+ repoPath,
717
+ "pull",
718
+ "--ff-only",
719
+ "origin",
720
+ "main",
721
+ ]);
722
+ return true;
723
+ } catch {
724
+ return false;
725
+ }
726
+ }
727
+ }
728
+
729
+ export async function exportBackup({
730
+ repoPath,
731
+ db = getNativeDb({ seedDemoData: false }),
732
+ commit = false,
733
+ push = false,
734
+ message = "archive: update birdclaw backup",
735
+ validate = true,
736
+ }: {
737
+ repoPath: string;
738
+ db?: Database.Database;
739
+ commit?: boolean;
740
+ push?: boolean;
741
+ message?: string;
742
+ validate?: boolean;
743
+ }): Promise<BackupExportResult> {
744
+ const resolvedRepoPath = path.resolve(repoPath);
745
+ await fs.mkdir(resolvedRepoPath, { recursive: true });
746
+ await ensureBackupReadme(resolvedRepoPath);
747
+
748
+ const shards = buildShards(db);
749
+ const shardEntries = [...shards.entries()].sort(([left], [right]) =>
750
+ left.localeCompare(right),
751
+ );
752
+ const expectedPaths = new Set(
753
+ shardEntries.map(([relativePath]) => relativePath),
754
+ );
755
+ const files = await Promise.all(
756
+ shardEntries.map(([relativePath, rows]) =>
757
+ writeJsonlFile(resolvedRepoPath, relativePath, rows),
758
+ ),
759
+ );
760
+ await removeStaleBackupFiles(resolvedRepoPath, expectedPaths);
761
+
762
+ const counts = computeCounts(files);
763
+ const backupHash = computeBackupHash(files);
764
+ const previousManifest = await readPreviousManifest(resolvedRepoPath);
765
+ const manifest: BackupManifest = {
766
+ app: "birdclaw",
767
+ schemaVersion: BACKUP_SCHEMA_VERSION,
768
+ generatedAt:
769
+ previousManifest?.backupHash === backupHash
770
+ ? previousManifest.generatedAt
771
+ : new Date().toISOString(),
772
+ counts,
773
+ files,
774
+ backupHash,
775
+ };
776
+ await writeManifest(resolvedRepoPath, manifest);
777
+
778
+ const validation = validate
779
+ ? await validateBackup(resolvedRepoPath)
780
+ : {
781
+ ok: true,
782
+ repoPath: resolvedRepoPath,
783
+ files,
784
+ counts,
785
+ backupHash: manifest.backupHash,
786
+ errors: [],
787
+ };
788
+ if (!validation.ok) {
789
+ throw new Error(
790
+ `Backup validation failed: ${validation.errors.join("; ")}`,
791
+ );
792
+ }
793
+
794
+ const git = await maybeCommitAndPush({
795
+ repoPath: resolvedRepoPath,
796
+ message,
797
+ commit,
798
+ push,
799
+ });
800
+
801
+ return {
802
+ ok: true,
803
+ repoPath: resolvedRepoPath,
804
+ manifest,
805
+ validation,
806
+ ...(git ? { git } : {}),
807
+ };
808
+ }
809
+
810
+ async function readManifest(repoPath: string): Promise<BackupManifest> {
811
+ const content = await fs.readFile(path.join(repoPath, MANIFEST_PATH), "utf8");
812
+ const parsed = JSON.parse(content) as BackupManifest;
813
+ if (parsed.app !== "birdclaw") {
814
+ throw new Error("Backup manifest is not a birdclaw backup");
815
+ }
816
+ if (parsed.schemaVersion !== BACKUP_SCHEMA_VERSION) {
817
+ throw new Error(
818
+ `Unsupported backup schema version ${String(parsed.schemaVersion)}`,
819
+ );
820
+ }
821
+ return parsed;
822
+ }
823
+
824
+ async function readJsonlFile(repoPath: string, relativePath: string) {
825
+ const content = await fs.readFile(path.join(repoPath, relativePath), "utf8");
826
+ return content
827
+ .split("\n")
828
+ .filter((line) => line.length > 0)
829
+ .map((line) => JSON.parse(line) as JsonRecord);
830
+ }
831
+
832
+ async function readJsonlFiles(repoPath: string, relativePaths: string[]) {
833
+ const nestedRows = await Promise.all(
834
+ relativePaths.map((relativePath) => readJsonlFile(repoPath, relativePath)),
835
+ );
836
+ return nestedRows.flat();
837
+ }
838
+
839
+ function rowsForManifestPath(
840
+ manifest: BackupManifest,
841
+ predicate: (relativePath: string) => boolean,
842
+ ) {
843
+ return manifest.files
844
+ .map((file) => file.path)
845
+ .filter(predicate)
846
+ .sort();
847
+ }
848
+
849
+ function insertRows(
850
+ db: Database.Database,
851
+ sql: string,
852
+ rows: JsonRecord[],
853
+ keys: string[],
854
+ ) {
855
+ const statement = db.prepare(sql);
856
+ for (const row of rows) {
857
+ statement.run(...keys.map((key) => row[key] ?? null));
858
+ }
859
+ }
860
+
861
+ function readFtsIds(
862
+ db: Database.Database,
863
+ tableName: "tweets_fts" | "dm_fts",
864
+ idColumn: "tweet_id" | "message_id",
865
+ ) {
866
+ const rows = db
867
+ .prepare(`select ${idColumn} as id from ${tableName}`)
868
+ .all() as { id: string }[];
869
+ return new Set(rows.map((row) => row.id));
870
+ }
871
+
872
+ function insertFtsRows(
873
+ db: Database.Database,
874
+ tableName: "tweets_fts" | "dm_fts",
875
+ idColumn: "tweet_id" | "message_id",
876
+ rows: JsonRecord[],
877
+ idKey: string,
878
+ textKey: string,
879
+ existingIds = new Set<string>(),
880
+ ) {
881
+ const statement = db.prepare(
882
+ `insert into ${tableName} (${idColumn}, text) values (?, ?)`,
883
+ );
884
+ for (const row of rows) {
885
+ const id = row[idKey];
886
+ if (typeof id !== "string" || existingIds.has(id)) {
887
+ continue;
888
+ }
889
+ const text = row[textKey];
890
+ statement.run(id, typeof text === "string" ? text : "");
891
+ existingIds.add(id);
892
+ }
893
+ }
894
+
895
+ function clearBackupImportData(db: Database.Database) {
896
+ db.exec(`
897
+ delete from ai_scores;
898
+ delete from tweet_actions;
899
+ delete from tweet_collections;
900
+ delete from blocks;
901
+ delete from mutes;
902
+ delete from dm_fts;
903
+ delete from tweets_fts;
904
+ delete from dm_messages;
905
+ delete from dm_conversations;
906
+ delete from tweets;
907
+ delete from profiles;
908
+ delete from accounts;
909
+ delete from sync_cache;
910
+ `);
911
+ }
912
+
913
+ export async function importBackup({
914
+ repoPath,
915
+ db = getNativeDb({ seedDemoData: false }),
916
+ validate = true,
917
+ mode = "merge",
918
+ }: {
919
+ repoPath: string;
920
+ db?: Database.Database;
921
+ validate?: boolean;
922
+ mode?: BackupImportMode;
923
+ }): Promise<BackupImportResult> {
924
+ const resolvedRepoPath = path.resolve(repoPath);
925
+ const manifest = await readManifest(resolvedRepoPath);
926
+ const validation = validate
927
+ ? await validateBackup(resolvedRepoPath)
928
+ : undefined;
929
+ if (validation && !validation.ok) {
930
+ throw new Error(
931
+ `Backup validation failed: ${validation.errors.join("; ")}`,
932
+ );
933
+ }
934
+
935
+ const readRows = (predicate: (relativePath: string) => boolean) =>
936
+ readJsonlFiles(resolvedRepoPath, rowsForManifestPath(manifest, predicate));
937
+
938
+ const [
939
+ accounts,
940
+ profiles,
941
+ tweets,
942
+ collections,
943
+ conversations,
944
+ messages,
945
+ blocks,
946
+ mutes,
947
+ actions,
948
+ scores,
949
+ ] = await Promise.all([
950
+ readRows((file) => file === "data/accounts.jsonl"),
951
+ readRows((file) => file === "data/profiles.jsonl"),
952
+ readRows((file) => file.startsWith("data/tweets/")),
953
+ readRows((file) => file.startsWith("data/collections/")),
954
+ readRows((file) => file === "data/dms/conversations.jsonl"),
955
+ readRows(
956
+ (file) =>
957
+ file.startsWith("data/dms/") && file !== "data/dms/conversations.jsonl",
958
+ ),
959
+ readRows((file) => file === "data/moderation/blocks.jsonl"),
960
+ readRows((file) => file === "data/moderation/mutes.jsonl"),
961
+ readRows((file) => file === "data/actions/tweet_actions.jsonl"),
962
+ readRows((file) => file === "data/ai_scores.jsonl"),
963
+ ]);
964
+
965
+ db.transaction(() => {
966
+ if (mode === "replace") {
967
+ clearBackupImportData(db);
968
+ }
969
+ const tweetFtsIds =
970
+ mode === "replace"
971
+ ? new Set<string>()
972
+ : readFtsIds(db, "tweets_fts", "tweet_id");
973
+ const dmFtsIds =
974
+ mode === "replace"
975
+ ? new Set<string>()
976
+ : readFtsIds(db, "dm_fts", "message_id");
977
+ insertRows(
978
+ db,
979
+ `
980
+ insert into accounts (id, name, handle, external_user_id, transport, is_default, created_at)
981
+ values (?, ?, ?, ?, ?, ?, ?)
982
+ on conflict(id) do update set
983
+ name = coalesce(nullif(excluded.name, ''), accounts.name),
984
+ handle = coalesce(nullif(excluded.handle, ''), accounts.handle),
985
+ external_user_id = coalesce(excluded.external_user_id, accounts.external_user_id),
986
+ transport = coalesce(nullif(excluded.transport, ''), accounts.transport),
987
+ is_default = max(accounts.is_default, excluded.is_default),
988
+ created_at = min(accounts.created_at, excluded.created_at)
989
+ `,
990
+ accounts,
991
+ [
992
+ "id",
993
+ "name",
994
+ "handle",
995
+ "external_user_id",
996
+ "transport",
997
+ "is_default",
998
+ "created_at",
999
+ ],
1000
+ );
1001
+ insertRows(
1002
+ db,
1003
+ `
1004
+ insert into profiles (
1005
+ id, handle, display_name, bio, followers_count, avatar_hue, avatar_url, created_at
1006
+ ) values (?, ?, ?, ?, ?, ?, ?, ?)
1007
+ on conflict(id) do update set
1008
+ handle = coalesce(nullif(excluded.handle, ''), profiles.handle),
1009
+ display_name = coalesce(nullif(excluded.display_name, ''), profiles.display_name),
1010
+ bio = coalesce(nullif(excluded.bio, ''), profiles.bio),
1011
+ followers_count = max(profiles.followers_count, excluded.followers_count),
1012
+ avatar_hue = case when profiles.avatar_hue = 0 then excluded.avatar_hue else profiles.avatar_hue end,
1013
+ avatar_url = coalesce(excluded.avatar_url, profiles.avatar_url),
1014
+ created_at = min(profiles.created_at, excluded.created_at)
1015
+ `,
1016
+ profiles,
1017
+ [
1018
+ "id",
1019
+ "handle",
1020
+ "display_name",
1021
+ "bio",
1022
+ "followers_count",
1023
+ "avatar_hue",
1024
+ "avatar_url",
1025
+ "created_at",
1026
+ ],
1027
+ );
1028
+ insertRows(
1029
+ db,
1030
+ `
1031
+ insert into tweets (
1032
+ id, account_id, author_profile_id, kind, text, created_at, is_replied,
1033
+ reply_to_id, like_count, media_count, bookmarked, liked, entities_json,
1034
+ media_json, quoted_tweet_id
1035
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1036
+ on conflict(id) do update set
1037
+ account_id = coalesce(nullif(excluded.account_id, ''), tweets.account_id),
1038
+ author_profile_id = coalesce(nullif(excluded.author_profile_id, ''), tweets.author_profile_id),
1039
+ kind = case
1040
+ when tweets.kind in ('home', 'mention') then tweets.kind
1041
+ when excluded.kind in ('home', 'mention') then excluded.kind
1042
+ else coalesce(nullif(excluded.kind, ''), tweets.kind)
1043
+ end,
1044
+ text = coalesce(nullif(excluded.text, ''), tweets.text),
1045
+ created_at = min(tweets.created_at, excluded.created_at),
1046
+ is_replied = max(tweets.is_replied, excluded.is_replied),
1047
+ reply_to_id = coalesce(excluded.reply_to_id, tweets.reply_to_id),
1048
+ like_count = max(tweets.like_count, excluded.like_count),
1049
+ media_count = max(tweets.media_count, excluded.media_count),
1050
+ bookmarked = max(tweets.bookmarked, excluded.bookmarked),
1051
+ liked = max(tweets.liked, excluded.liked),
1052
+ entities_json = case
1053
+ when excluded.entities_json not in ('', '{}', 'null') then excluded.entities_json
1054
+ else tweets.entities_json
1055
+ end,
1056
+ media_json = case
1057
+ when excluded.media_json not in ('', '[]', 'null') then excluded.media_json
1058
+ else tweets.media_json
1059
+ end,
1060
+ quoted_tweet_id = coalesce(excluded.quoted_tweet_id, tweets.quoted_tweet_id)
1061
+ `,
1062
+ tweets,
1063
+ [
1064
+ "id",
1065
+ "account_id",
1066
+ "author_profile_id",
1067
+ "kind",
1068
+ "text",
1069
+ "created_at",
1070
+ "is_replied",
1071
+ "reply_to_id",
1072
+ "like_count",
1073
+ "media_count",
1074
+ "bookmarked",
1075
+ "liked",
1076
+ "entities_json",
1077
+ "media_json",
1078
+ "quoted_tweet_id",
1079
+ ],
1080
+ );
1081
+ insertFtsRows(
1082
+ db,
1083
+ "tweets_fts",
1084
+ "tweet_id",
1085
+ tweets,
1086
+ "id",
1087
+ "text",
1088
+ tweetFtsIds,
1089
+ );
1090
+ insertRows(
1091
+ db,
1092
+ `
1093
+ insert into tweet_collections (
1094
+ account_id, tweet_id, kind, collected_at, source, raw_json, updated_at
1095
+ ) values (?, ?, ?, ?, ?, ?, ?)
1096
+ on conflict(account_id, tweet_id, kind) do update set
1097
+ collected_at = coalesce(tweet_collections.collected_at, excluded.collected_at),
1098
+ source = coalesce(nullif(excluded.source, ''), tweet_collections.source),
1099
+ raw_json = case
1100
+ when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
1101
+ else tweet_collections.raw_json
1102
+ end,
1103
+ updated_at = max(tweet_collections.updated_at, excluded.updated_at)
1104
+ `,
1105
+ collections,
1106
+ [
1107
+ "account_id",
1108
+ "tweet_id",
1109
+ "kind",
1110
+ "collected_at",
1111
+ "source",
1112
+ "raw_json",
1113
+ "updated_at",
1114
+ ],
1115
+ );
1116
+ insertRows(
1117
+ db,
1118
+ `
1119
+ insert into dm_conversations (
1120
+ id, account_id, participant_profile_id, title, last_message_at, unread_count, needs_reply
1121
+ ) values (?, ?, ?, ?, ?, ?, ?)
1122
+ on conflict(id) do update set
1123
+ account_id = coalesce(nullif(excluded.account_id, ''), dm_conversations.account_id),
1124
+ participant_profile_id = coalesce(nullif(excluded.participant_profile_id, ''), dm_conversations.participant_profile_id),
1125
+ title = coalesce(nullif(excluded.title, ''), dm_conversations.title),
1126
+ last_message_at = max(dm_conversations.last_message_at, excluded.last_message_at),
1127
+ unread_count = max(dm_conversations.unread_count, excluded.unread_count),
1128
+ needs_reply = max(dm_conversations.needs_reply, excluded.needs_reply)
1129
+ `,
1130
+ conversations,
1131
+ [
1132
+ "id",
1133
+ "account_id",
1134
+ "participant_profile_id",
1135
+ "title",
1136
+ "last_message_at",
1137
+ "unread_count",
1138
+ "needs_reply",
1139
+ ],
1140
+ );
1141
+ insertRows(
1142
+ db,
1143
+ `
1144
+ insert into dm_messages (
1145
+ id, conversation_id, sender_profile_id, text, created_at, direction, is_replied, media_count
1146
+ ) values (?, ?, ?, ?, ?, ?, ?, ?)
1147
+ on conflict(id) do update set
1148
+ conversation_id = coalesce(nullif(excluded.conversation_id, ''), dm_messages.conversation_id),
1149
+ sender_profile_id = coalesce(nullif(excluded.sender_profile_id, ''), dm_messages.sender_profile_id),
1150
+ text = coalesce(nullif(excluded.text, ''), dm_messages.text),
1151
+ created_at = min(dm_messages.created_at, excluded.created_at),
1152
+ direction = coalesce(nullif(excluded.direction, ''), dm_messages.direction),
1153
+ is_replied = max(dm_messages.is_replied, excluded.is_replied),
1154
+ media_count = max(dm_messages.media_count, excluded.media_count)
1155
+ `,
1156
+ messages,
1157
+ [
1158
+ "id",
1159
+ "conversation_id",
1160
+ "sender_profile_id",
1161
+ "text",
1162
+ "created_at",
1163
+ "direction",
1164
+ "is_replied",
1165
+ "media_count",
1166
+ ],
1167
+ );
1168
+ insertFtsRows(db, "dm_fts", "message_id", messages, "id", "text", dmFtsIds);
1169
+ insertRows(
1170
+ db,
1171
+ `
1172
+ insert into blocks (account_id, profile_id, source, created_at)
1173
+ values (?, ?, ?, ?)
1174
+ on conflict(account_id, profile_id) do update set
1175
+ source = coalesce(nullif(excluded.source, ''), blocks.source),
1176
+ created_at = min(blocks.created_at, excluded.created_at)
1177
+ `,
1178
+ blocks,
1179
+ ["account_id", "profile_id", "source", "created_at"],
1180
+ );
1181
+ insertRows(
1182
+ db,
1183
+ `
1184
+ insert into mutes (account_id, profile_id, source, created_at)
1185
+ values (?, ?, ?, ?)
1186
+ on conflict(account_id, profile_id) do update set
1187
+ source = coalesce(nullif(excluded.source, ''), mutes.source),
1188
+ created_at = min(mutes.created_at, excluded.created_at)
1189
+ `,
1190
+ mutes,
1191
+ ["account_id", "profile_id", "source", "created_at"],
1192
+ );
1193
+ insertRows(
1194
+ db,
1195
+ `
1196
+ insert into tweet_actions (id, account_id, tweet_id, kind, body, created_at)
1197
+ values (?, ?, ?, ?, ?, ?)
1198
+ on conflict(id) do update set
1199
+ account_id = coalesce(nullif(excluded.account_id, ''), tweet_actions.account_id),
1200
+ tweet_id = coalesce(excluded.tweet_id, tweet_actions.tweet_id),
1201
+ kind = coalesce(nullif(excluded.kind, ''), tweet_actions.kind),
1202
+ body = coalesce(nullif(excluded.body, ''), tweet_actions.body),
1203
+ created_at = min(tweet_actions.created_at, excluded.created_at)
1204
+ `,
1205
+ actions,
1206
+ ["id", "account_id", "tweet_id", "kind", "body", "created_at"],
1207
+ );
1208
+ insertRows(
1209
+ db,
1210
+ `
1211
+ insert into ai_scores (
1212
+ entity_kind, entity_id, model, score, summary, reasoning, updated_at
1213
+ ) values (?, ?, ?, ?, ?, ?, ?)
1214
+ on conflict(entity_kind, entity_id) do update set
1215
+ model = coalesce(nullif(excluded.model, ''), ai_scores.model),
1216
+ score = max(ai_scores.score, excluded.score),
1217
+ summary = coalesce(nullif(excluded.summary, ''), ai_scores.summary),
1218
+ reasoning = coalesce(nullif(excluded.reasoning, ''), ai_scores.reasoning),
1219
+ updated_at = max(ai_scores.updated_at, excluded.updated_at)
1220
+ `,
1221
+ scores,
1222
+ [
1223
+ "entity_kind",
1224
+ "entity_id",
1225
+ "model",
1226
+ "score",
1227
+ "summary",
1228
+ "reasoning",
1229
+ "updated_at",
1230
+ ],
1231
+ );
1232
+ })();
1233
+
1234
+ return {
1235
+ ok: true,
1236
+ repoPath: resolvedRepoPath,
1237
+ mode,
1238
+ manifest,
1239
+ ...(validation ? { validation } : {}),
1240
+ fingerprint: getBackupDatabaseFingerprint(db),
1241
+ };
1242
+ }
1243
+
1244
+ export async function syncBackup({
1245
+ repoPath,
1246
+ remote,
1247
+ db = getNativeDb({ seedDemoData: false }),
1248
+ message = "archive: sync birdclaw backup",
1249
+ }: {
1250
+ repoPath: string;
1251
+ remote?: string;
1252
+ db?: Database.Database;
1253
+ message?: string;
1254
+ }): Promise<BackupSyncResult> {
1255
+ const resolvedRepoPath = path.resolve(repoPath);
1256
+ await ensureBackupGitRepo({ repoPath: resolvedRepoPath, remote });
1257
+ const pulled = await pullBackupGitRepo(resolvedRepoPath);
1258
+ const manifestExists = existsSync(path.join(resolvedRepoPath, MANIFEST_PATH));
1259
+ const importResult = manifestExists
1260
+ ? await importBackup({
1261
+ repoPath: resolvedRepoPath,
1262
+ db,
1263
+ mode: "merge",
1264
+ })
1265
+ : undefined;
1266
+ const exportResult = await exportBackup({
1267
+ repoPath: resolvedRepoPath,
1268
+ db,
1269
+ commit: true,
1270
+ push: true,
1271
+ message,
1272
+ });
1273
+
1274
+ return {
1275
+ ok: true,
1276
+ repoPath: resolvedRepoPath,
1277
+ ...(remote ? { remote } : {}),
1278
+ pulled,
1279
+ imported: Boolean(importResult),
1280
+ ...(importResult ? { importResult } : {}),
1281
+ exportResult,
1282
+ };
1283
+ }
1284
+
1285
+ export async function updateBackupFromGit({
1286
+ repoPath,
1287
+ remote,
1288
+ db = getNativeDb({ seedDemoData: false }),
1289
+ }: {
1290
+ repoPath: string;
1291
+ remote?: string;
1292
+ db?: Database.Database;
1293
+ }): Promise<{
1294
+ ok: true;
1295
+ repoPath: string;
1296
+ remote?: string;
1297
+ pulled: boolean;
1298
+ imported: boolean;
1299
+ importResult?: BackupImportResult;
1300
+ }> {
1301
+ const resolvedRepoPath = path.resolve(repoPath);
1302
+ await ensureBackupGitRepo({ repoPath: resolvedRepoPath, remote });
1303
+ const pulled = await pullBackupGitRepo(resolvedRepoPath);
1304
+ const manifestExists = existsSync(path.join(resolvedRepoPath, MANIFEST_PATH));
1305
+ const importResult = manifestExists
1306
+ ? await importBackup({
1307
+ repoPath: resolvedRepoPath,
1308
+ db,
1309
+ mode: "merge",
1310
+ })
1311
+ : undefined;
1312
+
1313
+ return {
1314
+ ok: true,
1315
+ repoPath: resolvedRepoPath,
1316
+ ...(remote ? { remote } : {}),
1317
+ pulled,
1318
+ imported: Boolean(importResult),
1319
+ ...(importResult ? { importResult } : {}),
1320
+ };
1321
+ }
1322
+
1323
+ function readAutoSyncState(db: Database.Database) {
1324
+ const row = db
1325
+ .prepare("select value_json from sync_cache where cache_key = ?")
1326
+ .get(AUTO_SYNC_CACHE_KEY) as { value_json: string } | undefined;
1327
+ if (!row) {
1328
+ return null;
1329
+ }
1330
+ try {
1331
+ return JSON.parse(row.value_json) as {
1332
+ checkedAt?: string;
1333
+ ok?: boolean;
1334
+ error?: string;
1335
+ };
1336
+ } catch {
1337
+ return null;
1338
+ }
1339
+ }
1340
+
1341
+ function writeAutoSyncState(
1342
+ db: Database.Database,
1343
+ value: { checkedAt: string; ok: boolean; error?: string },
1344
+ ) {
1345
+ db.prepare(
1346
+ `
1347
+ insert into sync_cache (cache_key, value_json, updated_at)
1348
+ values (?, ?, ?)
1349
+ on conflict(cache_key) do update set
1350
+ value_json = excluded.value_json,
1351
+ updated_at = excluded.updated_at
1352
+ `,
1353
+ ).run(AUTO_SYNC_CACHE_KEY, JSON.stringify(value), value.checkedAt);
1354
+ }
1355
+
1356
+ function resolveAutoSyncConfig() {
1357
+ const backup = getBirdclawConfig().backup;
1358
+ if (!backup || backup.autoSync === false) {
1359
+ return null;
1360
+ }
1361
+ const repoPath = backup.repoPath?.trim();
1362
+ const remote = backup.remote?.trim();
1363
+ if (!repoPath && !remote) {
1364
+ return null;
1365
+ }
1366
+ const staleAfterSeconds =
1367
+ typeof backup.staleAfterSeconds === "number" &&
1368
+ Number.isFinite(backup.staleAfterSeconds) &&
1369
+ backup.staleAfterSeconds >= 0
1370
+ ? Math.floor(backup.staleAfterSeconds)
1371
+ : DEFAULT_STALE_AFTER_SECONDS;
1372
+
1373
+ return {
1374
+ repoPath:
1375
+ repoPath ||
1376
+ path.join(process.env.HOME || ".", "Projects", "backup-birdclaw"),
1377
+ remote,
1378
+ staleAfterSeconds,
1379
+ };
1380
+ }
1381
+
1382
+ export async function maybeAutoUpdateBackup(
1383
+ db?: Database.Database,
1384
+ ): Promise<BackupAutoUpdateResult> {
1385
+ if (process.env.BIRDCLAW_BACKUP_AUTO_SYNC === "0") {
1386
+ return {
1387
+ ok: true,
1388
+ enabled: false,
1389
+ skipped: true,
1390
+ reason: "disabled by BIRDCLAW_BACKUP_AUTO_SYNC=0",
1391
+ };
1392
+ }
1393
+ const config = resolveAutoSyncConfig();
1394
+ if (!config) {
1395
+ return {
1396
+ ok: true,
1397
+ enabled: false,
1398
+ skipped: true,
1399
+ reason: "backup auto-sync is not configured",
1400
+ };
1401
+ }
1402
+
1403
+ const database = db ?? getNativeDb({ seedDemoData: false });
1404
+ const state = readAutoSyncState(database);
1405
+ const checkedAt = state?.checkedAt ? new Date(state.checkedAt).getTime() : 0;
1406
+ const ageMs = Date.now() - checkedAt;
1407
+ if (ageMs >= 0 && ageMs < config.staleAfterSeconds * 1000) {
1408
+ return {
1409
+ ok: true,
1410
+ enabled: true,
1411
+ skipped: true,
1412
+ reason: "backup auto-sync is fresh",
1413
+ repoPath: config.repoPath,
1414
+ ...(config.remote ? { remote: config.remote } : {}),
1415
+ };
1416
+ }
1417
+
1418
+ const now = new Date().toISOString();
1419
+ try {
1420
+ const result = await updateBackupFromGit({
1421
+ repoPath: config.repoPath,
1422
+ remote: config.remote,
1423
+ db: database,
1424
+ });
1425
+ writeAutoSyncState(database, { checkedAt: now, ok: true });
1426
+ return {
1427
+ ok: true,
1428
+ enabled: true,
1429
+ skipped: false,
1430
+ repoPath: result.repoPath,
1431
+ ...(result.remote ? { remote: result.remote } : {}),
1432
+ pulled: result.pulled,
1433
+ imported: result.imported,
1434
+ };
1435
+ } catch (error) {
1436
+ const message = error instanceof Error ? error.message : String(error);
1437
+ writeAutoSyncState(database, { checkedAt: now, ok: false, error: message });
1438
+ return {
1439
+ ok: false,
1440
+ enabled: true,
1441
+ skipped: false,
1442
+ repoPath: config.repoPath,
1443
+ ...(config.remote ? { remote: config.remote } : {}),
1444
+ error: message,
1445
+ };
1446
+ }
1447
+ }
1448
+
1449
+ export async function maybeAutoSyncBackup(
1450
+ db?: Database.Database,
1451
+ ): Promise<BackupAutoUpdateResult> {
1452
+ if (process.env.BIRDCLAW_BACKUP_AUTO_SYNC === "0") {
1453
+ return {
1454
+ ok: true,
1455
+ enabled: false,
1456
+ skipped: true,
1457
+ reason: "disabled by BIRDCLAW_BACKUP_AUTO_SYNC=0",
1458
+ };
1459
+ }
1460
+ const config = resolveAutoSyncConfig();
1461
+ if (!config) {
1462
+ return {
1463
+ ok: true,
1464
+ enabled: false,
1465
+ skipped: true,
1466
+ reason: "backup auto-sync is not configured",
1467
+ };
1468
+ }
1469
+ const database = db ?? getNativeDb({ seedDemoData: false });
1470
+ const now = new Date().toISOString();
1471
+ try {
1472
+ const result = await syncBackup({
1473
+ repoPath: config.repoPath,
1474
+ remote: config.remote,
1475
+ db: database,
1476
+ });
1477
+ writeAutoSyncState(database, { checkedAt: now, ok: true });
1478
+ return {
1479
+ ok: true,
1480
+ enabled: true,
1481
+ skipped: false,
1482
+ repoPath: result.repoPath,
1483
+ ...(result.remote ? { remote: result.remote } : {}),
1484
+ pulled: result.pulled,
1485
+ imported: result.imported,
1486
+ };
1487
+ } catch (error) {
1488
+ const message = error instanceof Error ? error.message : String(error);
1489
+ writeAutoSyncState(database, {
1490
+ checkedAt: now,
1491
+ ok: false,
1492
+ error: message,
1493
+ });
1494
+ return {
1495
+ ok: false,
1496
+ enabled: true,
1497
+ skipped: false,
1498
+ repoPath: config.repoPath,
1499
+ ...(config.remote ? { remote: config.remote } : {}),
1500
+ error: message,
1501
+ };
1502
+ }
1503
+ }
1504
+
1505
+ export async function validateBackup(
1506
+ repoPath: string,
1507
+ ): Promise<BackupValidationResult> {
1508
+ const resolvedRepoPath = path.resolve(repoPath);
1509
+ const errors: string[] = [];
1510
+ let manifest: BackupManifest;
1511
+ try {
1512
+ manifest = await readManifest(resolvedRepoPath);
1513
+ } catch (error) {
1514
+ return {
1515
+ ok: false,
1516
+ repoPath: resolvedRepoPath,
1517
+ files: [],
1518
+ counts: {},
1519
+ backupHash: "",
1520
+ errors: [error instanceof Error ? error.message : String(error)],
1521
+ };
1522
+ }
1523
+
1524
+ const results = await Promise.all(
1525
+ manifest.files.map(async (expected) => {
1526
+ const fileErrors: string[] = [];
1527
+ let file: BackupFileManifest | undefined;
1528
+ try {
1529
+ const content = await fs.readFile(
1530
+ path.join(resolvedRepoPath, expected.path),
1531
+ );
1532
+ const text = content.toString("utf8");
1533
+ const rows = text.split("\n").filter((line) => line.length > 0);
1534
+ for (const [index, line] of rows.entries()) {
1535
+ try {
1536
+ JSON.parse(line);
1537
+ } catch (error) {
1538
+ fileErrors.push(
1539
+ `${expected.path}:${index + 1}: ${
1540
+ error instanceof Error ? error.message : String(error)
1541
+ }`,
1542
+ );
1543
+ }
1544
+ }
1545
+ file = {
1546
+ path: expected.path,
1547
+ rows: rows.length,
1548
+ sha256: sha256(content),
1549
+ bytes: content.byteLength,
1550
+ };
1551
+ } catch (error) {
1552
+ fileErrors.push(
1553
+ `${expected.path}: ${error instanceof Error ? error.message : String(error)}`,
1554
+ );
1555
+ }
1556
+ return { file, errors: fileErrors };
1557
+ }),
1558
+ );
1559
+
1560
+ const files: BackupFileManifest[] = [];
1561
+ for (const result of results) {
1562
+ errors.push(...result.errors);
1563
+ if (result.file) {
1564
+ files.push(result.file);
1565
+ }
1566
+ }
1567
+
1568
+ for (const expected of manifest.files) {
1569
+ const file = files.find((entry) => entry.path === expected.path);
1570
+ if (!file) {
1571
+ continue;
1572
+ }
1573
+ if (file.rows !== expected.rows) {
1574
+ errors.push(`${file.path}: row count ${file.rows} != ${expected.rows}`);
1575
+ }
1576
+ if (file.sha256 !== expected.sha256) {
1577
+ errors.push(`${file.path}: sha256 ${file.sha256} != ${expected.sha256}`);
1578
+ }
1579
+ if (file.bytes !== expected.bytes) {
1580
+ errors.push(`${file.path}: bytes ${file.bytes} != ${expected.bytes}`);
1581
+ }
1582
+ }
1583
+
1584
+ const counts = computeCounts(files);
1585
+ const backupHash = computeBackupHash(files);
1586
+ if (backupHash !== manifest.backupHash) {
1587
+ errors.push(`backup hash ${backupHash} != ${manifest.backupHash}`);
1588
+ }
1589
+ if (canonicalStringify(counts) !== canonicalStringify(manifest.counts)) {
1590
+ errors.push("manifest counts do not match backup files");
1591
+ }
1592
+
1593
+ return {
1594
+ ok: errors.length === 0,
1595
+ repoPath: resolvedRepoPath,
1596
+ files,
1597
+ counts,
1598
+ backupHash,
1599
+ errors,
1600
+ };
1601
+ }
1602
+
1603
+ export function getBackupDatabaseFingerprint(
1604
+ db = getNativeDb({ seedDemoData: false }),
1605
+ ): BackupDatabaseFingerprint {
1606
+ const counts: Record<string, number> = {};
1607
+ const hash = createHash("sha256");
1608
+ for (const rowSet of getExportRowSets(db)) {
1609
+ counts[rowSet.logicalName] = rowSet.rows.length;
1610
+ hash.update(`${rowSet.logicalName}\n`);
1611
+ for (const row of rowSet.rows) {
1612
+ hash.update(canonicalStringify(row));
1613
+ hash.update("\n");
1614
+ }
1615
+ }
1616
+ return { counts, hash: hash.digest("hex") };
1617
+ }