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