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