birdclaw 0.5.0 → 0.6.0

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