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.
- package/CHANGELOG.md +32 -0
- package/README.md +117 -10
- package/bin/birdclaw.mjs +3 -1
- package/package.json +4 -3
- package/src/cli.ts +237 -4
- package/src/components/AppNav.tsx +2 -0
- package/src/components/SavedTimelineView.tsx +115 -0
- package/src/components/TimelineCard.tsx +26 -17
- package/src/lib/archive-import.ts +193 -16
- package/src/lib/backup.ts +1617 -0
- package/src/lib/bird.ts +163 -24
- package/src/lib/blocks-write.ts +2 -2
- package/src/lib/blocks.ts +2 -7
- package/src/lib/bookmark-sync-job.ts +446 -0
- package/src/lib/config.ts +6 -0
- package/src/lib/db.ts +82 -4
- package/src/lib/mentions-live.ts +2 -5
- package/src/lib/moderation-target.ts +4 -4
- package/src/lib/mutes-write.ts +2 -2
- package/src/lib/queries.ts +70 -1
- package/src/lib/seed.ts +4 -2
- package/src/lib/theme-transition.ts +6 -10
- package/src/lib/theme.tsx +13 -5
- package/src/lib/timeline-collections-live.ts +406 -0
- package/src/lib/types.ts +9 -1
- package/src/lib/xurl.ts +90 -4
- package/src/routeTree.gen.ts +42 -0
- package/src/routes/__root.tsx +6 -1
- package/src/routes/api/action.tsx +14 -4
- package/src/routes/api/inbox.tsx +3 -1
- package/src/routes/api/query.tsx +20 -2
- package/src/routes/api/status.tsx +6 -3
- package/src/routes/bookmarks.tsx +18 -0
- package/src/routes/likes.tsx +18 -0
- package/tsconfig.json +0 -1
|
@@ -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
|
+
}
|