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.
Files changed (116) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +75 -5
  3. package/package.json +8 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +812 -37
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +37 -7
  9. package/src/components/AvatarChip.tsx +9 -3
  10. package/src/components/DmWorkspace.tsx +18 -8
  11. package/src/components/LinkPreviewCard.tsx +40 -18
  12. package/src/components/MarkdownViewer.tsx +818 -0
  13. package/src/components/ProfileAnalysisStream.tsx +428 -0
  14. package/src/components/ProfilePreview.tsx +120 -9
  15. package/src/components/SavedTimelineView.tsx +30 -8
  16. package/src/components/SyncNowButton.tsx +60 -25
  17. package/src/components/ThemeSlider.tsx +55 -50
  18. package/src/components/TimelineCard.tsx +240 -92
  19. package/src/components/TimelineRouteFrame.tsx +38 -8
  20. package/src/components/TweetMediaGrid.tsx +87 -38
  21. package/src/components/TweetRichText.tsx +45 -17
  22. package/src/components/account-selection.ts +64 -0
  23. package/src/components/useTimelineRouteData.ts +97 -13
  24. package/src/lib/account-sync-job.ts +666 -0
  25. package/src/lib/actions-transport.ts +216 -146
  26. package/src/lib/api-client.ts +128 -53
  27. package/src/lib/archive-finder.ts +78 -63
  28. package/src/lib/archive-import.ts +1593 -1291
  29. package/src/lib/authored-live.ts +262 -204
  30. package/src/lib/avatar-cache.ts +208 -43
  31. package/src/lib/backup.ts +1536 -954
  32. package/src/lib/bird-actions.ts +139 -57
  33. package/src/lib/bird-command.ts +101 -28
  34. package/src/lib/bird.ts +582 -194
  35. package/src/lib/blocklist.ts +40 -23
  36. package/src/lib/blocks-write.ts +129 -80
  37. package/src/lib/blocks.ts +165 -97
  38. package/src/lib/bookmark-sync-job.ts +250 -160
  39. package/src/lib/config.ts +35 -2
  40. package/src/lib/conversation-surface.ts +79 -48
  41. package/src/lib/data-sources.ts +219 -0
  42. package/src/lib/db.ts +95 -4
  43. package/src/lib/dms-live.ts +720 -66
  44. package/src/lib/effect-runtime.ts +45 -0
  45. package/src/lib/follow-graph.ts +224 -180
  46. package/src/lib/geocoding.ts +296 -0
  47. package/src/lib/http-effect.ts +222 -0
  48. package/src/lib/inbox.ts +74 -43
  49. package/src/lib/link-index.ts +88 -76
  50. package/src/lib/link-insights.ts +24 -0
  51. package/src/lib/link-preview-metadata.ts +472 -52
  52. package/src/lib/location.ts +137 -0
  53. package/src/lib/media-fetch.ts +286 -213
  54. package/src/lib/mention-threads-live.ts +445 -288
  55. package/src/lib/mentions-live.ts +549 -354
  56. package/src/lib/moderation-target.ts +102 -65
  57. package/src/lib/moderation-write.ts +77 -18
  58. package/src/lib/mutes-write.ts +129 -80
  59. package/src/lib/mutes.ts +8 -1
  60. package/src/lib/network-map.ts +382 -0
  61. package/src/lib/openai.ts +84 -53
  62. package/src/lib/period-digest.ts +1317 -0
  63. package/src/lib/profile-affiliation-hydration.ts +93 -54
  64. package/src/lib/profile-analysis.ts +1272 -0
  65. package/src/lib/profile-bio-entities.ts +1 -1
  66. package/src/lib/profile-hydration.ts +124 -72
  67. package/src/lib/profile-replies.ts +60 -43
  68. package/src/lib/profile-resolver.ts +402 -294
  69. package/src/lib/queries.ts +983 -203
  70. package/src/lib/research.ts +165 -120
  71. package/src/lib/search-discussion.ts +1016 -0
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +325 -51
  75. package/src/lib/tweet-account-edges.ts +2 -0
  76. package/src/lib/tweet-lookup.ts +30 -19
  77. package/src/lib/tweet-render.ts +141 -1
  78. package/src/lib/tweet-search-live.ts +565 -0
  79. package/src/lib/types.ts +75 -3
  80. package/src/lib/ui.ts +31 -8
  81. package/src/lib/url-expansion.ts +226 -55
  82. package/src/lib/url-safety.ts +220 -0
  83. package/src/lib/web-sync.ts +222 -149
  84. package/src/lib/whois.ts +166 -147
  85. package/src/lib/x-web.ts +102 -71
  86. package/src/lib/xurl-rate-limits.ts +267 -0
  87. package/src/lib/xurl.ts +1185 -405
  88. package/src/routeTree.gen.ts +273 -0
  89. package/src/routes/__root.tsx +24 -5
  90. package/src/routes/api/action.tsx +127 -78
  91. package/src/routes/api/avatar.tsx +39 -30
  92. package/src/routes/api/blocks.tsx +26 -23
  93. package/src/routes/api/conversation.tsx +25 -14
  94. package/src/routes/api/data-sources.tsx +24 -0
  95. package/src/routes/api/inbox.tsx +27 -21
  96. package/src/routes/api/link-insights.tsx +31 -25
  97. package/src/routes/api/link-preview.tsx +25 -21
  98. package/src/routes/api/network-map.tsx +55 -0
  99. package/src/routes/api/period-digest.tsx +133 -0
  100. package/src/routes/api/profile-analysis.tsx +152 -0
  101. package/src/routes/api/profile-hydrate.tsx +31 -28
  102. package/src/routes/api/query.tsx +80 -55
  103. package/src/routes/api/search-discussion.tsx +169 -0
  104. package/src/routes/api/status.tsx +18 -10
  105. package/src/routes/api/sync.tsx +75 -29
  106. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  107. package/src/routes/data-sources.tsx +255 -0
  108. package/src/routes/discuss.tsx +419 -0
  109. package/src/routes/dms.tsx +95 -28
  110. package/src/routes/inbox.tsx +32 -19
  111. package/src/routes/network-map.tsx +1035 -0
  112. package/src/routes/profile-analyze.tsx +112 -0
  113. package/src/routes/profiles.$handle.tsx +228 -0
  114. package/src/routes/rate-limits.tsx +309 -0
  115. package/src/routes/today.tsx +455 -0
  116. 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, last_message_at,
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
- async function writeJsonlFile(
597
+ function writeJsonlFileEffect(
527
598
  repoPath: string,
528
599
  relativePath: string,
529
600
  rows: JsonRecord[],
530
- ) {
531
- const fullPath = path.join(repoPath, relativePath);
532
- const content = `${rows.map((row) => jsonlStringify(row)).join("\n")}\n`;
533
- await fs.mkdir(path.dirname(fullPath), { recursive: true });
534
- let shouldWrite = true;
535
- try {
536
- shouldWrite = (await fs.readFile(fullPath, "utf8")) !== content;
537
- } catch {
538
- shouldWrite = true;
539
- }
540
- if (shouldWrite) {
541
- await fs.writeFile(fullPath, content, "utf8");
542
- }
543
- return {
544
- path: relativePath,
545
- rows: rows.length,
546
- sha256: sha256(content),
547
- bytes: Buffer.byteLength(content),
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
- async function removeStaleBackupFiles(
641
+ function removeStaleBackupFilesEffect(
552
642
  repoPath: string,
553
643
  expectedPaths: Set<string>,
554
644
  directory = DATA_DIR,
555
- ) {
556
- const fullDirectory = path.join(repoPath, directory);
557
- let entries: Array<{ name: string; isDirectory: () => boolean }> = [];
558
- try {
559
- entries = await fs.readdir(fullDirectory, { withFileTypes: true });
560
- } catch {
561
- return;
562
- }
563
-
564
- await Promise.all(
565
- entries.map(async (entry) => {
566
- const relativePath = path.posix.join(directory, entry.name);
567
- const fullPath = path.join(repoPath, relativePath);
568
- if (entry.isDirectory()) {
569
- await removeStaleBackupFiles(repoPath, expectedPaths, relativePath);
570
- const remaining = await fs.readdir(fullPath);
571
- if (remaining.length === 0) {
572
- await fs.rmdir(fullPath);
573
- }
574
- return;
575
- }
576
- if (relativePath.endsWith(".jsonl") && !expectedPaths.has(relativePath)) {
577
- await fs.rm(fullPath, { force: true });
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
- async function ensureBackupReadme(repoPath: string) {
626
- const readmePath = path.join(repoPath, "README.md");
627
- if (existsSync(readmePath)) {
628
- return;
629
- }
630
- await fs.writeFile(
631
- readmePath,
632
- `# Birdclaw Store
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
- "utf8",
669
- );
789
+ "utf8",
790
+ ),
791
+ );
792
+ });
670
793
  }
671
794
 
672
- async function writeManifest(repoPath: string, manifest: BackupManifest) {
673
- const manifestPath = path.join(repoPath, MANIFEST_PATH);
674
- const content = `${canonicalStringify(manifest as unknown as JsonRecord)}\n`;
675
- try {
676
- if ((await fs.readFile(manifestPath, "utf8")) === content) {
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
- } catch {
680
- // New backup repo.
681
- }
682
- await fs.writeFile(manifestPath, content, "utf8");
813
+ yield* tryPromise(() => fs.writeFile(manifestPath, content, "utf8"));
814
+ });
683
815
  }
684
816
 
685
- async function readPreviousManifest(repoPath: string) {
686
- try {
687
- return await readManifest(repoPath);
688
- } catch {
689
- return undefined;
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
- async function maybeCommitAndPush({
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
- try {
709
- await execFileAsync("git", [
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
- } catch {
716
- await execFileAsync("git", ["-C", repoPath, "init"]);
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
- try {
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
- "config",
746
- "user.name",
747
- "Birdclaw Backup",
855
+ "add",
856
+ "README.md",
857
+ MANIFEST_PATH,
858
+ DATA_DIR,
748
859
  ]);
749
- }
750
860
 
751
- let committed = false;
752
- let commitHash: string | undefined;
753
- try {
754
- await execFileAsync("git", ["-C", repoPath, "diff", "--cached", "--quiet"]);
755
- } catch {
756
- await execFileAsync("git", ["-C", repoPath, "commit", "-m", message]);
757
- committed = true;
758
- const { stdout } = await execFileAsync("git", [
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
- "rev-parse",
762
- "HEAD",
763
- ]);
764
- commitHash = stdout.trim();
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
- if (push) {
768
- try {
769
- await execFileAsync("git", ["-C", repoPath, "push"]);
770
- } catch {
771
- await execFileAsync("git", [
772
- "-C",
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
- return { committed, pushed: push, commit: commitHash };
919
+ return {
920
+ committed: commitResult.committed,
921
+ pushed: push,
922
+ commit: commitResult.commitHash,
923
+ };
924
+ });
783
925
  }
784
926
 
785
- async function isGitRepo(repoPath: string) {
786
- try {
787
- await execFileAsync("git", [
788
- "-C",
789
- repoPath,
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
- async function hasGitCommits(repoPath: string) {
800
- try {
801
- await execFileAsync("git", [
802
- "-C",
803
- repoPath,
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
- async function ensureBackupGitRepo({
941
+ function ensureBackupGitRepoEffect({
815
942
  repoPath,
816
943
  remote,
817
944
  }: {
818
945
  repoPath: string;
819
946
  remote?: string;
820
947
  }) {
821
- if (!(await isGitRepo(repoPath))) {
822
- if (remote && !existsSync(repoPath)) {
823
- await execFileAsync("git", ["clone", remote, repoPath]);
824
- } else {
825
- await fs.mkdir(repoPath, { recursive: true });
826
- await execFileAsync("git", ["-C", repoPath, "init"]);
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
- if (remote) {
831
- try {
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
- if (stdout.trim() !== remote) {
840
- await execFileAsync("git", [
841
- "-C",
842
- repoPath,
843
- "remote",
844
- "set-url",
845
- "origin",
846
- remote,
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
- if (remote && !(await hasGitCommits(repoPath))) {
862
- try {
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
- "checkout",
868
- "-B",
989
+ "fetch",
990
+ "origin",
869
991
  "main",
870
- "origin/main",
871
- ]);
872
- return;
873
- } catch {
874
- // Empty remote or no main branch yet; create the first main commit locally.
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
- if (!(await hasGitCommits(repoPath))) {
879
- await execFileAsync("git", ["-C", repoPath, "checkout", "-B", "main"]);
880
- }
1002
+ if (!(yield* hasGitCommitsEffect(repoPath))) {
1003
+ yield* gitEffect(["-C", repoPath, "checkout", "-B", "main"]);
1004
+ }
1005
+ });
881
1006
  }
882
1007
 
883
- async function pullBackupGitRepo(repoPath: string) {
884
- if (!(await isGitRepo(repoPath)) || !(await hasGitCommits(repoPath))) {
885
- return false;
886
- }
887
- try {
888
- await execFileAsync("git", ["-C", repoPath, "pull", "--ff-only"]);
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 async function exportBackup({
1028
+ export function exportBackupEffect({
908
1029
  repoPath,
909
- db = getNativeDb({ seedDemoData: false }),
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
- }): Promise<BackupExportResult> {
922
- const resolvedRepoPath = path.resolve(repoPath);
923
- await fs.mkdir(resolvedRepoPath, { recursive: true });
924
- await ensureBackupReadme(resolvedRepoPath);
925
-
926
- const shards = buildShards(db);
927
- const shardEntries = [...shards.entries()].sort(([left], [right]) =>
928
- left.localeCompare(right),
929
- );
930
- const expectedPaths = new Set(
931
- shardEntries.map(([relativePath]) => relativePath),
932
- );
933
- const files = await Promise.all(
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
- const validation = validate
957
- ? await validateBackup(resolvedRepoPath)
958
- : {
959
- ok: true,
960
- repoPath: resolvedRepoPath,
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
- const git = await maybeCommitAndPush({
973
- repoPath: resolvedRepoPath,
974
- message,
975
- commit,
976
- push,
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
- return {
980
- ok: true,
981
- repoPath: resolvedRepoPath,
982
- manifest,
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
- async function readManifest(repoPath: string): Promise<BackupManifest> {
989
- const content = await fs.readFile(path.join(repoPath, MANIFEST_PATH), "utf8");
990
- const parsed = JSON.parse(content) as BackupManifest;
991
- if (parsed.app !== "birdclaw") {
992
- throw new Error("Backup manifest is not a birdclaw backup");
993
- }
994
- if (parsed.schemaVersion !== BACKUP_SCHEMA_VERSION) {
995
- throw new Error(
996
- `Unsupported backup schema version ${String(parsed.schemaVersion)}`,
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 parsed;
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
- async function readJsonlFile(repoPath: string, relativePath: string) {
1003
- const content = await fs.readFile(path.join(repoPath, relativePath), "utf8");
1004
- return content
1005
- .split("\n")
1006
- .filter((line) => line.length > 0)
1007
- .map((line) => JSON.parse(line) as JsonRecord);
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
- async function readJsonlFiles(repoPath: string, relativePaths: string[]) {
1011
- const nestedRows = await Promise.all(
1012
- relativePaths.map((relativePath) => readJsonlFile(repoPath, relativePath)),
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
- function readFtsIds(
1040
- db: Database,
1041
- tableName: "tweets_fts" | "dm_fts",
1042
- idColumn: "tweet_id" | "message_id",
1043
- ) {
1044
- const rows = db
1045
- .prepare(`select ${idColumn} as id from ${tableName}`)
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 async function importBackup({
1492
+ export function importBackupEffect({
1102
1493
  repoPath,
1103
- db = getNativeDb({ seedDemoData: false }),
1494
+ db: providedDb,
1104
1495
  validate = true,
1105
1496
  mode = "merge",
1106
- }: {
1107
- repoPath: string;
1108
- db?: Database;
1109
- validate?: boolean;
1110
- mode?: BackupImportMode;
1111
- }): Promise<BackupImportResult> {
1112
- const resolvedRepoPath = path.resolve(repoPath);
1113
- const manifest = await readManifest(resolvedRepoPath);
1114
- const validation = validate
1115
- ? await validateBackup(resolvedRepoPath)
1116
- : undefined;
1117
- if (validation && !validation.ok) {
1118
- throw new Error(
1119
- `Backup validation failed: ${validation.errors.join("; ")}`,
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
- const readRows = (predicate: (relativePath: string) => boolean) =>
1124
- readJsonlFiles(resolvedRepoPath, rowsForManifestPath(manifest, predicate));
1125
-
1126
- const [
1127
- accounts,
1128
- profiles,
1129
- profileAffiliations,
1130
- profileSnapshots,
1131
- profileBioEntities,
1132
- tweets,
1133
- collections,
1134
- timelineEdges,
1135
- conversations,
1136
- messages,
1137
- blocks,
1138
- mutes,
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
- accounts,
1199
- [
1200
- "id",
1201
- "name",
1202
- "handle",
1203
- "external_user_id",
1204
- "transport",
1205
- "is_default",
1206
- "created_at",
1207
- ],
1208
- );
1209
- insertRows(
1210
- db,
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
- profileSnapshots,
1226
- [
1227
- "profile_id",
1228
- "snapshot_hash",
1229
- "observed_at",
1230
- "last_seen_at",
1231
- "source",
1232
- "handle",
1233
- "display_name",
1234
- "bio",
1235
- "location",
1236
- "url",
1237
- "verified_type",
1238
- "followers_count",
1239
- "following_count",
1240
- "affiliations_json",
1241
- "raw_json",
1242
- ],
1243
- );
1244
- insertRows(
1245
- db,
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
- profileBioEntities,
1260
- [
1261
- "profile_id",
1262
- "kind",
1263
- "value",
1264
- "source",
1265
- "is_active",
1266
- "first_seen_at",
1267
- "last_seen_at",
1268
- "raw_json",
1269
- ],
1270
- );
1271
- insertRows(
1272
- db,
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
- profiles,
1305
- [
1306
- "id",
1307
- "handle",
1308
- "display_name",
1309
- "bio",
1310
- "followers_count",
1311
- "following_count",
1312
- "public_metrics_json",
1313
- "avatar_hue",
1314
- "avatar_url",
1315
- "location",
1316
- "url",
1317
- "verified_type",
1318
- "entities_json",
1319
- "raw_json",
1320
- "created_at",
1321
- ],
1322
- );
1323
- insertRows(
1324
- db,
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
- profileAffiliations,
1347
- [
1348
- "subject_profile_id",
1349
- "organization_profile_id",
1350
- "organization_name",
1351
- "organization_handle",
1352
- "badge_url",
1353
- "url",
1354
- "label",
1355
- "source",
1356
- "is_active",
1357
- "first_seen_at",
1358
- "last_seen_at",
1359
- "raw_json",
1360
- "updated_at",
1361
- ],
1362
- );
1363
- insertRows(
1364
- db,
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
- followSnapshots,
1385
- [
1386
- "id",
1387
- "account_id",
1388
- "direction",
1389
- "source",
1390
- "status",
1391
- "page_count",
1392
- "result_count",
1393
- "started_at",
1394
- "completed_at",
1395
- "raw_meta_json",
1396
- ],
1397
- );
1398
- insertRows(
1399
- db,
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
- followSnapshotMembers,
1409
- ["snapshot_id", "profile_id", "external_user_id", "position"],
1410
- );
1411
- insertRows(
1412
- db,
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
- followEdges,
1434
- [
1435
- "account_id",
1436
- "direction",
1437
- "profile_id",
1438
- "external_user_id",
1439
- "source",
1440
- "current",
1441
- "first_seen_at",
1442
- "last_seen_at",
1443
- "ended_at",
1444
- "updated_at",
1445
- ],
1446
- );
1447
- insertRows(
1448
- db,
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
- followEvents,
1464
- [
1465
- "id",
1466
- "account_id",
1467
- "direction",
1468
- "profile_id",
1469
- "external_user_id",
1470
- "kind",
1471
- "event_at",
1472
- "snapshot_id",
1473
- ],
1474
- );
1475
- insertRows(
1476
- db,
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
- tweets,
1510
- [
1511
- "id",
1512
- "account_id",
1513
- "author_profile_id",
1514
- "kind",
1515
- "text",
1516
- "created_at",
1517
- "is_replied",
1518
- "reply_to_id",
1519
- "like_count",
1520
- "media_count",
1521
- "bookmarked",
1522
- "liked",
1523
- "entities_json",
1524
- "media_json",
1525
- "quoted_tweet_id",
1526
- ],
1527
- );
1528
- insertFtsRows(
1529
- db,
1530
- "tweets_fts",
1531
- "tweet_id",
1532
- tweets,
1533
- "id",
1534
- "text",
1535
- tweetFtsIds,
1536
- );
1537
- insertRows(
1538
- db,
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
- collections,
1553
- [
1554
- "account_id",
1555
- "tweet_id",
1556
- "kind",
1557
- "collected_at",
1558
- "source",
1559
- "raw_json",
1560
- "updated_at",
1561
- ],
1562
- );
1563
- insertRows(
1564
- db,
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
- timelineEdges,
1582
- [
1583
- "account_id",
1584
- "tweet_id",
1585
- "kind",
1586
- "first_seen_at",
1587
- "last_seen_at",
1588
- "seen_count",
1589
- "source",
1590
- "raw_json",
1591
- "updated_at",
1592
- ],
1593
- );
1594
- insertRows(
1595
- db,
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
- conversations,
1609
- [
1610
- "id",
1611
- "account_id",
1612
- "participant_profile_id",
1613
- "title",
1614
- "last_message_at",
1615
- "unread_count",
1616
- "needs_reply",
1617
- ],
1618
- );
1619
- insertRows(
1620
- db,
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
- messages,
1635
- [
1636
- "id",
1637
- "conversation_id",
1638
- "sender_profile_id",
1639
- "text",
1640
- "created_at",
1641
- "direction",
1642
- "is_replied",
1643
- "media_count",
1644
- ],
1645
- );
1646
- insertFtsRows(db, "dm_fts", "message_id", messages, "id", "text", dmFtsIds);
1647
- insertRows(
1648
- db,
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
- urlExpansions,
1670
- [
1671
- "short_url",
1672
- "expanded_url",
1673
- "final_url",
1674
- "status",
1675
- "expanded_tweet_id",
1676
- "expanded_handle",
1677
- "title",
1678
- "description",
1679
- "image_url",
1680
- "site_name",
1681
- "error",
1682
- "source",
1683
- "updated_at",
1684
- ],
1685
- );
1686
- insertRows(
1687
- db,
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
- linkOccurrences,
1700
- [
1701
- "source_kind",
1702
- "source_id",
1703
- "source_position",
1704
- "short_url",
1705
- "account_id",
1706
- "conversation_id",
1707
- "direction",
1708
- "created_at",
1709
- ],
1710
- );
1711
- insertRows(
1712
- db,
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
- blocks,
1721
- ["account_id", "profile_id", "source", "created_at"],
1722
- );
1723
- insertRows(
1724
- db,
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
- mutes,
1733
- ["account_id", "profile_id", "source", "created_at"],
1734
- );
1735
- insertRows(
1736
- db,
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
- actions,
1748
- ["id", "account_id", "tweet_id", "kind", "body", "created_at"],
1749
- );
1750
- insertRows(
1751
- db,
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
- scores,
1764
- [
1765
- "entity_kind",
1766
- "entity_id",
1767
- "model",
1768
- "score",
1769
- "summary",
1770
- "reasoning",
1771
- "updated_at",
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
- return {
1777
- ok: true,
1778
- repoPath: resolvedRepoPath,
1779
- mode,
1780
- manifest,
1781
- ...(validation ? { validation } : {}),
1782
- fingerprint: getBackupDatabaseFingerprint(db),
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 async function syncBackup({
1787
- repoPath,
1788
- remote,
1789
- db = getNativeDb({ seedDemoData: false }),
1790
- message = "archive: sync birdclaw backup",
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 async function updateBackupFromGit({
2186
+ export function syncBackupEffect({
1828
2187
  repoPath,
1829
2188
  remote,
1830
- db = getNativeDb({ seedDemoData: false }),
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
- }): Promise<{
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
- return {
1856
- ok: true,
1857
- repoPath: resolvedRepoPath,
1858
- ...(remote ? { remote } : {}),
1859
- pulled,
1860
- imported: Boolean(importResult),
1861
- ...(importResult ? { importResult } : {}),
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
- export async function maybeAutoUpdateBackup(
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
- ): Promise<BackupAutoUpdateResult> {
1927
- if (process.env.BIRDCLAW_BACKUP_AUTO_SYNC === "0") {
1928
- return {
1929
- ok: true,
1930
- enabled: false,
1931
- skipped: true,
1932
- reason: "disabled by BIRDCLAW_BACKUP_AUTO_SYNC=0",
1933
- };
1934
- }
1935
- const config = resolveAutoSyncConfig();
1936
- if (!config) {
1937
- return {
1938
- ok: true,
1939
- enabled: false,
1940
- skipped: true,
1941
- reason: "backup auto-sync is not configured",
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
- const database = db ?? getNativeDb({ seedDemoData: false });
1946
- const state = readAutoSyncState(database);
1947
- const checkedAt = state?.checkedAt ? new Date(state.checkedAt).getTime() : 0;
1948
- const ageMs = Date.now() - checkedAt;
1949
- if (ageMs >= 0 && ageMs < config.staleAfterSeconds * 1000) {
1950
- return {
1951
- ok: true,
1952
- enabled: true,
1953
- skipped: true,
1954
- reason: "backup auto-sync is fresh",
1955
- repoPath: config.repoPath,
1956
- ...(config.remote ? { remote: config.remote } : {}),
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
- const now = new Date().toISOString();
1961
- try {
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
- writeAutoSyncState(database, { checkedAt: now, ok: true });
1968
- return {
1969
- ok: true,
1970
- enabled: true,
1971
- skipped: false,
1972
- repoPath: result.repoPath,
1973
- ...(result.remote ? { remote: result.remote } : {}),
1974
- pulled: result.pulled,
1975
- imported: result.imported,
1976
- };
1977
- } catch (error) {
1978
- const message = error instanceof Error ? error.message : String(error);
1979
- writeAutoSyncState(database, { checkedAt: now, ok: false, error: message });
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 async function maybeAutoSyncBackup(
2476
+ export function maybeAutoUpdateBackup(
1992
2477
  db?: Database,
1993
2478
  ): Promise<BackupAutoUpdateResult> {
1994
- if (process.env.BIRDCLAW_BACKUP_AUTO_SYNC === "0") {
1995
- return {
1996
- ok: true,
1997
- enabled: false,
1998
- skipped: true,
1999
- reason: "disabled by BIRDCLAW_BACKUP_AUTO_SYNC=0",
2000
- };
2001
- }
2002
- const config = resolveAutoSyncConfig();
2003
- if (!config) {
2004
- return {
2005
- ok: true,
2006
- enabled: false,
2007
- skipped: true,
2008
- reason: "backup auto-sync is not configured",
2009
- };
2010
- }
2011
- const database = db ?? getNativeDb({ seedDemoData: false });
2012
- const now = new Date().toISOString();
2013
- try {
2014
- const result = await syncBackup({
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
- writeAutoSyncState(database, { checkedAt: now, ok: true });
2020
- return {
2021
- ok: true,
2022
- enabled: true,
2023
- skipped: false,
2024
- repoPath: result.repoPath,
2025
- ...(result.remote ? { remote: result.remote } : {}),
2026
- pulled: result.pulled,
2027
- imported: result.imported,
2028
- };
2029
- } catch (error) {
2030
- const message = error instanceof Error ? error.message : String(error);
2031
- writeAutoSyncState(database, {
2032
- checkedAt: now,
2033
- ok: false,
2034
- error: message,
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 async function validateBackup(
2048
- repoPath: string,
2049
- ): Promise<BackupValidationResult> {
2050
- const resolvedRepoPath = path.resolve(repoPath);
2051
- const errors: string[] = [];
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
- const results = await Promise.all(
2067
- manifest.files.map(async (expected) => {
2068
- const fileErrors: string[] = [];
2069
- let file: BackupFileManifest | undefined;
2070
- try {
2071
- const content = await fs.readFile(
2072
- path.join(resolvedRepoPath, expected.path),
2073
- );
2074
- const text = content.toString("utf8");
2075
- const rows = text.split("\n").filter((line) => line.length > 0);
2076
- for (const [index, line] of rows.entries()) {
2077
- try {
2078
- JSON.parse(line);
2079
- } catch (error) {
2080
- fileErrors.push(
2081
- `${expected.path}:${index + 1}: ${
2082
- error instanceof Error ? error.message : String(error)
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
- file = {
2088
- path: expected.path,
2089
- rows: rows.length,
2090
- sha256: sha256(content),
2091
- bytes: content.byteLength,
2092
- };
2093
- } catch (error) {
2094
- fileErrors.push(
2095
- `${expected.path}: ${error instanceof Error ? error.message : String(error)}`,
2096
- );
2097
- }
2098
- return { file, errors: fileErrors };
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
- const files: BackupFileManifest[] = [];
2103
- for (const result of results) {
2104
- errors.push(...result.errors);
2105
- if (result.file) {
2106
- files.push(result.file);
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
- for (const expected of manifest.files) {
2111
- const file = files.find((entry) => entry.path === expected.path);
2112
- if (!file) {
2113
- continue;
2114
- }
2115
- if (file.rows !== expected.rows) {
2116
- errors.push(`${file.path}: row count ${file.rows} != ${expected.rows}`);
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
- if (file.sha256 !== expected.sha256) {
2119
- errors.push(`${file.path}: sha256 ${file.sha256} != ${expected.sha256}`);
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 (file.bytes !== expected.bytes) {
2122
- errors.push(`${file.path}: bytes ${file.bytes} != ${expected.bytes}`);
2706
+ if (canonicalStringify(counts) !== canonicalStringify(manifest.counts)) {
2707
+ errors.push("manifest counts do not match backup files");
2123
2708
  }
2124
- }
2125
2709
 
2126
- const counts = computeCounts(files);
2127
- const backupHash = computeBackupHash(files);
2128
- if (backupHash !== manifest.backupHash) {
2129
- errors.push(`backup hash ${backupHash} != ${manifest.backupHash}`);
2130
- }
2131
- if (canonicalStringify(counts) !== canonicalStringify(manifest.counts)) {
2132
- errors.push("manifest counts do not match backup files");
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
- return {
2136
- ok: errors.length === 0,
2137
- repoPath: resolvedRepoPath,
2138
- files,
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(