birdclaw 0.8.3 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +10 -8
- package/bin/birdclaw.mjs +4 -66
- package/dist/cli/birdclaw.js +28165 -0
- package/dist/client/assets/AvatarChip-DLoqM5xD.js +1 -0
- package/dist/client/assets/ProfilePreview-BejAuYsJ.js +1 -0
- package/dist/client/assets/SavedTimelineView-BIS7HKpp.js +1 -0
- package/dist/client/assets/SmartTimestamp-BR0-9se2.js +1 -0
- package/dist/client/assets/TimelineRouteFrame-CyGPj0eM.js +1 -0
- package/dist/client/assets/blocks-7UDKQp8H.js +1 -0
- package/dist/client/assets/bookmarks-CbBkMvXk.js +1 -0
- package/dist/client/assets/chunk-C0phanGV.js +1 -0
- package/dist/client/assets/circle-check-C_Vq3ElD.js +1 -0
- package/dist/client/assets/client-stream-contracts-ys3XhWG4.js +5 -0
- package/dist/client/assets/createLucideIcon-JV8isFFR.js +1 -0
- package/dist/client/assets/data-sources-Cm2bpNvg.js +1 -0
- package/dist/client/assets/discuss-DTjaOugw.js +1 -0
- package/dist/client/assets/dms-CGd6Eogq.js +1 -0
- package/dist/client/assets/inbox-BHlGuOy2.js +1 -0
- package/dist/client/assets/index-CEIG7S6s.js +31 -0
- package/dist/client/assets/index-bpFrQ5yG.css +1 -0
- package/dist/client/assets/likes-cLrcAQem.js +1 -0
- package/dist/client/assets/links-CM9QO1LC.js +1 -0
- package/dist/client/assets/mapbox-BxTlqXt9.js +2 -0
- package/dist/client/assets/mapbox-gl-dgf_hX3a.js +3215 -0
- package/dist/client/assets/mentions-CxKCgzIf.js +1 -0
- package/dist/client/assets/network-map-B5a3whqj.js +2 -0
- package/dist/client/assets/profile-analyze-CRtNEwlb.js +1 -0
- package/dist/client/assets/profile-hydration-client-YEtJsNTv.js +1 -0
- package/dist/client/assets/profiles._handle-C82_HoKX.js +1 -0
- package/dist/client/assets/query-client-fYu2rzRC.js +17 -0
- package/dist/client/assets/rate-limits-ScaEwdVK.js +1 -0
- package/dist/client/assets/react-dom-DHl6Ug7F.js +1 -0
- package/dist/client/assets/react-nBylq1U5.js +1 -0
- package/dist/client/assets/repeat-2-xCwHM9hD.js +1 -0
- package/dist/client/assets/routes-q7ZaW4En.js +1 -0
- package/dist/client/assets/schemas-I9r6F6ya.js +64 -0
- package/dist/client/assets/search-Dq5kYsxA.js +1 -0
- package/dist/client/assets/sparkles-CQVlvHsZ.js +1 -0
- package/dist/client/assets/styles-B8s3tt3r.css +2 -0
- package/dist/client/assets/today-CeG1O7Py.js +1 -0
- package/dist/client/assets/triangle-alert-CBL7s2pf.js +1 -0
- package/dist/client/assets/useTimelineRouteData-1HsqgvUi.js +1 -0
- package/dist/client/assets/users-CHbKlqUr.js +1 -0
- package/dist/server/assets/AvatarChip-zUDAEmjL.js +105 -0
- package/dist/server/assets/FeedState-ntW0Q2GB.js +214 -0
- package/dist/server/assets/ProfileAnalysisStream-BFvhVdhn.js +198 -0
- package/dist/server/assets/ProfilePreview-C6gAor_j.js +752 -0
- package/dist/server/assets/SavedTimelineView-BG1jnBp1.js +64 -0
- package/dist/server/assets/SmartTimestamp-DfwE1NGf.js +45 -0
- package/dist/server/assets/SyncNowButton-BOn_IBXS.js +76 -0
- package/dist/server/assets/TimelineRouteFrame-CU7ilM7U.js +85 -0
- package/dist/server/assets/TweetRichText-DhCVv1-6.js +74 -0
- package/dist/server/assets/_tanstack-start-manifest_v-C-ZRnG_n.js +148 -0
- package/dist/server/assets/account-selection-CTdFGWRk.js +44 -0
- package/dist/server/assets/blocks-C1mgGTB3.js +299 -0
- package/dist/server/assets/blocks-DSgQghqP.js +17 -0
- package/dist/server/assets/bookmarks-BJN8tykY.js +14 -0
- package/dist/server/assets/client-stream-contracts-C2xbWVjm.js +769 -0
- package/dist/server/assets/data-sources-BnA1Q94p.js +180 -0
- package/dist/server/assets/discuss-CJ1dau1j.js +15 -0
- package/dist/server/assets/discuss-QTo08NCN.js +338 -0
- package/dist/server/assets/dms-BuLj4bp8.js +368 -0
- package/dist/server/assets/dms-DAyQrVM2.js +240 -0
- package/dist/server/assets/empty-plugin-adapters-DzrEkdBF.js +5 -0
- package/dist/server/assets/inbox-CkGOaW1q.js +214 -0
- package/dist/server/assets/inbox-DpWd1EzC.js +146 -0
- package/dist/server/assets/likes-DBOAtsJU.js +14 -0
- package/dist/server/assets/links-DAhSjynu.js +240 -0
- package/dist/server/assets/links-DmH5lxHO.js +468 -0
- package/dist/server/assets/mentions-BH2MzKtB.js +26 -0
- package/dist/server/assets/network-map-D3CzRcC4.js +224 -0
- package/dist/server/assets/network-map-D5DoLOwR.js +582 -0
- package/dist/server/assets/profile-analyze-BP65PED0.js +90 -0
- package/dist/server/assets/profile-analyze-C1NoM0tV.js +9 -0
- package/dist/server/assets/profile-hydration-client-GJ4kwJS8.js +133 -0
- package/dist/server/assets/profiles._handle-DjwlN_ac.js +184 -0
- package/dist/server/assets/profiles._handle-DsLNj2Ca.js +12 -0
- package/dist/server/assets/query-client-BdauHBgd.js +923 -0
- package/dist/server/assets/rate-limits-VAr2qzYD.js +262 -0
- package/dist/server/assets/route-search-BUlQ5lGM.js +117 -0
- package/dist/server/assets/router-DqkwSPiM.js +17026 -0
- package/dist/server/assets/routes-A9c9ZhwN.js +26 -0
- package/dist/server/assets/start-FaDOTmto.js +4 -0
- package/dist/server/assets/today-B5hVM57Z.js +17 -0
- package/dist/server/assets/today-fvGiZ7ud.js +303 -0
- package/dist/server/assets/ui-qbmcMSSX.js +137 -0
- package/{src/components/useDebouncedValue.ts → dist/server/assets/useDebouncedValue-DOnA6Xcy.js} +4 -4
- package/dist/server/assets/useTimelineRouteData-27cxynxa.js +1101 -0
- package/dist/server/server.js +5520 -0
- package/package.json +26 -34
- package/playwright.config.ts +0 -35
- package/scripts/browser-perf.mjs +0 -427
- package/scripts/build-docs-site.mjs +0 -967
- package/scripts/docs-site-assets.mjs +0 -311
- package/scripts/run-vitest.mjs +0 -21
- package/scripts/sanitize-node-options.mjs +0 -23
- package/scripts/start-test-server.mjs +0 -42
- package/src/cli/command-context.ts +0 -17
- package/src/cli/register-analysis.ts +0 -500
- package/src/cli/register-compose.ts +0 -40
- package/src/cli/register-graph.ts +0 -132
- package/src/cli/register-inbox.ts +0 -41
- package/src/cli/register-storage.ts +0 -106
- package/src/cli-moderation.ts +0 -210
- package/src/cli.ts +0 -1616
- package/src/components/AccountSwitcher.tsx +0 -178
- package/src/components/AppNav.tsx +0 -118
- package/src/components/AvatarChip.tsx +0 -58
- package/src/components/AvatarPreload.ts +0 -149
- package/src/components/BrandMark.tsx +0 -67
- package/src/components/ConversationThread.tsx +0 -131
- package/src/components/DmWorkspace.tsx +0 -286
- package/src/components/EmbeddedTweetCard.tsx +0 -55
- package/src/components/FeedState.tsx +0 -147
- package/src/components/FloatingPreview.tsx +0 -382
- package/src/components/InboxCard.tsx +0 -152
- package/src/components/LinkPreviewCard.tsx +0 -292
- package/src/components/MarkdownCitations.tsx +0 -680
- package/src/components/MarkdownViewer.tsx +0 -111
- package/src/components/ProfileAnalysisClient.ts +0 -191
- package/src/components/ProfileAnalysisStream.tsx +0 -259
- package/src/components/ProfilePreview.tsx +0 -131
- package/src/components/SavedTimelineView.tsx +0 -155
- package/src/components/SmartTimestamp.tsx +0 -72
- package/src/components/SyncNowButton.tsx +0 -140
- package/src/components/ThemeSlider.tsx +0 -80
- package/src/components/TimelineCard.tsx +0 -533
- package/src/components/TimelineRouteFrame.tsx +0 -186
- package/src/components/TweetArticleCard.tsx +0 -66
- package/src/components/TweetMediaGrid.tsx +0 -185
- package/src/components/TweetRichText.tsx +0 -159
- package/src/components/account-selection.ts +0 -64
- package/src/components/links-controller.ts +0 -162
- package/src/components/links-model.ts +0 -198
- package/src/components/network-map-controller.ts +0 -84
- package/src/components/network-map-model.ts +0 -255
- package/src/components/useTimelineRouteData.ts +0 -193
- package/src/lib/account-sync-job.ts +0 -666
- package/src/lib/actions-transport.ts +0 -300
- package/src/lib/analysis-runtime.ts +0 -238
- package/src/lib/api-client.ts +0 -220
- package/src/lib/api-contracts.ts +0 -328
- package/src/lib/archive-finder.ts +0 -185
- package/src/lib/archive-import-plan.ts +0 -102
- package/src/lib/archive-import.ts +0 -2695
- package/src/lib/authored-live.ts +0 -1027
- package/src/lib/avatar-cache.ts +0 -349
- package/src/lib/backup.ts +0 -2652
- package/src/lib/bird-actions.ts +0 -294
- package/src/lib/bird-command.ts +0 -130
- package/src/lib/bird.ts +0 -1140
- package/src/lib/blocklist.ts +0 -136
- package/src/lib/blocks-write.ts +0 -171
- package/src/lib/blocks.ts +0 -387
- package/src/lib/bookmark-sync-job.ts +0 -536
- package/src/lib/config.ts +0 -224
- package/src/lib/conversation-surface.ts +0 -205
- package/src/lib/data-sources.ts +0 -219
- package/src/lib/database-metrics.ts +0 -88
- package/src/lib/database-migrations.ts +0 -34
- package/src/lib/database-schema.ts +0 -312
- package/src/lib/database-writer.ts +0 -69
- package/src/lib/db.ts +0 -889
- package/src/lib/dm-read-model.ts +0 -533
- package/src/lib/dms-live.ts +0 -870
- package/src/lib/effect-runtime.ts +0 -45
- package/src/lib/follow-graph.ts +0 -1087
- package/src/lib/geocoding.ts +0 -296
- package/src/lib/http-effect.ts +0 -222
- package/src/lib/identity-search-index.ts +0 -369
- package/src/lib/import-repository.ts +0 -138
- package/src/lib/inbox.ts +0 -242
- package/src/lib/link-index.ts +0 -641
- package/src/lib/link-insights.ts +0 -860
- package/src/lib/link-preview-metadata.ts +0 -754
- package/src/lib/live-sync-engine.ts +0 -209
- package/src/lib/live-transport-gateway.ts +0 -128
- package/src/lib/location.ts +0 -137
- package/src/lib/media-fetch.ts +0 -860
- package/src/lib/media-includes.ts +0 -165
- package/src/lib/mention-threads-live.ts +0 -830
- package/src/lib/mentions-export.ts +0 -147
- package/src/lib/mentions-live.ts +0 -1114
- package/src/lib/moderation-target.ts +0 -227
- package/src/lib/moderation-write.ts +0 -131
- package/src/lib/mutes-write.ts +0 -171
- package/src/lib/mutes.ts +0 -84
- package/src/lib/ndjson-stream.ts +0 -106
- package/src/lib/network-map.ts +0 -382
- package/src/lib/openai-response-runtime.ts +0 -251
- package/src/lib/openai.ts +0 -117
- package/src/lib/paginated-sync.ts +0 -93
- package/src/lib/period-digest.ts +0 -1364
- package/src/lib/present.ts +0 -118
- package/src/lib/profile-affiliation-hydration.ts +0 -228
- package/src/lib/profile-affiliations.ts +0 -262
- package/src/lib/profile-analysis.ts +0 -1198
- package/src/lib/profile-bio-entities.ts +0 -318
- package/src/lib/profile-history.ts +0 -164
- package/src/lib/profile-hydration.ts +0 -180
- package/src/lib/profile-replies.ts +0 -77
- package/src/lib/profile-resolver.ts +0 -650
- package/src/lib/queries.ts +0 -6
- package/src/lib/query-actions.ts +0 -437
- package/src/lib/query-client.tsx +0 -47
- package/src/lib/query-read-model-shared.ts +0 -52
- package/src/lib/query-read-models.ts +0 -5
- package/src/lib/query-resource.ts +0 -41
- package/src/lib/query-status.ts +0 -164
- package/src/lib/research.ts +0 -652
- package/src/lib/runtime-services.ts +0 -20
- package/src/lib/search-discussion.ts +0 -812
- package/src/lib/seed.ts +0 -613
- package/src/lib/server-runtime-services.ts +0 -30
- package/src/lib/sqlite.ts +0 -183
- package/src/lib/streaming-ingestion.ts +0 -240
- package/src/lib/sync-cache.ts +0 -70
- package/src/lib/sync-plan.ts +0 -175
- package/src/lib/theme-transition.ts +0 -113
- package/src/lib/theme.tsx +0 -165
- package/src/lib/timeline-collections-live.ts +0 -407
- package/src/lib/timeline-live.ts +0 -302
- package/src/lib/timeline-read-model.ts +0 -1191
- package/src/lib/tweet-account-edges.ts +0 -48
- package/src/lib/tweet-lookup.ts +0 -46
- package/src/lib/tweet-render.ts +0 -430
- package/src/lib/tweet-repository.ts +0 -156
- package/src/lib/tweet-search-live.ts +0 -462
- package/src/lib/types.ts +0 -723
- package/src/lib/ui.ts +0 -529
- package/src/lib/url-expansion-store.ts +0 -110
- package/src/lib/url-expansion.ts +0 -343
- package/src/lib/url-safety.ts +0 -220
- package/src/lib/web-sync.ts +0 -533
- package/src/lib/whois.ts +0 -1080
- package/src/lib/x-profile.ts +0 -365
- package/src/lib/x-web.ts +0 -199
- package/src/lib/xurl-rate-limits.ts +0 -267
- package/src/lib/xurl.ts +0 -1677
- package/src/routeTree.gen.ts +0 -723
- package/src/router.tsx +0 -20
- package/src/routes/__root.tsx +0 -82
- package/src/routes/api/action.tsx +0 -147
- package/src/routes/api/avatar.tsx +0 -50
- package/src/routes/api/blocks.tsx +0 -35
- package/src/routes/api/conversation.tsx +0 -45
- package/src/routes/api/data-sources.tsx +0 -24
- package/src/routes/api/inbox.tsx +0 -43
- package/src/routes/api/link-insights.tsx +0 -85
- package/src/routes/api/link-preview.tsx +0 -47
- package/src/routes/api/network-map.tsx +0 -55
- package/src/routes/api/period-digest.tsx +0 -101
- package/src/routes/api/profile-analysis.tsx +0 -97
- package/src/routes/api/profile-hydrate.tsx +0 -54
- package/src/routes/api/query.tsx +0 -121
- package/src/routes/api/search-discussion.tsx +0 -113
- package/src/routes/api/status.tsx +0 -30
- package/src/routes/api/sync.tsx +0 -108
- package/src/routes/api/xurl-rate-limits.tsx +0 -24
- package/src/routes/blocks.tsx +0 -343
- package/src/routes/bookmarks.tsx +0 -18
- package/src/routes/data-sources.tsx +0 -249
- package/src/routes/discuss.tsx +0 -419
- package/src/routes/dms.tsx +0 -415
- package/src/routes/inbox.tsx +0 -232
- package/src/routes/index.tsx +0 -32
- package/src/routes/likes.tsx +0 -18
- package/src/routes/links.tsx +0 -631
- package/src/routes/mentions.tsx +0 -32
- package/src/routes/network-map.tsx +0 -732
- package/src/routes/profile-analyze.tsx +0 -112
- package/src/routes/profiles.$handle.tsx +0 -228
- package/src/routes/rate-limits.tsx +0 -305
- package/src/routes/today.tsx +0 -511
- package/src/styles.css +0 -257
- package/tsconfig.json +0 -29
- package/vite.config.ts +0 -29
- /package/{public → dist/client}/birdclaw-mark.png +0 -0
- /package/{public → dist/client}/favicon.ico +0 -0
- /package/{public → dist/client}/logo192.png +0 -0
- /package/{public → dist/client}/logo512.png +0 -0
- /package/{public → dist/client}/manifest.json +0 -0
- /package/{public → dist/client}/robots.txt +0 -0
package/src/lib/backup.ts
DELETED
|
@@ -1,2652 +0,0 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
4
|
-
import fs from "node:fs/promises";
|
|
5
|
-
import path from "node:path";
|
|
6
|
-
import { promisify } from "node:util";
|
|
7
|
-
import { Data, Effect } from "effect";
|
|
8
|
-
import type { Database } from "./sqlite";
|
|
9
|
-
import { getBirdclawConfig } from "./config";
|
|
10
|
-
import { getNativeDb } from "./db";
|
|
11
|
-
import { databaseWriteEffect } from "./database-writer";
|
|
12
|
-
import { runEffectPromise, tryPromise } from "./effect-runtime";
|
|
13
|
-
import { getImportRepository } from "./import-repository";
|
|
14
|
-
import {
|
|
15
|
-
collectIngestionSourcesEffect,
|
|
16
|
-
streamJsonLines,
|
|
17
|
-
} from "./streaming-ingestion";
|
|
18
|
-
import { safeHttpUrl } from "./url-safety";
|
|
19
|
-
|
|
20
|
-
const execFileAsync = promisify(execFile);
|
|
21
|
-
const BACKUP_SCHEMA_VERSION = 1;
|
|
22
|
-
const MANIFEST_PATH = "manifest.json";
|
|
23
|
-
const DATA_DIR = "data";
|
|
24
|
-
const AUTO_SYNC_CACHE_KEY = "backup:auto-sync";
|
|
25
|
-
const DEFAULT_STALE_AFTER_SECONDS = 15 * 60;
|
|
26
|
-
let autoUpdateInFlight: Promise<BackupAutoUpdateResult> | null = null;
|
|
27
|
-
|
|
28
|
-
type JsonValue =
|
|
29
|
-
| null
|
|
30
|
-
| boolean
|
|
31
|
-
| number
|
|
32
|
-
| string
|
|
33
|
-
| JsonValue[]
|
|
34
|
-
| { [key: string]: JsonValue };
|
|
35
|
-
|
|
36
|
-
type JsonRecord = Record<string, JsonValue>;
|
|
37
|
-
|
|
38
|
-
export interface BackupFileManifest {
|
|
39
|
-
path: string;
|
|
40
|
-
rows: number;
|
|
41
|
-
sha256: string;
|
|
42
|
-
bytes: number;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface BackupManifest {
|
|
46
|
-
app: "birdclaw";
|
|
47
|
-
schemaVersion: number;
|
|
48
|
-
generatedAt: string;
|
|
49
|
-
counts: Record<string, number>;
|
|
50
|
-
files: BackupFileManifest[];
|
|
51
|
-
backupHash: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface BackupExportResult {
|
|
55
|
-
ok: true;
|
|
56
|
-
repoPath: string;
|
|
57
|
-
manifest: BackupManifest;
|
|
58
|
-
validation: BackupValidationResult;
|
|
59
|
-
git?: {
|
|
60
|
-
committed: boolean;
|
|
61
|
-
pushed: boolean;
|
|
62
|
-
commit?: string;
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export interface BackupImportResult {
|
|
67
|
-
ok: true;
|
|
68
|
-
repoPath: string;
|
|
69
|
-
mode: BackupImportMode;
|
|
70
|
-
manifest: BackupManifest;
|
|
71
|
-
validation?: BackupValidationResult;
|
|
72
|
-
fingerprint: BackupDatabaseFingerprint;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export interface BackupSyncResult {
|
|
76
|
-
ok: true;
|
|
77
|
-
repoPath: string;
|
|
78
|
-
remote?: string;
|
|
79
|
-
pulled: boolean;
|
|
80
|
-
imported: boolean;
|
|
81
|
-
importResult?: BackupImportResult;
|
|
82
|
-
exportResult: BackupExportResult;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export interface BackupAutoUpdateResult {
|
|
86
|
-
ok: boolean;
|
|
87
|
-
enabled: boolean;
|
|
88
|
-
skipped: boolean;
|
|
89
|
-
reason?: string;
|
|
90
|
-
repoPath?: string;
|
|
91
|
-
remote?: string;
|
|
92
|
-
pulled?: boolean;
|
|
93
|
-
imported?: boolean;
|
|
94
|
-
error?: string;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export interface BackupValidationResult {
|
|
98
|
-
ok: boolean;
|
|
99
|
-
repoPath: string;
|
|
100
|
-
files: BackupFileManifest[];
|
|
101
|
-
counts: Record<string, number>;
|
|
102
|
-
backupHash: string;
|
|
103
|
-
errors: string[];
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export interface BackupDatabaseFingerprint {
|
|
107
|
-
counts: Record<string, number>;
|
|
108
|
-
hash: string;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export type BackupImportMode = "merge" | "replace";
|
|
112
|
-
|
|
113
|
-
export interface BackupImportOptions {
|
|
114
|
-
repoPath: string;
|
|
115
|
-
db?: Database;
|
|
116
|
-
validate?: boolean;
|
|
117
|
-
mode?: BackupImportMode;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export class BackupGitCommandError extends Data.TaggedError(
|
|
121
|
-
"BackupGitCommandError",
|
|
122
|
-
)<{
|
|
123
|
-
readonly message: string;
|
|
124
|
-
readonly args: readonly string[];
|
|
125
|
-
readonly stdout?: string;
|
|
126
|
-
readonly stderr?: string;
|
|
127
|
-
readonly cause?: unknown;
|
|
128
|
-
}> {}
|
|
129
|
-
|
|
130
|
-
function toError(error: unknown) {
|
|
131
|
-
return error instanceof Error ? error : new Error(String(error));
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function trySync<T>(try_: () => T) {
|
|
135
|
-
return Effect.try({
|
|
136
|
-
try: try_,
|
|
137
|
-
catch: toError,
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function getErrorOutput(error: unknown, key: "stdout" | "stderr") {
|
|
142
|
-
if (!error || typeof error !== "object" || !(key in error)) {
|
|
143
|
-
return undefined;
|
|
144
|
-
}
|
|
145
|
-
const output = (error as Record<"stdout" | "stderr", unknown>)[key];
|
|
146
|
-
return typeof output === "string" ? output : undefined;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function redactSecretUrl(value: string) {
|
|
150
|
-
return value.replace(
|
|
151
|
-
/([a-z][a-z0-9+.-]*:\/\/)([^/@:\s]+)(?::([^/@\s]+))?@/gi,
|
|
152
|
-
(_match, protocol: string, username: string, password?: string) =>
|
|
153
|
-
`${protocol}${username ? "REDACTED" : ""}${password ? ":REDACTED" : ""}@`,
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function gitCommandError(args: readonly string[], cause: unknown) {
|
|
158
|
-
const redactedArgs = args.map((arg) => redactSecretUrl(arg));
|
|
159
|
-
const command = `git ${redactedArgs.join(" ")}`;
|
|
160
|
-
const message = redactSecretUrl(
|
|
161
|
-
cause instanceof Error ? cause.message : `${command} failed`,
|
|
162
|
-
);
|
|
163
|
-
return new BackupGitCommandError({
|
|
164
|
-
message,
|
|
165
|
-
args: redactedArgs,
|
|
166
|
-
stdout: redactSecretUrl(getErrorOutput(cause, "stdout") ?? ""),
|
|
167
|
-
stderr: redactSecretUrl(getErrorOutput(cause, "stderr") ?? ""),
|
|
168
|
-
cause,
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function gitEffect(args: string[]) {
|
|
173
|
-
return Effect.tryPromise({
|
|
174
|
-
try: () => execFileAsync("git", args),
|
|
175
|
-
catch: (cause) => gitCommandError(args, cause),
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function canonicalStringify(value: JsonValue): string {
|
|
180
|
-
if (value === null || typeof value !== "object") {
|
|
181
|
-
return JSON.stringify(value);
|
|
182
|
-
}
|
|
183
|
-
if (Array.isArray(value)) {
|
|
184
|
-
return `[${value.map((item) => canonicalStringify(item)).join(",")}]`;
|
|
185
|
-
}
|
|
186
|
-
const keys = Object.keys(value).sort();
|
|
187
|
-
return `{${keys
|
|
188
|
-
.map((key) => `${JSON.stringify(key)}:${canonicalStringify(value[key])}`)
|
|
189
|
-
.join(",")}}`;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function toJsonRecord(row: Record<string, unknown>): JsonRecord {
|
|
193
|
-
const result: JsonRecord = {};
|
|
194
|
-
for (const [key, value] of Object.entries(row)) {
|
|
195
|
-
if (
|
|
196
|
-
value === null ||
|
|
197
|
-
typeof value === "string" ||
|
|
198
|
-
typeof value === "number" ||
|
|
199
|
-
typeof value === "boolean"
|
|
200
|
-
) {
|
|
201
|
-
result[key] = value;
|
|
202
|
-
continue;
|
|
203
|
-
}
|
|
204
|
-
result[key] = JSON.parse(JSON.stringify(value)) as JsonValue;
|
|
205
|
-
}
|
|
206
|
-
return result;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function sha256(content: string | Buffer) {
|
|
210
|
-
return createHash("sha256").update(content).digest("hex");
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const jsonlKeyOrderCache = new Map<string, string[]>();
|
|
214
|
-
|
|
215
|
-
function jsonlStringify(row: JsonRecord): string {
|
|
216
|
-
const keys = Object.keys(row);
|
|
217
|
-
const signature = keys.join("\0");
|
|
218
|
-
let sortedKeys = jsonlKeyOrderCache.get(signature);
|
|
219
|
-
if (!sortedKeys) {
|
|
220
|
-
sortedKeys = [...keys].sort();
|
|
221
|
-
jsonlKeyOrderCache.set(signature, sortedKeys);
|
|
222
|
-
}
|
|
223
|
-
return `{${sortedKeys
|
|
224
|
-
.map((key) => `${JSON.stringify(key)}:${JSON.stringify(row[key])}`)
|
|
225
|
-
.join(",")}}`;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
function yearFromTimestamp(value: unknown) {
|
|
229
|
-
if (typeof value !== "string") {
|
|
230
|
-
return "unknown";
|
|
231
|
-
}
|
|
232
|
-
const match = /^(\d{4})/.exec(value);
|
|
233
|
-
if (!match || match[1] === "1970") {
|
|
234
|
-
return "unknown";
|
|
235
|
-
}
|
|
236
|
-
return match[1];
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function rowsForQuery(db: Database, sql: string, params: unknown[] = []) {
|
|
240
|
-
return (db.prepare(sql).all(...params) as Record<string, unknown>[]).map(
|
|
241
|
-
toJsonRecord,
|
|
242
|
-
);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function getExportRowSets(db: Database) {
|
|
246
|
-
const rowSets: Array<{ logicalName: string; rows: JsonRecord[] }> = [
|
|
247
|
-
{
|
|
248
|
-
logicalName: "accounts",
|
|
249
|
-
rows: rowsForQuery(
|
|
250
|
-
db,
|
|
251
|
-
`
|
|
252
|
-
select id, name, handle, external_user_id, transport, is_default, created_at
|
|
253
|
-
from accounts
|
|
254
|
-
order by id
|
|
255
|
-
`,
|
|
256
|
-
),
|
|
257
|
-
},
|
|
258
|
-
{
|
|
259
|
-
logicalName: "profiles",
|
|
260
|
-
rows: rowsForQuery(
|
|
261
|
-
db,
|
|
262
|
-
`
|
|
263
|
-
select id, handle, display_name, bio, followers_count,
|
|
264
|
-
following_count, public_metrics_json, avatar_hue, avatar_url,
|
|
265
|
-
location, url, verified_type, entities_json, raw_json, created_at
|
|
266
|
-
from profiles
|
|
267
|
-
order by id
|
|
268
|
-
`,
|
|
269
|
-
),
|
|
270
|
-
},
|
|
271
|
-
{
|
|
272
|
-
logicalName: "profile_affiliations",
|
|
273
|
-
rows: rowsForQuery(
|
|
274
|
-
db,
|
|
275
|
-
`
|
|
276
|
-
select subject_profile_id, organization_profile_id, organization_name,
|
|
277
|
-
organization_handle, badge_url, url, label, source, is_active,
|
|
278
|
-
first_seen_at, last_seen_at, raw_json, updated_at
|
|
279
|
-
from profile_affiliations
|
|
280
|
-
order by subject_profile_id, organization_profile_id
|
|
281
|
-
`,
|
|
282
|
-
),
|
|
283
|
-
},
|
|
284
|
-
{
|
|
285
|
-
logicalName: "profile_snapshots",
|
|
286
|
-
rows: rowsForQuery(
|
|
287
|
-
db,
|
|
288
|
-
`
|
|
289
|
-
select profile_id, snapshot_hash, observed_at, last_seen_at, source,
|
|
290
|
-
handle, display_name, bio, location, url, verified_type,
|
|
291
|
-
followers_count, following_count, affiliations_json, raw_json
|
|
292
|
-
from profile_snapshots
|
|
293
|
-
order by profile_id, last_seen_at, snapshot_hash
|
|
294
|
-
`,
|
|
295
|
-
),
|
|
296
|
-
},
|
|
297
|
-
{
|
|
298
|
-
logicalName: "profile_bio_entities",
|
|
299
|
-
rows: rowsForQuery(
|
|
300
|
-
db,
|
|
301
|
-
`
|
|
302
|
-
select profile_id, kind, value, source, is_active, first_seen_at,
|
|
303
|
-
last_seen_at, raw_json
|
|
304
|
-
from profile_bio_entities
|
|
305
|
-
order by profile_id, kind, value
|
|
306
|
-
`,
|
|
307
|
-
),
|
|
308
|
-
},
|
|
309
|
-
{
|
|
310
|
-
logicalName: "tweets",
|
|
311
|
-
rows: rowsForQuery(
|
|
312
|
-
db,
|
|
313
|
-
`
|
|
314
|
-
select id, account_id, author_profile_id, kind, text, created_at, is_replied,
|
|
315
|
-
reply_to_id, like_count, media_count, bookmarked, liked, entities_json,
|
|
316
|
-
media_json, quoted_tweet_id
|
|
317
|
-
from tweets
|
|
318
|
-
order by created_at, id
|
|
319
|
-
`,
|
|
320
|
-
),
|
|
321
|
-
},
|
|
322
|
-
{
|
|
323
|
-
logicalName: "tweet_collections",
|
|
324
|
-
rows: rowsForQuery(
|
|
325
|
-
db,
|
|
326
|
-
`
|
|
327
|
-
select account_id, tweet_id, kind, collected_at, source, raw_json, updated_at
|
|
328
|
-
from tweet_collections
|
|
329
|
-
order by kind, account_id, coalesce(collected_at, ''), tweet_id
|
|
330
|
-
`,
|
|
331
|
-
),
|
|
332
|
-
},
|
|
333
|
-
{
|
|
334
|
-
logicalName: "tweet_account_edges",
|
|
335
|
-
rows: rowsForQuery(
|
|
336
|
-
db,
|
|
337
|
-
`
|
|
338
|
-
select account_id, tweet_id, kind, first_seen_at, last_seen_at,
|
|
339
|
-
seen_count, source, raw_json, updated_at
|
|
340
|
-
from tweet_account_edges
|
|
341
|
-
order by kind, account_id, last_seen_at, tweet_id
|
|
342
|
-
`,
|
|
343
|
-
),
|
|
344
|
-
},
|
|
345
|
-
{
|
|
346
|
-
logicalName: "dm_conversations",
|
|
347
|
-
rows: rowsForQuery(
|
|
348
|
-
db,
|
|
349
|
-
`
|
|
350
|
-
select id, account_id, participant_profile_id, title, inbox_kind,
|
|
351
|
-
last_message_at, unread_count, needs_reply
|
|
352
|
-
from dm_conversations
|
|
353
|
-
order by last_message_at, id
|
|
354
|
-
`,
|
|
355
|
-
),
|
|
356
|
-
},
|
|
357
|
-
{
|
|
358
|
-
logicalName: "dm_messages",
|
|
359
|
-
rows: rowsForQuery(
|
|
360
|
-
db,
|
|
361
|
-
`
|
|
362
|
-
select id, conversation_id, sender_profile_id, text, created_at, direction,
|
|
363
|
-
is_replied, media_count
|
|
364
|
-
from dm_messages
|
|
365
|
-
order by conversation_id, created_at, id
|
|
366
|
-
`,
|
|
367
|
-
),
|
|
368
|
-
},
|
|
369
|
-
{
|
|
370
|
-
logicalName: "url_expansions",
|
|
371
|
-
rows: rowsForQuery(
|
|
372
|
-
db,
|
|
373
|
-
`
|
|
374
|
-
select short_url, expanded_url, final_url, status, expanded_tweet_id,
|
|
375
|
-
expanded_handle, title, description, image_url, site_name, error,
|
|
376
|
-
source, updated_at
|
|
377
|
-
from url_expansions
|
|
378
|
-
order by short_url
|
|
379
|
-
`,
|
|
380
|
-
),
|
|
381
|
-
},
|
|
382
|
-
{
|
|
383
|
-
logicalName: "link_occurrences",
|
|
384
|
-
rows: rowsForQuery(
|
|
385
|
-
db,
|
|
386
|
-
`
|
|
387
|
-
select source_kind, source_id, source_position, short_url, account_id,
|
|
388
|
-
conversation_id, direction, created_at
|
|
389
|
-
from link_occurrences
|
|
390
|
-
order by source_kind, source_id, source_position, short_url
|
|
391
|
-
`,
|
|
392
|
-
),
|
|
393
|
-
},
|
|
394
|
-
{
|
|
395
|
-
logicalName: "blocks",
|
|
396
|
-
rows: rowsForQuery(
|
|
397
|
-
db,
|
|
398
|
-
`
|
|
399
|
-
select account_id, profile_id, source, created_at
|
|
400
|
-
from blocks
|
|
401
|
-
order by account_id, profile_id
|
|
402
|
-
`,
|
|
403
|
-
),
|
|
404
|
-
},
|
|
405
|
-
{
|
|
406
|
-
logicalName: "mutes",
|
|
407
|
-
rows: rowsForQuery(
|
|
408
|
-
db,
|
|
409
|
-
`
|
|
410
|
-
select account_id, profile_id, source, created_at
|
|
411
|
-
from mutes
|
|
412
|
-
order by account_id, profile_id
|
|
413
|
-
`,
|
|
414
|
-
),
|
|
415
|
-
},
|
|
416
|
-
{
|
|
417
|
-
logicalName: "tweet_actions",
|
|
418
|
-
rows: rowsForQuery(
|
|
419
|
-
db,
|
|
420
|
-
`
|
|
421
|
-
select id, account_id, tweet_id, kind, body, created_at
|
|
422
|
-
from tweet_actions
|
|
423
|
-
order by created_at, id
|
|
424
|
-
`,
|
|
425
|
-
),
|
|
426
|
-
},
|
|
427
|
-
{
|
|
428
|
-
logicalName: "ai_scores",
|
|
429
|
-
rows: rowsForQuery(
|
|
430
|
-
db,
|
|
431
|
-
`
|
|
432
|
-
select entity_kind, entity_id, model, score, summary, reasoning, updated_at
|
|
433
|
-
from ai_scores
|
|
434
|
-
order by entity_kind, entity_id, model
|
|
435
|
-
`,
|
|
436
|
-
),
|
|
437
|
-
},
|
|
438
|
-
{
|
|
439
|
-
logicalName: "follow_snapshots",
|
|
440
|
-
rows: rowsForQuery(
|
|
441
|
-
db,
|
|
442
|
-
`
|
|
443
|
-
select id, account_id, direction, source, status, page_count,
|
|
444
|
-
result_count, started_at, completed_at, raw_meta_json
|
|
445
|
-
from follow_snapshots
|
|
446
|
-
order by account_id, direction, completed_at, id
|
|
447
|
-
`,
|
|
448
|
-
),
|
|
449
|
-
},
|
|
450
|
-
{
|
|
451
|
-
logicalName: "follow_snapshot_members",
|
|
452
|
-
rows: rowsForQuery(
|
|
453
|
-
db,
|
|
454
|
-
`
|
|
455
|
-
select snapshot_id, profile_id, external_user_id, position
|
|
456
|
-
from follow_snapshot_members
|
|
457
|
-
order by snapshot_id, position, profile_id
|
|
458
|
-
`,
|
|
459
|
-
),
|
|
460
|
-
},
|
|
461
|
-
{
|
|
462
|
-
logicalName: "follow_edges",
|
|
463
|
-
rows: rowsForQuery(
|
|
464
|
-
db,
|
|
465
|
-
`
|
|
466
|
-
select account_id, direction, profile_id, external_user_id, source,
|
|
467
|
-
current, first_seen_at, last_seen_at, ended_at, updated_at
|
|
468
|
-
from follow_edges
|
|
469
|
-
order by account_id, direction, profile_id
|
|
470
|
-
`,
|
|
471
|
-
),
|
|
472
|
-
},
|
|
473
|
-
{
|
|
474
|
-
logicalName: "follow_events",
|
|
475
|
-
rows: rowsForQuery(
|
|
476
|
-
db,
|
|
477
|
-
`
|
|
478
|
-
select id, account_id, direction, profile_id, external_user_id, kind,
|
|
479
|
-
event_at, snapshot_id
|
|
480
|
-
from follow_events
|
|
481
|
-
order by account_id, direction, event_at, kind, profile_id, id
|
|
482
|
-
`,
|
|
483
|
-
),
|
|
484
|
-
},
|
|
485
|
-
];
|
|
486
|
-
return rowSets;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
function addRows(
|
|
490
|
-
shards: Map<string, JsonRecord[]>,
|
|
491
|
-
relativePath: string,
|
|
492
|
-
rows: JsonRecord[],
|
|
493
|
-
) {
|
|
494
|
-
if (rows.length === 0) {
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
|
-
const existing = shards.get(relativePath) ?? [];
|
|
498
|
-
existing.push(...rows);
|
|
499
|
-
shards.set(relativePath, existing);
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
function buildShards(db: Database) {
|
|
503
|
-
const shards = new Map<string, JsonRecord[]>();
|
|
504
|
-
const rowSets = getExportRowSets(db);
|
|
505
|
-
|
|
506
|
-
for (const rowSet of rowSets) {
|
|
507
|
-
switch (rowSet.logicalName) {
|
|
508
|
-
case "accounts":
|
|
509
|
-
addRows(shards, "data/accounts.jsonl", rowSet.rows);
|
|
510
|
-
break;
|
|
511
|
-
case "profiles":
|
|
512
|
-
addRows(shards, "data/profiles.jsonl", rowSet.rows);
|
|
513
|
-
break;
|
|
514
|
-
case "profile_affiliations":
|
|
515
|
-
addRows(shards, "data/profile_affiliations.jsonl", rowSet.rows);
|
|
516
|
-
break;
|
|
517
|
-
case "profile_snapshots":
|
|
518
|
-
addRows(shards, "data/profile_snapshots.jsonl", rowSet.rows);
|
|
519
|
-
break;
|
|
520
|
-
case "profile_bio_entities":
|
|
521
|
-
addRows(shards, "data/profile_bio_entities.jsonl", rowSet.rows);
|
|
522
|
-
break;
|
|
523
|
-
case "tweets":
|
|
524
|
-
for (const row of rowSet.rows) {
|
|
525
|
-
addRows(
|
|
526
|
-
shards,
|
|
527
|
-
`data/tweets/${yearFromTimestamp(row.created_at)}.jsonl`,
|
|
528
|
-
[row],
|
|
529
|
-
);
|
|
530
|
-
}
|
|
531
|
-
break;
|
|
532
|
-
case "tweet_collections":
|
|
533
|
-
for (const row of rowSet.rows) {
|
|
534
|
-
const kind =
|
|
535
|
-
row.kind === "likes" || row.kind === "bookmarks"
|
|
536
|
-
? row.kind
|
|
537
|
-
: "unknown";
|
|
538
|
-
addRows(shards, `data/collections/${kind}.jsonl`, [row]);
|
|
539
|
-
}
|
|
540
|
-
break;
|
|
541
|
-
case "tweet_account_edges":
|
|
542
|
-
for (const row of rowSet.rows) {
|
|
543
|
-
const kind =
|
|
544
|
-
row.kind === "home" ||
|
|
545
|
-
row.kind === "mention" ||
|
|
546
|
-
row.kind === "authored" ||
|
|
547
|
-
row.kind === "search"
|
|
548
|
-
? row.kind
|
|
549
|
-
: "unknown";
|
|
550
|
-
addRows(shards, `data/timeline_edges/${kind}.jsonl`, [row]);
|
|
551
|
-
}
|
|
552
|
-
break;
|
|
553
|
-
case "dm_conversations":
|
|
554
|
-
addRows(shards, "data/dms/conversations.jsonl", rowSet.rows);
|
|
555
|
-
break;
|
|
556
|
-
case "dm_messages":
|
|
557
|
-
for (const row of rowSet.rows) {
|
|
558
|
-
addRows(
|
|
559
|
-
shards,
|
|
560
|
-
`data/dms/${yearFromTimestamp(row.created_at)}.jsonl`,
|
|
561
|
-
[row],
|
|
562
|
-
);
|
|
563
|
-
}
|
|
564
|
-
break;
|
|
565
|
-
case "url_expansions":
|
|
566
|
-
addRows(shards, "data/links/url_expansions.jsonl", rowSet.rows);
|
|
567
|
-
break;
|
|
568
|
-
case "link_occurrences":
|
|
569
|
-
addRows(shards, "data/links/occurrences.jsonl", rowSet.rows);
|
|
570
|
-
break;
|
|
571
|
-
case "blocks":
|
|
572
|
-
case "mutes":
|
|
573
|
-
addRows(
|
|
574
|
-
shards,
|
|
575
|
-
`data/moderation/${rowSet.logicalName}.jsonl`,
|
|
576
|
-
rowSet.rows,
|
|
577
|
-
);
|
|
578
|
-
break;
|
|
579
|
-
case "tweet_actions":
|
|
580
|
-
addRows(shards, "data/actions/tweet_actions.jsonl", rowSet.rows);
|
|
581
|
-
break;
|
|
582
|
-
case "ai_scores":
|
|
583
|
-
addRows(shards, "data/ai_scores.jsonl", rowSet.rows);
|
|
584
|
-
break;
|
|
585
|
-
case "follow_snapshots":
|
|
586
|
-
addRows(shards, "data/follow_snapshots.jsonl", rowSet.rows);
|
|
587
|
-
break;
|
|
588
|
-
case "follow_snapshot_members":
|
|
589
|
-
addRows(shards, "data/follow_snapshot_members.jsonl", rowSet.rows);
|
|
590
|
-
break;
|
|
591
|
-
case "follow_edges":
|
|
592
|
-
addRows(shards, "data/follow_edges.jsonl", rowSet.rows);
|
|
593
|
-
break;
|
|
594
|
-
case "follow_events":
|
|
595
|
-
addRows(shards, "data/follow_events.jsonl", rowSet.rows);
|
|
596
|
-
break;
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
return shards;
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
function writeJsonlFileEffect(
|
|
604
|
-
repoPath: string,
|
|
605
|
-
relativePath: string,
|
|
606
|
-
rows: JsonRecord[],
|
|
607
|
-
): Effect.Effect<BackupFileManifest, unknown> {
|
|
608
|
-
return Effect.gen(function* () {
|
|
609
|
-
const fullPath = yield* trySync(() =>
|
|
610
|
-
resolveBackupFilePath(repoPath, relativePath),
|
|
611
|
-
);
|
|
612
|
-
const content = yield* trySync(
|
|
613
|
-
() => `${rows.map((row) => jsonlStringify(row)).join("\n")}\n`,
|
|
614
|
-
);
|
|
615
|
-
yield* assertNoSymlinkAncestorEffect(repoPath, path.dirname(fullPath));
|
|
616
|
-
yield* tryPromise(() =>
|
|
617
|
-
fs.mkdir(path.dirname(fullPath), { recursive: true }),
|
|
618
|
-
);
|
|
619
|
-
yield* assertNoSymlinkAncestorEffect(repoPath, path.dirname(fullPath));
|
|
620
|
-
yield* assertBackupPathInsideRealRootEffect(
|
|
621
|
-
repoPath,
|
|
622
|
-
path.dirname(fullPath),
|
|
623
|
-
);
|
|
624
|
-
const outputStat = yield* tryPromise(() => fs.lstat(fullPath)).pipe(
|
|
625
|
-
Effect.option,
|
|
626
|
-
);
|
|
627
|
-
if (outputStat._tag === "Some" && !outputStat.value.isFile()) {
|
|
628
|
-
return yield* Effect.fail(
|
|
629
|
-
new Error(`Backup output path is not a regular file: ${relativePath}`),
|
|
630
|
-
);
|
|
631
|
-
}
|
|
632
|
-
const current = yield* tryPromise(() => fs.readFile(fullPath, "utf8")).pipe(
|
|
633
|
-
Effect.option,
|
|
634
|
-
);
|
|
635
|
-
if (current._tag === "None" || current.value !== content) {
|
|
636
|
-
yield* tryPromise(() => fs.writeFile(fullPath, content, "utf8"));
|
|
637
|
-
}
|
|
638
|
-
return {
|
|
639
|
-
path: relativePath,
|
|
640
|
-
rows: rows.length,
|
|
641
|
-
sha256: sha256(content),
|
|
642
|
-
bytes: Buffer.byteLength(content),
|
|
643
|
-
};
|
|
644
|
-
});
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
function removeStaleBackupFilesEffect(
|
|
648
|
-
repoPath: string,
|
|
649
|
-
expectedPaths: Set<string>,
|
|
650
|
-
directory = DATA_DIR,
|
|
651
|
-
): Effect.Effect<void, unknown> {
|
|
652
|
-
return Effect.gen(function* () {
|
|
653
|
-
const fullDirectory = yield* trySync(() =>
|
|
654
|
-
resolveBackupFilePath(repoPath, directory),
|
|
655
|
-
);
|
|
656
|
-
const directoryStat = yield* tryPromise(() => fs.lstat(fullDirectory)).pipe(
|
|
657
|
-
Effect.option,
|
|
658
|
-
);
|
|
659
|
-
if (directoryStat._tag === "None" || !directoryStat.value.isDirectory()) {
|
|
660
|
-
return;
|
|
661
|
-
}
|
|
662
|
-
yield* assertBackupPathInsideRealRootEffect(repoPath, fullDirectory);
|
|
663
|
-
const entries = yield* tryPromise(() =>
|
|
664
|
-
fs.readdir(fullDirectory, { withFileTypes: true }),
|
|
665
|
-
).pipe(Effect.catchAll(() => Effect.succeed([])));
|
|
666
|
-
|
|
667
|
-
yield* Effect.forEach(
|
|
668
|
-
entries,
|
|
669
|
-
(entry) =>
|
|
670
|
-
Effect.gen(function* () {
|
|
671
|
-
const relativePath = path.posix.join(directory, entry.name);
|
|
672
|
-
const fullPath = yield* trySync(() =>
|
|
673
|
-
resolveBackupFilePath(repoPath, relativePath),
|
|
674
|
-
);
|
|
675
|
-
if (entry.isDirectory()) {
|
|
676
|
-
yield* removeStaleBackupFilesEffect(
|
|
677
|
-
repoPath,
|
|
678
|
-
expectedPaths,
|
|
679
|
-
relativePath,
|
|
680
|
-
);
|
|
681
|
-
const remaining = yield* tryPromise(() => fs.readdir(fullPath));
|
|
682
|
-
if (remaining.length === 0) {
|
|
683
|
-
yield* tryPromise(() => fs.rmdir(fullPath));
|
|
684
|
-
}
|
|
685
|
-
return;
|
|
686
|
-
}
|
|
687
|
-
if (
|
|
688
|
-
relativePath.endsWith(".jsonl") &&
|
|
689
|
-
!expectedPaths.has(relativePath)
|
|
690
|
-
) {
|
|
691
|
-
const stat = yield* tryPromise(() => fs.lstat(fullPath)).pipe(
|
|
692
|
-
Effect.option,
|
|
693
|
-
);
|
|
694
|
-
if (stat._tag === "Some" && !stat.value.isFile()) return;
|
|
695
|
-
yield* tryPromise(() => fs.rm(fullPath, { force: true }));
|
|
696
|
-
}
|
|
697
|
-
}),
|
|
698
|
-
{ concurrency: "unbounded" },
|
|
699
|
-
);
|
|
700
|
-
});
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
function computeBackupHash(files: BackupFileManifest[]) {
|
|
704
|
-
const content = files
|
|
705
|
-
.map((file) => `${file.path}\t${file.rows}\t${file.bytes}\t${file.sha256}`)
|
|
706
|
-
.sort()
|
|
707
|
-
.join("\n");
|
|
708
|
-
return sha256(content);
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
function computeCounts(files: BackupFileManifest[]) {
|
|
712
|
-
const counts: Record<string, number> = {};
|
|
713
|
-
for (const file of files) {
|
|
714
|
-
const [first, second, third] = file.path.split("/");
|
|
715
|
-
if (first !== "data") {
|
|
716
|
-
continue;
|
|
717
|
-
}
|
|
718
|
-
const key =
|
|
719
|
-
second === "tweets"
|
|
720
|
-
? "tweets"
|
|
721
|
-
: second === "collections"
|
|
722
|
-
? `collections_${third?.replace(/\.jsonl$/, "") ?? "unknown"}`
|
|
723
|
-
: second === "timeline_edges"
|
|
724
|
-
? `timeline_edges_${third?.replace(/\.jsonl$/, "") ?? "unknown"}`
|
|
725
|
-
: second === "dms" && third === "conversations.jsonl"
|
|
726
|
-
? "dm_conversations"
|
|
727
|
-
: second === "dms"
|
|
728
|
-
? "dm_messages"
|
|
729
|
-
: second === "links"
|
|
730
|
-
? third === "url_expansions.jsonl"
|
|
731
|
-
? "url_expansions"
|
|
732
|
-
: third === "occurrences.jsonl"
|
|
733
|
-
? "link_occurrences"
|
|
734
|
-
: "links"
|
|
735
|
-
: second === "moderation"
|
|
736
|
-
? third?.replace(/\.jsonl$/, "") || "moderation"
|
|
737
|
-
: second === "actions"
|
|
738
|
-
? third?.replace(/\.jsonl$/, "") || "actions"
|
|
739
|
-
: second?.replace(/\.jsonl$/, "") || "unknown";
|
|
740
|
-
counts[key] = (counts[key] ?? 0) + file.rows;
|
|
741
|
-
}
|
|
742
|
-
return counts;
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
function ensureBackupReadmeEffect(
|
|
746
|
-
repoPath: string,
|
|
747
|
-
): Effect.Effect<void, unknown> {
|
|
748
|
-
return Effect.gen(function* () {
|
|
749
|
-
const readmePath = yield* trySync(() =>
|
|
750
|
-
resolveBackupFilePath(repoPath, "README.md"),
|
|
751
|
-
);
|
|
752
|
-
yield* assertNoSymlinkAncestorEffect(repoPath, readmePath);
|
|
753
|
-
if (yield* trySync(() => existsSync(readmePath))) {
|
|
754
|
-
return;
|
|
755
|
-
}
|
|
756
|
-
yield* tryPromise(() =>
|
|
757
|
-
fs.writeFile(
|
|
758
|
-
readmePath,
|
|
759
|
-
`# Birdclaw Store
|
|
760
|
-
|
|
761
|
-
Private text backup for Birdclaw data. The committed files are canonical JSONL shards that can rebuild the local SQLite index.
|
|
762
|
-
|
|
763
|
-
## Layout
|
|
764
|
-
|
|
765
|
-
\`\`\`text
|
|
766
|
-
manifest.json
|
|
767
|
-
data/accounts.jsonl
|
|
768
|
-
data/profiles.jsonl
|
|
769
|
-
data/profile_affiliations.jsonl
|
|
770
|
-
data/profile_snapshots.jsonl
|
|
771
|
-
data/profile_bio_entities.jsonl
|
|
772
|
-
data/tweets/YYYY.jsonl
|
|
773
|
-
data/tweets/unknown.jsonl
|
|
774
|
-
data/timeline_edges/home.jsonl
|
|
775
|
-
data/timeline_edges/mention.jsonl
|
|
776
|
-
data/collections/likes.jsonl
|
|
777
|
-
data/collections/bookmarks.jsonl
|
|
778
|
-
data/dms/conversations.jsonl
|
|
779
|
-
data/dms/YYYY.jsonl
|
|
780
|
-
data/links/url_expansions.jsonl
|
|
781
|
-
data/links/occurrences.jsonl
|
|
782
|
-
data/moderation/blocks.jsonl
|
|
783
|
-
data/moderation/mutes.jsonl
|
|
784
|
-
data/follow_snapshots.jsonl
|
|
785
|
-
data/follow_snapshot_members.jsonl
|
|
786
|
-
data/follow_edges.jsonl
|
|
787
|
-
data/follow_events.jsonl
|
|
788
|
-
\`\`\`
|
|
789
|
-
|
|
790
|
-
Tweets are sharded by creation year. Collection-only tweets whose creation date is unknown live in \`data/tweets/unknown.jsonl\`. Timeline edges keep account-scoped home/mention membership separate from canonical tweet content. DMs are sharded by year and keep \`conversation_id\` in each row.
|
|
791
|
-
The links shard stores expanded short URLs and their source tweet/DM occurrences so linked-tweet search can be rebuilt without re-expanding every \`t.co\` URL.
|
|
792
|
-
|
|
793
|
-
Never commit live tokens, browser cookies, raw SQLite WAL/SHM sidecars, or temporary cache files here.
|
|
794
|
-
`,
|
|
795
|
-
"utf8",
|
|
796
|
-
),
|
|
797
|
-
);
|
|
798
|
-
});
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
function writeManifestEffect(
|
|
802
|
-
repoPath: string,
|
|
803
|
-
manifest: BackupManifest,
|
|
804
|
-
): Effect.Effect<void, unknown> {
|
|
805
|
-
return Effect.gen(function* () {
|
|
806
|
-
const manifestPath = yield* trySync(() =>
|
|
807
|
-
resolveBackupFilePath(repoPath, MANIFEST_PATH),
|
|
808
|
-
);
|
|
809
|
-
yield* assertNoSymlinkAncestorEffect(repoPath, manifestPath);
|
|
810
|
-
const content = yield* trySync(
|
|
811
|
-
() => `${canonicalStringify(manifest as unknown as JsonRecord)}\n`,
|
|
812
|
-
);
|
|
813
|
-
const current = yield* tryPromise(() =>
|
|
814
|
-
fs.readFile(manifestPath, "utf8"),
|
|
815
|
-
).pipe(Effect.option);
|
|
816
|
-
if (current._tag === "Some" && current.value === content) {
|
|
817
|
-
return;
|
|
818
|
-
}
|
|
819
|
-
yield* tryPromise(() => fs.writeFile(manifestPath, content, "utf8"));
|
|
820
|
-
});
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
function readPreviousManifestEffect(
|
|
824
|
-
repoPath: string,
|
|
825
|
-
): Effect.Effect<BackupManifest | undefined, never> {
|
|
826
|
-
return readManifestEffect(repoPath).pipe(
|
|
827
|
-
Effect.catchAll(() => Effect.succeed(undefined)),
|
|
828
|
-
);
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
function maybeCommitAndPushEffect({
|
|
832
|
-
repoPath,
|
|
833
|
-
message,
|
|
834
|
-
commit,
|
|
835
|
-
push,
|
|
836
|
-
}: {
|
|
837
|
-
repoPath: string;
|
|
838
|
-
message: string;
|
|
839
|
-
commit: boolean;
|
|
840
|
-
push: boolean;
|
|
841
|
-
}) {
|
|
842
|
-
if (!commit && !push) {
|
|
843
|
-
return Effect.succeed(undefined);
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
return Effect.gen(function* () {
|
|
847
|
-
yield* gitEffect([
|
|
848
|
-
"-C",
|
|
849
|
-
repoPath,
|
|
850
|
-
"rev-parse",
|
|
851
|
-
"--is-inside-work-tree",
|
|
852
|
-
]).pipe(
|
|
853
|
-
Effect.catchAll(() =>
|
|
854
|
-
gitEffect(["-C", repoPath, "init"]).pipe(Effect.asVoid),
|
|
855
|
-
),
|
|
856
|
-
);
|
|
857
|
-
|
|
858
|
-
yield* gitEffect([
|
|
859
|
-
"-C",
|
|
860
|
-
repoPath,
|
|
861
|
-
"add",
|
|
862
|
-
"README.md",
|
|
863
|
-
MANIFEST_PATH,
|
|
864
|
-
DATA_DIR,
|
|
865
|
-
]);
|
|
866
|
-
|
|
867
|
-
yield* gitEffect(["-C", repoPath, "config", "user.email"]).pipe(
|
|
868
|
-
Effect.catchAll(() =>
|
|
869
|
-
gitEffect([
|
|
870
|
-
"-C",
|
|
871
|
-
repoPath,
|
|
872
|
-
"config",
|
|
873
|
-
"user.email",
|
|
874
|
-
"birdclaw@example.invalid",
|
|
875
|
-
]),
|
|
876
|
-
),
|
|
877
|
-
);
|
|
878
|
-
yield* gitEffect(["-C", repoPath, "config", "user.name"]).pipe(
|
|
879
|
-
Effect.catchAll(() =>
|
|
880
|
-
gitEffect(["-C", repoPath, "config", "user.name", "Birdclaw Backup"]),
|
|
881
|
-
),
|
|
882
|
-
);
|
|
883
|
-
|
|
884
|
-
const commitResult = yield* gitEffect([
|
|
885
|
-
"-C",
|
|
886
|
-
repoPath,
|
|
887
|
-
"diff",
|
|
888
|
-
"--cached",
|
|
889
|
-
"--quiet",
|
|
890
|
-
]).pipe(
|
|
891
|
-
Effect.as({ committed: false as const, commitHash: undefined }),
|
|
892
|
-
Effect.catchAll(() =>
|
|
893
|
-
Effect.gen(function* () {
|
|
894
|
-
yield* gitEffect([
|
|
895
|
-
"-C",
|
|
896
|
-
repoPath,
|
|
897
|
-
"-c",
|
|
898
|
-
"commit.gpgsign=false",
|
|
899
|
-
"commit",
|
|
900
|
-
"-m",
|
|
901
|
-
message,
|
|
902
|
-
]);
|
|
903
|
-
const { stdout } = yield* gitEffect([
|
|
904
|
-
"-C",
|
|
905
|
-
repoPath,
|
|
906
|
-
"rev-parse",
|
|
907
|
-
"HEAD",
|
|
908
|
-
]);
|
|
909
|
-
return {
|
|
910
|
-
committed: true as const,
|
|
911
|
-
commitHash: stdout.trim(),
|
|
912
|
-
};
|
|
913
|
-
}),
|
|
914
|
-
),
|
|
915
|
-
);
|
|
916
|
-
|
|
917
|
-
if (push) {
|
|
918
|
-
yield* gitEffect(["-C", repoPath, "push"]).pipe(
|
|
919
|
-
Effect.catchAll(() =>
|
|
920
|
-
gitEffect(["-C", repoPath, "push", "-u", "origin", "HEAD:main"]),
|
|
921
|
-
),
|
|
922
|
-
);
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
return {
|
|
926
|
-
committed: commitResult.committed,
|
|
927
|
-
pushed: push,
|
|
928
|
-
commit: commitResult.commitHash,
|
|
929
|
-
};
|
|
930
|
-
});
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
function isGitRepoEffect(repoPath: string) {
|
|
934
|
-
return gitEffect(["-C", repoPath, "rev-parse", "--is-inside-work-tree"]).pipe(
|
|
935
|
-
Effect.as(true),
|
|
936
|
-
Effect.catchAll(() => Effect.succeed(false)),
|
|
937
|
-
);
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
function hasGitCommitsEffect(repoPath: string) {
|
|
941
|
-
return gitEffect(["-C", repoPath, "rev-parse", "--verify", "HEAD"]).pipe(
|
|
942
|
-
Effect.as(true),
|
|
943
|
-
Effect.catchAll(() => Effect.succeed(false)),
|
|
944
|
-
);
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
function ensureBackupGitRepoEffect({
|
|
948
|
-
repoPath,
|
|
949
|
-
remote,
|
|
950
|
-
}: {
|
|
951
|
-
repoPath: string;
|
|
952
|
-
remote?: string;
|
|
953
|
-
}) {
|
|
954
|
-
return Effect.gen(function* () {
|
|
955
|
-
if (!(yield* isGitRepoEffect(repoPath))) {
|
|
956
|
-
if (remote && !existsSync(repoPath)) {
|
|
957
|
-
yield* gitEffect(["clone", remote, repoPath]);
|
|
958
|
-
} else {
|
|
959
|
-
yield* tryPromise(() => fs.mkdir(repoPath, { recursive: true }));
|
|
960
|
-
yield* gitEffect(["-C", repoPath, "init"]);
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
if (remote) {
|
|
965
|
-
const origin = yield* gitEffect([
|
|
966
|
-
"-C",
|
|
967
|
-
repoPath,
|
|
968
|
-
"remote",
|
|
969
|
-
"get-url",
|
|
970
|
-
"origin",
|
|
971
|
-
]).pipe(
|
|
972
|
-
Effect.map(({ stdout }) => ({ ok: true as const, stdout })),
|
|
973
|
-
Effect.catchAll(() => Effect.succeed({ ok: false as const })),
|
|
974
|
-
);
|
|
975
|
-
if (origin.ok) {
|
|
976
|
-
if (origin.stdout.trim() !== remote) {
|
|
977
|
-
yield* gitEffect([
|
|
978
|
-
"-C",
|
|
979
|
-
repoPath,
|
|
980
|
-
"remote",
|
|
981
|
-
"set-url",
|
|
982
|
-
"origin",
|
|
983
|
-
remote,
|
|
984
|
-
]);
|
|
985
|
-
}
|
|
986
|
-
} else {
|
|
987
|
-
yield* gitEffect(["-C", repoPath, "remote", "add", "origin", remote]);
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
if (remote && !(yield* hasGitCommitsEffect(repoPath))) {
|
|
992
|
-
const fetched = yield* gitEffect([
|
|
993
|
-
"-C",
|
|
994
|
-
repoPath,
|
|
995
|
-
"fetch",
|
|
996
|
-
"origin",
|
|
997
|
-
"main",
|
|
998
|
-
]).pipe(
|
|
999
|
-
Effect.flatMap(() =>
|
|
1000
|
-
gitEffect(["-C", repoPath, "checkout", "-B", "main", "origin/main"]),
|
|
1001
|
-
),
|
|
1002
|
-
Effect.as(true),
|
|
1003
|
-
Effect.catchAll(() => Effect.succeed(false)),
|
|
1004
|
-
);
|
|
1005
|
-
if (fetched) return;
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
if (!(yield* hasGitCommitsEffect(repoPath))) {
|
|
1009
|
-
yield* gitEffect(["-C", repoPath, "checkout", "-B", "main"]);
|
|
1010
|
-
}
|
|
1011
|
-
});
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
function pullBackupGitRepoEffect(repoPath: string) {
|
|
1015
|
-
return Effect.gen(function* () {
|
|
1016
|
-
if (
|
|
1017
|
-
!(yield* isGitRepoEffect(repoPath)) ||
|
|
1018
|
-
!(yield* hasGitCommitsEffect(repoPath))
|
|
1019
|
-
) {
|
|
1020
|
-
return false;
|
|
1021
|
-
}
|
|
1022
|
-
return yield* gitEffect(["-C", repoPath, "pull", "--ff-only"]).pipe(
|
|
1023
|
-
Effect.as(true),
|
|
1024
|
-
Effect.catchAll(() =>
|
|
1025
|
-
gitEffect(["-C", repoPath, "pull", "--ff-only", "origin", "main"]).pipe(
|
|
1026
|
-
Effect.as(true),
|
|
1027
|
-
Effect.catchAll(() => Effect.succeed(false)),
|
|
1028
|
-
),
|
|
1029
|
-
),
|
|
1030
|
-
);
|
|
1031
|
-
});
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
export function exportBackupEffect({
|
|
1035
|
-
repoPath,
|
|
1036
|
-
db,
|
|
1037
|
-
commit = false,
|
|
1038
|
-
push = false,
|
|
1039
|
-
message = "archive: update birdclaw backup",
|
|
1040
|
-
validate = true,
|
|
1041
|
-
}: {
|
|
1042
|
-
repoPath: string;
|
|
1043
|
-
db?: Database;
|
|
1044
|
-
commit?: boolean;
|
|
1045
|
-
push?: boolean;
|
|
1046
|
-
message?: string;
|
|
1047
|
-
validate?: boolean;
|
|
1048
|
-
}): Effect.Effect<BackupExportResult, unknown> {
|
|
1049
|
-
return Effect.gen(function* () {
|
|
1050
|
-
const resolvedRepoPath = yield* trySync(() => path.resolve(repoPath));
|
|
1051
|
-
const database =
|
|
1052
|
-
db ?? (yield* trySync(() => getNativeDb({ seedDemoData: false })));
|
|
1053
|
-
yield* tryPromise(() => fs.mkdir(resolvedRepoPath, { recursive: true }));
|
|
1054
|
-
const repoStat = yield* tryPromise(() => fs.lstat(resolvedRepoPath));
|
|
1055
|
-
if (!repoStat.isDirectory() || repoStat.isSymbolicLink()) {
|
|
1056
|
-
return yield* Effect.fail(
|
|
1057
|
-
new Error("Backup repository path must be a real directory"),
|
|
1058
|
-
);
|
|
1059
|
-
}
|
|
1060
|
-
yield* ensureBackupReadmeEffect(resolvedRepoPath);
|
|
1061
|
-
|
|
1062
|
-
const shards = yield* trySync(() => buildShards(database));
|
|
1063
|
-
const shardEntries = yield* trySync(() =>
|
|
1064
|
-
[...shards.entries()].sort(([left], [right]) =>
|
|
1065
|
-
left.localeCompare(right),
|
|
1066
|
-
),
|
|
1067
|
-
);
|
|
1068
|
-
const expectedPaths = yield* trySync(
|
|
1069
|
-
() => new Set(shardEntries.map(([relativePath]) => relativePath)),
|
|
1070
|
-
);
|
|
1071
|
-
const files = yield* Effect.forEach(
|
|
1072
|
-
shardEntries,
|
|
1073
|
-
([relativePath, rows]) =>
|
|
1074
|
-
writeJsonlFileEffect(resolvedRepoPath, relativePath, rows),
|
|
1075
|
-
{ concurrency: "unbounded" },
|
|
1076
|
-
);
|
|
1077
|
-
yield* removeStaleBackupFilesEffect(resolvedRepoPath, expectedPaths);
|
|
1078
|
-
|
|
1079
|
-
const counts = yield* trySync(() => computeCounts(files));
|
|
1080
|
-
const backupHash = yield* trySync(() => computeBackupHash(files));
|
|
1081
|
-
const previousManifest =
|
|
1082
|
-
yield* readPreviousManifestEffect(resolvedRepoPath);
|
|
1083
|
-
const manifest: BackupManifest = {
|
|
1084
|
-
app: "birdclaw",
|
|
1085
|
-
schemaVersion: BACKUP_SCHEMA_VERSION,
|
|
1086
|
-
generatedAt:
|
|
1087
|
-
previousManifest?.backupHash === backupHash
|
|
1088
|
-
? previousManifest.generatedAt
|
|
1089
|
-
: new Date().toISOString(),
|
|
1090
|
-
counts,
|
|
1091
|
-
files,
|
|
1092
|
-
backupHash,
|
|
1093
|
-
};
|
|
1094
|
-
yield* writeManifestEffect(resolvedRepoPath, manifest);
|
|
1095
|
-
|
|
1096
|
-
const validation = validate
|
|
1097
|
-
? yield* validateBackupEffect(resolvedRepoPath)
|
|
1098
|
-
: {
|
|
1099
|
-
ok: true,
|
|
1100
|
-
repoPath: resolvedRepoPath,
|
|
1101
|
-
files,
|
|
1102
|
-
counts,
|
|
1103
|
-
backupHash: manifest.backupHash,
|
|
1104
|
-
errors: [],
|
|
1105
|
-
};
|
|
1106
|
-
if (!validation.ok) {
|
|
1107
|
-
return yield* Effect.fail(
|
|
1108
|
-
new Error(`Backup validation failed: ${validation.errors.join("; ")}`),
|
|
1109
|
-
);
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
|
-
const git = yield* maybeCommitAndPushEffect({
|
|
1113
|
-
repoPath: resolvedRepoPath,
|
|
1114
|
-
message,
|
|
1115
|
-
commit,
|
|
1116
|
-
push,
|
|
1117
|
-
});
|
|
1118
|
-
|
|
1119
|
-
return {
|
|
1120
|
-
ok: true,
|
|
1121
|
-
repoPath: resolvedRepoPath,
|
|
1122
|
-
manifest,
|
|
1123
|
-
validation,
|
|
1124
|
-
...(git ? { git } : {}),
|
|
1125
|
-
};
|
|
1126
|
-
});
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
export function exportBackup(
|
|
1130
|
-
options: Parameters<typeof exportBackupEffect>[0],
|
|
1131
|
-
): Promise<BackupExportResult> {
|
|
1132
|
-
return runEffectPromise(exportBackupEffect(options));
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
function readManifestEffect(
|
|
1136
|
-
repoPath: string,
|
|
1137
|
-
): Effect.Effect<BackupManifest, unknown> {
|
|
1138
|
-
return Effect.gen(function* () {
|
|
1139
|
-
const manifestPath = yield* trySync(() =>
|
|
1140
|
-
resolveBackupFilePath(repoPath, MANIFEST_PATH),
|
|
1141
|
-
);
|
|
1142
|
-
yield* assertReadableBackupFileEffect(
|
|
1143
|
-
repoPath,
|
|
1144
|
-
manifestPath,
|
|
1145
|
-
MANIFEST_PATH,
|
|
1146
|
-
);
|
|
1147
|
-
const content = yield* tryPromise(() => fs.readFile(manifestPath, "utf8"));
|
|
1148
|
-
const parsed = yield* trySync(() => JSON.parse(content) as BackupManifest);
|
|
1149
|
-
if (parsed.app !== "birdclaw") {
|
|
1150
|
-
return yield* Effect.fail(
|
|
1151
|
-
new Error("Backup manifest is not a birdclaw backup"),
|
|
1152
|
-
);
|
|
1153
|
-
}
|
|
1154
|
-
if (parsed.schemaVersion !== BACKUP_SCHEMA_VERSION) {
|
|
1155
|
-
return yield* Effect.fail(
|
|
1156
|
-
new Error(
|
|
1157
|
-
`Unsupported backup schema version ${String(parsed.schemaVersion)}`,
|
|
1158
|
-
),
|
|
1159
|
-
);
|
|
1160
|
-
}
|
|
1161
|
-
return parsed;
|
|
1162
|
-
});
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
function resolveBackupFilePath(repoPath: string, relativePath: string) {
|
|
1166
|
-
if (path.isAbsolute(relativePath)) {
|
|
1167
|
-
throw new Error(`Backup manifest path must be relative: ${relativePath}`);
|
|
1168
|
-
}
|
|
1169
|
-
const normalized = path.normalize(relativePath);
|
|
1170
|
-
if (
|
|
1171
|
-
normalized === "." ||
|
|
1172
|
-
normalized.startsWith("..") ||
|
|
1173
|
-
path.isAbsolute(normalized)
|
|
1174
|
-
) {
|
|
1175
|
-
throw new Error(`Backup manifest path escapes repository: ${relativePath}`);
|
|
1176
|
-
}
|
|
1177
|
-
const root = path.resolve(repoPath);
|
|
1178
|
-
const resolved = path.resolve(root, normalized);
|
|
1179
|
-
const relative = path.relative(root, resolved);
|
|
1180
|
-
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
1181
|
-
throw new Error(`Backup manifest path escapes repository: ${relativePath}`);
|
|
1182
|
-
}
|
|
1183
|
-
return resolved;
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
function isPathInsideRoot(root: string, candidate: string) {
|
|
1187
|
-
const relative = path.relative(root, candidate);
|
|
1188
|
-
return (
|
|
1189
|
-
relative === "" ||
|
|
1190
|
-
(!relative.startsWith("..") && !path.isAbsolute(relative))
|
|
1191
|
-
);
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
|
-
function assertBackupPathInsideRealRootEffect(
|
|
1195
|
-
repoPath: string,
|
|
1196
|
-
fullPath: string,
|
|
1197
|
-
): Effect.Effect<void, unknown> {
|
|
1198
|
-
return Effect.gen(function* () {
|
|
1199
|
-
const realRoot = yield* tryPromise(() => fs.realpath(repoPath));
|
|
1200
|
-
const realPath = yield* tryPromise(() => fs.realpath(fullPath));
|
|
1201
|
-
if (!isPathInsideRoot(realRoot, realPath)) {
|
|
1202
|
-
return yield* Effect.fail(new Error("Backup path escapes repository"));
|
|
1203
|
-
}
|
|
1204
|
-
});
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
function assertReadableBackupFileEffect(
|
|
1208
|
-
repoPath: string,
|
|
1209
|
-
fullPath: string,
|
|
1210
|
-
label: string,
|
|
1211
|
-
) {
|
|
1212
|
-
return Effect.gen(function* () {
|
|
1213
|
-
yield* assertNoSymlinkAncestorEffect(repoPath, fullPath);
|
|
1214
|
-
const stat = yield* tryPromise(() => fs.lstat(fullPath));
|
|
1215
|
-
if (!stat.isFile()) {
|
|
1216
|
-
return yield* Effect.fail(
|
|
1217
|
-
new Error(`Backup path is not a regular file: ${label}`),
|
|
1218
|
-
);
|
|
1219
|
-
}
|
|
1220
|
-
yield* assertBackupPathInsideRealRootEffect(repoPath, fullPath);
|
|
1221
|
-
return stat;
|
|
1222
|
-
});
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
function assertNoSymlinkAncestorEffect(
|
|
1226
|
-
repoPath: string,
|
|
1227
|
-
fullPath: string,
|
|
1228
|
-
): Effect.Effect<void, unknown> {
|
|
1229
|
-
return Effect.gen(function* () {
|
|
1230
|
-
const root = path.resolve(repoPath);
|
|
1231
|
-
const target = path.resolve(fullPath);
|
|
1232
|
-
if (!isPathInsideRoot(root, target)) {
|
|
1233
|
-
return yield* Effect.fail(new Error("Backup path escapes repository"));
|
|
1234
|
-
}
|
|
1235
|
-
const relative = path.relative(root, target);
|
|
1236
|
-
let current = root;
|
|
1237
|
-
for (const part of relative.split(path.sep).filter(Boolean)) {
|
|
1238
|
-
current = path.join(current, part);
|
|
1239
|
-
const stat = yield* tryPromise(() => fs.lstat(current)).pipe(
|
|
1240
|
-
Effect.catchAll((error) =>
|
|
1241
|
-
error &&
|
|
1242
|
-
typeof error === "object" &&
|
|
1243
|
-
"code" in error &&
|
|
1244
|
-
error.code === "ENOENT"
|
|
1245
|
-
? Effect.succeed(null)
|
|
1246
|
-
: Effect.fail(error),
|
|
1247
|
-
),
|
|
1248
|
-
);
|
|
1249
|
-
if (!stat) return;
|
|
1250
|
-
if (stat.isSymbolicLink()) {
|
|
1251
|
-
return yield* Effect.fail(
|
|
1252
|
-
new Error(
|
|
1253
|
-
`Backup path contains symlink: ${path.relative(root, current)}`,
|
|
1254
|
-
),
|
|
1255
|
-
);
|
|
1256
|
-
}
|
|
1257
|
-
}
|
|
1258
|
-
});
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
function readJsonlFilesEffect(
|
|
1262
|
-
repoPath: string,
|
|
1263
|
-
relativePaths: string[],
|
|
1264
|
-
): Effect.Effect<JsonRecord[], unknown> {
|
|
1265
|
-
return Effect.gen(function* () {
|
|
1266
|
-
const sources = yield* Effect.forEach(
|
|
1267
|
-
relativePaths,
|
|
1268
|
-
(relativePath) =>
|
|
1269
|
-
Effect.gen(function* () {
|
|
1270
|
-
const filePath = yield* trySync(() =>
|
|
1271
|
-
resolveBackupFilePath(repoPath, relativePath),
|
|
1272
|
-
);
|
|
1273
|
-
yield* assertReadableBackupFileEffect(
|
|
1274
|
-
repoPath,
|
|
1275
|
-
filePath,
|
|
1276
|
-
relativePath,
|
|
1277
|
-
);
|
|
1278
|
-
return {
|
|
1279
|
-
id: relativePath,
|
|
1280
|
-
stream: async function* () {
|
|
1281
|
-
for await (const row of streamJsonLines(filePath)) {
|
|
1282
|
-
yield row.value as JsonRecord;
|
|
1283
|
-
}
|
|
1284
|
-
},
|
|
1285
|
-
};
|
|
1286
|
-
}),
|
|
1287
|
-
{ concurrency: "unbounded" },
|
|
1288
|
-
);
|
|
1289
|
-
return yield* collectIngestionSourcesEffect(sources);
|
|
1290
|
-
});
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
function readBackupImportRowsEffect(
|
|
1294
|
-
resolvedRepoPath: string,
|
|
1295
|
-
manifest: BackupManifest,
|
|
1296
|
-
): Effect.Effect<JsonRecord[][], unknown> {
|
|
1297
|
-
const readRows = (predicate: (relativePath: string) => boolean) =>
|
|
1298
|
-
readJsonlFilesEffect(
|
|
1299
|
-
resolvedRepoPath,
|
|
1300
|
-
rowsForManifestPath(manifest, predicate),
|
|
1301
|
-
);
|
|
1302
|
-
|
|
1303
|
-
return Effect.all(
|
|
1304
|
-
[
|
|
1305
|
-
readRows((file) => file === "data/accounts.jsonl"),
|
|
1306
|
-
readRows((file) => file === "data/profiles.jsonl"),
|
|
1307
|
-
readRows((file) => file === "data/profile_affiliations.jsonl"),
|
|
1308
|
-
readRows((file) => file === "data/profile_snapshots.jsonl"),
|
|
1309
|
-
readRows((file) => file === "data/profile_bio_entities.jsonl"),
|
|
1310
|
-
readRows((file) => file.startsWith("data/tweets/")),
|
|
1311
|
-
readRows((file) => file.startsWith("data/collections/")),
|
|
1312
|
-
readRows((file) => file.startsWith("data/timeline_edges/")),
|
|
1313
|
-
readRows((file) => file === "data/dms/conversations.jsonl"),
|
|
1314
|
-
readRows(
|
|
1315
|
-
(file) =>
|
|
1316
|
-
file.startsWith("data/dms/") &&
|
|
1317
|
-
file !== "data/dms/conversations.jsonl",
|
|
1318
|
-
),
|
|
1319
|
-
readRows((file) => file === "data/moderation/blocks.jsonl"),
|
|
1320
|
-
readRows((file) => file === "data/moderation/mutes.jsonl"),
|
|
1321
|
-
readRows((file) => file === "data/actions/tweet_actions.jsonl"),
|
|
1322
|
-
readRows((file) => file === "data/ai_scores.jsonl"),
|
|
1323
|
-
readRows((file) => file === "data/links/url_expansions.jsonl"),
|
|
1324
|
-
readRows((file) => file === "data/links/occurrences.jsonl"),
|
|
1325
|
-
readRows((file) => file === "data/follow_snapshots.jsonl"),
|
|
1326
|
-
readRows((file) => file === "data/follow_snapshot_members.jsonl"),
|
|
1327
|
-
readRows((file) => file === "data/follow_edges.jsonl"),
|
|
1328
|
-
readRows((file) => file === "data/follow_events.jsonl"),
|
|
1329
|
-
],
|
|
1330
|
-
{ concurrency: "unbounded" },
|
|
1331
|
-
);
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
function rowsForManifestPath(
|
|
1335
|
-
manifest: BackupManifest,
|
|
1336
|
-
predicate: (relativePath: string) => boolean,
|
|
1337
|
-
) {
|
|
1338
|
-
return manifest.files
|
|
1339
|
-
.map((file) => file.path)
|
|
1340
|
-
.filter(predicate)
|
|
1341
|
-
.sort();
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
const JSON_URL_KEYS = new Set([
|
|
1345
|
-
"url",
|
|
1346
|
-
"expandedUrl",
|
|
1347
|
-
"expanded_url",
|
|
1348
|
-
"imageUrl",
|
|
1349
|
-
"image_url",
|
|
1350
|
-
"mediaUrl",
|
|
1351
|
-
"media_url",
|
|
1352
|
-
"media_url_https",
|
|
1353
|
-
"thumbnailUrl",
|
|
1354
|
-
"thumbnail_url",
|
|
1355
|
-
"previewImageUrl",
|
|
1356
|
-
"preview_image_url",
|
|
1357
|
-
]);
|
|
1358
|
-
|
|
1359
|
-
function sanitizeJsonUrlValue(key: string, value: JsonValue): JsonValue {
|
|
1360
|
-
if (!JSON_URL_KEYS.has(key)) return value;
|
|
1361
|
-
if (typeof value !== "string" || value.length === 0) return value;
|
|
1362
|
-
return safeHttpUrl(value) ?? "";
|
|
1363
|
-
}
|
|
1364
|
-
|
|
1365
|
-
function sanitizeJsonUrls(value: JsonValue, key = ""): JsonValue {
|
|
1366
|
-
if (Array.isArray(value)) {
|
|
1367
|
-
return value.map((item) => sanitizeJsonUrls(item));
|
|
1368
|
-
}
|
|
1369
|
-
if (value && typeof value === "object") {
|
|
1370
|
-
return Object.fromEntries(
|
|
1371
|
-
Object.entries(value).map(([entryKey, entryValue]) => [
|
|
1372
|
-
entryKey,
|
|
1373
|
-
sanitizeJsonUrls(entryValue, entryKey),
|
|
1374
|
-
]),
|
|
1375
|
-
);
|
|
1376
|
-
}
|
|
1377
|
-
return sanitizeJsonUrlValue(key, value);
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
|
-
function sanitizeJsonTextUrls(value: JsonValue, fallback: JsonValue) {
|
|
1381
|
-
if (typeof value !== "string" || value.length === 0) return value;
|
|
1382
|
-
try {
|
|
1383
|
-
return JSON.stringify(sanitizeJsonUrls(JSON.parse(value) as JsonValue));
|
|
1384
|
-
} catch {
|
|
1385
|
-
return JSON.stringify(fallback);
|
|
1386
|
-
}
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
function sanitizeImportedTweets(rows: JsonRecord[]) {
|
|
1390
|
-
return rows.map((row) => ({
|
|
1391
|
-
...row,
|
|
1392
|
-
entities_json: sanitizeJsonTextUrls(row.entities_json, {}),
|
|
1393
|
-
media_json: sanitizeJsonTextUrls(row.media_json, []),
|
|
1394
|
-
}));
|
|
1395
|
-
}
|
|
1396
|
-
|
|
1397
|
-
function sanitizeImportedUrlExpansions(rows: JsonRecord[]) {
|
|
1398
|
-
return rows.map((row) => {
|
|
1399
|
-
const shortUrl =
|
|
1400
|
-
typeof row.short_url === "string" ? safeHttpUrl(row.short_url) : null;
|
|
1401
|
-
const expandedUrl =
|
|
1402
|
-
typeof row.expanded_url === "string"
|
|
1403
|
-
? safeHttpUrl(row.expanded_url)
|
|
1404
|
-
: null;
|
|
1405
|
-
const finalUrl =
|
|
1406
|
-
typeof row.final_url === "string" ? safeHttpUrl(row.final_url) : null;
|
|
1407
|
-
const safe = Boolean(shortUrl || expandedUrl || finalUrl);
|
|
1408
|
-
return {
|
|
1409
|
-
...row,
|
|
1410
|
-
short_url: shortUrl ?? "",
|
|
1411
|
-
expanded_url: expandedUrl ?? shortUrl ?? "",
|
|
1412
|
-
final_url: finalUrl ?? expandedUrl ?? shortUrl ?? "",
|
|
1413
|
-
status: safe ? row.status : "error",
|
|
1414
|
-
error: safe ? row.error : "unsafe URL stripped from backup import",
|
|
1415
|
-
image_url:
|
|
1416
|
-
typeof row.image_url === "string"
|
|
1417
|
-
? (safeHttpUrl(row.image_url) ?? "")
|
|
1418
|
-
: row.image_url,
|
|
1419
|
-
};
|
|
1420
|
-
});
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
export function importBackupEffect({
|
|
1424
|
-
repoPath,
|
|
1425
|
-
db: providedDb,
|
|
1426
|
-
validate = true,
|
|
1427
|
-
mode = "merge",
|
|
1428
|
-
}: BackupImportOptions): Effect.Effect<BackupImportResult, unknown> {
|
|
1429
|
-
return Effect.gen(function* () {
|
|
1430
|
-
const resolvedRepoPath = yield* trySync(() => path.resolve(repoPath));
|
|
1431
|
-
const db =
|
|
1432
|
-
providedDb ??
|
|
1433
|
-
(yield* trySync(() => getNativeDb({ seedDemoData: false })));
|
|
1434
|
-
const manifest = yield* readManifestEffect(resolvedRepoPath);
|
|
1435
|
-
const validation = validate
|
|
1436
|
-
? yield* validateBackupEffect(resolvedRepoPath)
|
|
1437
|
-
: undefined;
|
|
1438
|
-
if (validation && !validation.ok) {
|
|
1439
|
-
return yield* Effect.fail(
|
|
1440
|
-
new Error(`Backup validation failed: ${validation.errors.join("; ")}`),
|
|
1441
|
-
);
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
const [
|
|
1445
|
-
accounts,
|
|
1446
|
-
profiles,
|
|
1447
|
-
profileAffiliations,
|
|
1448
|
-
profileSnapshots,
|
|
1449
|
-
profileBioEntities,
|
|
1450
|
-
tweets,
|
|
1451
|
-
collections,
|
|
1452
|
-
timelineEdges,
|
|
1453
|
-
conversations,
|
|
1454
|
-
messages,
|
|
1455
|
-
blocks,
|
|
1456
|
-
mutes,
|
|
1457
|
-
actions,
|
|
1458
|
-
scores,
|
|
1459
|
-
urlExpansions,
|
|
1460
|
-
linkOccurrences,
|
|
1461
|
-
followSnapshots,
|
|
1462
|
-
followSnapshotMembers,
|
|
1463
|
-
followEdges,
|
|
1464
|
-
followEvents,
|
|
1465
|
-
] = yield* readBackupImportRowsEffect(resolvedRepoPath, manifest);
|
|
1466
|
-
const sanitizedTweets = yield* trySync(() =>
|
|
1467
|
-
sanitizeImportedTweets(tweets),
|
|
1468
|
-
);
|
|
1469
|
-
const sanitizedUrlExpansions = yield* trySync(() =>
|
|
1470
|
-
sanitizeImportedUrlExpansions(urlExpansions),
|
|
1471
|
-
);
|
|
1472
|
-
const fingerprint = yield* databaseWriteEffect((writeDb) => {
|
|
1473
|
-
const repository = getImportRepository(writeDb);
|
|
1474
|
-
if (mode === "replace") {
|
|
1475
|
-
repository.clearBackupImport();
|
|
1476
|
-
}
|
|
1477
|
-
const tweetFtsIds =
|
|
1478
|
-
mode === "replace"
|
|
1479
|
-
? new Set<string>()
|
|
1480
|
-
: repository.readFtsIds({
|
|
1481
|
-
table: "tweets_fts",
|
|
1482
|
-
idColumn: "tweet_id",
|
|
1483
|
-
});
|
|
1484
|
-
const dmFtsIds =
|
|
1485
|
-
mode === "replace"
|
|
1486
|
-
? new Set<string>()
|
|
1487
|
-
: repository.readFtsIds({
|
|
1488
|
-
table: "dm_fts",
|
|
1489
|
-
idColumn: "message_id",
|
|
1490
|
-
});
|
|
1491
|
-
repository.insertRows(
|
|
1492
|
-
`
|
|
1493
|
-
insert into accounts (id, name, handle, external_user_id, transport, is_default, created_at)
|
|
1494
|
-
values (?, ?, ?, ?, ?, ?, ?)
|
|
1495
|
-
on conflict(id) do update set
|
|
1496
|
-
name = coalesce(nullif(excluded.name, ''), accounts.name),
|
|
1497
|
-
handle = coalesce(nullif(excluded.handle, ''), accounts.handle),
|
|
1498
|
-
external_user_id = coalesce(excluded.external_user_id, accounts.external_user_id),
|
|
1499
|
-
transport = coalesce(nullif(excluded.transport, ''), accounts.transport),
|
|
1500
|
-
is_default = max(accounts.is_default, excluded.is_default),
|
|
1501
|
-
created_at = min(accounts.created_at, excluded.created_at)
|
|
1502
|
-
`,
|
|
1503
|
-
accounts,
|
|
1504
|
-
[
|
|
1505
|
-
"id",
|
|
1506
|
-
"name",
|
|
1507
|
-
"handle",
|
|
1508
|
-
"external_user_id",
|
|
1509
|
-
"transport",
|
|
1510
|
-
"is_default",
|
|
1511
|
-
"created_at",
|
|
1512
|
-
],
|
|
1513
|
-
);
|
|
1514
|
-
repository.insertRows(
|
|
1515
|
-
`
|
|
1516
|
-
insert into profile_snapshots (
|
|
1517
|
-
profile_id, snapshot_hash, observed_at, last_seen_at, source, handle,
|
|
1518
|
-
display_name, bio, location, url, verified_type, followers_count,
|
|
1519
|
-
following_count, affiliations_json, raw_json
|
|
1520
|
-
) values (?, ?, ?, ?, coalesce(?, 'backup'), ?, ?, ?, ?, ?, ?, coalesce(?, 0), coalesce(?, 0), coalesce(?, '[]'), coalesce(?, '{}'))
|
|
1521
|
-
on conflict(profile_id, snapshot_hash) do update set
|
|
1522
|
-
last_seen_at = max(profile_snapshots.last_seen_at, excluded.last_seen_at),
|
|
1523
|
-
source = excluded.source,
|
|
1524
|
-
raw_json = case
|
|
1525
|
-
when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
|
|
1526
|
-
else profile_snapshots.raw_json
|
|
1527
|
-
end
|
|
1528
|
-
`,
|
|
1529
|
-
profileSnapshots,
|
|
1530
|
-
[
|
|
1531
|
-
"profile_id",
|
|
1532
|
-
"snapshot_hash",
|
|
1533
|
-
"observed_at",
|
|
1534
|
-
"last_seen_at",
|
|
1535
|
-
"source",
|
|
1536
|
-
"handle",
|
|
1537
|
-
"display_name",
|
|
1538
|
-
"bio",
|
|
1539
|
-
"location",
|
|
1540
|
-
"url",
|
|
1541
|
-
"verified_type",
|
|
1542
|
-
"followers_count",
|
|
1543
|
-
"following_count",
|
|
1544
|
-
"affiliations_json",
|
|
1545
|
-
"raw_json",
|
|
1546
|
-
],
|
|
1547
|
-
);
|
|
1548
|
-
repository.insertRows(
|
|
1549
|
-
`
|
|
1550
|
-
insert into profile_bio_entities (
|
|
1551
|
-
profile_id, kind, value, source, is_active, first_seen_at, last_seen_at, raw_json
|
|
1552
|
-
) values (?, ?, ?, coalesce(?, 'backup'), coalesce(?, 1), ?, ?, coalesce(?, '{}'))
|
|
1553
|
-
on conflict(profile_id, kind, value) do update set
|
|
1554
|
-
source = excluded.source,
|
|
1555
|
-
is_active = excluded.is_active,
|
|
1556
|
-
last_seen_at = max(profile_bio_entities.last_seen_at, excluded.last_seen_at),
|
|
1557
|
-
raw_json = case
|
|
1558
|
-
when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
|
|
1559
|
-
else profile_bio_entities.raw_json
|
|
1560
|
-
end
|
|
1561
|
-
`,
|
|
1562
|
-
profileBioEntities,
|
|
1563
|
-
[
|
|
1564
|
-
"profile_id",
|
|
1565
|
-
"kind",
|
|
1566
|
-
"value",
|
|
1567
|
-
"source",
|
|
1568
|
-
"is_active",
|
|
1569
|
-
"first_seen_at",
|
|
1570
|
-
"last_seen_at",
|
|
1571
|
-
"raw_json",
|
|
1572
|
-
],
|
|
1573
|
-
);
|
|
1574
|
-
repository.insertRows(
|
|
1575
|
-
`
|
|
1576
|
-
insert into profiles (
|
|
1577
|
-
id, handle, display_name, bio, followers_count, following_count,
|
|
1578
|
-
public_metrics_json, avatar_hue, avatar_url, location, url,
|
|
1579
|
-
verified_type, entities_json, raw_json, created_at
|
|
1580
|
-
) values (?, ?, ?, ?, ?, coalesce(?, 0), coalesce(?, '{}'), ?, ?, ?, ?, ?, coalesce(?, '{}'), coalesce(?, '{}'), ?)
|
|
1581
|
-
on conflict(id) do update set
|
|
1582
|
-
handle = coalesce(nullif(excluded.handle, ''), profiles.handle),
|
|
1583
|
-
display_name = coalesce(nullif(excluded.display_name, ''), profiles.display_name),
|
|
1584
|
-
bio = coalesce(nullif(excluded.bio, ''), profiles.bio),
|
|
1585
|
-
followers_count = max(profiles.followers_count, excluded.followers_count),
|
|
1586
|
-
following_count = max(profiles.following_count, excluded.following_count),
|
|
1587
|
-
public_metrics_json = case
|
|
1588
|
-
when excluded.public_metrics_json not in ('', '{}', 'null') then excluded.public_metrics_json
|
|
1589
|
-
else profiles.public_metrics_json
|
|
1590
|
-
end,
|
|
1591
|
-
avatar_hue = case when profiles.avatar_hue = 0 then excluded.avatar_hue else profiles.avatar_hue end,
|
|
1592
|
-
avatar_url = coalesce(excluded.avatar_url, profiles.avatar_url),
|
|
1593
|
-
location = coalesce(excluded.location, profiles.location),
|
|
1594
|
-
url = coalesce(excluded.url, profiles.url),
|
|
1595
|
-
verified_type = coalesce(excluded.verified_type, profiles.verified_type),
|
|
1596
|
-
entities_json = case
|
|
1597
|
-
when excluded.entities_json not in ('', '{}', 'null') then excluded.entities_json
|
|
1598
|
-
else profiles.entities_json
|
|
1599
|
-
end,
|
|
1600
|
-
raw_json = case
|
|
1601
|
-
when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
|
|
1602
|
-
else profiles.raw_json
|
|
1603
|
-
end,
|
|
1604
|
-
created_at = min(profiles.created_at, excluded.created_at)
|
|
1605
|
-
`,
|
|
1606
|
-
profiles,
|
|
1607
|
-
[
|
|
1608
|
-
"id",
|
|
1609
|
-
"handle",
|
|
1610
|
-
"display_name",
|
|
1611
|
-
"bio",
|
|
1612
|
-
"followers_count",
|
|
1613
|
-
"following_count",
|
|
1614
|
-
"public_metrics_json",
|
|
1615
|
-
"avatar_hue",
|
|
1616
|
-
"avatar_url",
|
|
1617
|
-
"location",
|
|
1618
|
-
"url",
|
|
1619
|
-
"verified_type",
|
|
1620
|
-
"entities_json",
|
|
1621
|
-
"raw_json",
|
|
1622
|
-
"created_at",
|
|
1623
|
-
],
|
|
1624
|
-
);
|
|
1625
|
-
repository.insertRows(
|
|
1626
|
-
`
|
|
1627
|
-
insert into profile_affiliations (
|
|
1628
|
-
subject_profile_id, organization_profile_id, organization_name,
|
|
1629
|
-
organization_handle, badge_url, url, label, source, is_active,
|
|
1630
|
-
first_seen_at, last_seen_at, raw_json, updated_at
|
|
1631
|
-
) values (?, ?, ?, ?, ?, ?, ?, coalesce(?, 'backup'), coalesce(?, 1), ?, ?, coalesce(?, '{}'), ?)
|
|
1632
|
-
on conflict(subject_profile_id, organization_profile_id) do update set
|
|
1633
|
-
organization_name = coalesce(excluded.organization_name, profile_affiliations.organization_name),
|
|
1634
|
-
organization_handle = coalesce(excluded.organization_handle, profile_affiliations.organization_handle),
|
|
1635
|
-
badge_url = coalesce(excluded.badge_url, profile_affiliations.badge_url),
|
|
1636
|
-
url = coalesce(excluded.url, profile_affiliations.url),
|
|
1637
|
-
label = coalesce(excluded.label, profile_affiliations.label),
|
|
1638
|
-
source = excluded.source,
|
|
1639
|
-
is_active = excluded.is_active,
|
|
1640
|
-
last_seen_at = max(profile_affiliations.last_seen_at, excluded.last_seen_at),
|
|
1641
|
-
raw_json = case
|
|
1642
|
-
when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
|
|
1643
|
-
else profile_affiliations.raw_json
|
|
1644
|
-
end,
|
|
1645
|
-
updated_at = excluded.updated_at
|
|
1646
|
-
`,
|
|
1647
|
-
profileAffiliations,
|
|
1648
|
-
[
|
|
1649
|
-
"subject_profile_id",
|
|
1650
|
-
"organization_profile_id",
|
|
1651
|
-
"organization_name",
|
|
1652
|
-
"organization_handle",
|
|
1653
|
-
"badge_url",
|
|
1654
|
-
"url",
|
|
1655
|
-
"label",
|
|
1656
|
-
"source",
|
|
1657
|
-
"is_active",
|
|
1658
|
-
"first_seen_at",
|
|
1659
|
-
"last_seen_at",
|
|
1660
|
-
"raw_json",
|
|
1661
|
-
"updated_at",
|
|
1662
|
-
],
|
|
1663
|
-
);
|
|
1664
|
-
repository.insertRows(
|
|
1665
|
-
`
|
|
1666
|
-
insert into follow_snapshots (
|
|
1667
|
-
id, account_id, direction, source, status, page_count, result_count,
|
|
1668
|
-
started_at, completed_at, raw_meta_json
|
|
1669
|
-
) values (?, ?, ?, coalesce(?, 'backup'), ?, coalesce(?, 0), coalesce(?, 0), ?, ?, coalesce(?, '{}'))
|
|
1670
|
-
on conflict(id) do update set
|
|
1671
|
-
account_id = coalesce(nullif(excluded.account_id, ''), follow_snapshots.account_id),
|
|
1672
|
-
direction = coalesce(nullif(excluded.direction, ''), follow_snapshots.direction),
|
|
1673
|
-
source = coalesce(nullif(excluded.source, ''), follow_snapshots.source),
|
|
1674
|
-
status = coalesce(nullif(excluded.status, ''), follow_snapshots.status),
|
|
1675
|
-
page_count = max(follow_snapshots.page_count, excluded.page_count),
|
|
1676
|
-
result_count = max(follow_snapshots.result_count, excluded.result_count),
|
|
1677
|
-
started_at = min(follow_snapshots.started_at, excluded.started_at),
|
|
1678
|
-
completed_at = max(follow_snapshots.completed_at, excluded.completed_at),
|
|
1679
|
-
raw_meta_json = case
|
|
1680
|
-
when excluded.raw_meta_json not in ('', '{}', 'null') then excluded.raw_meta_json
|
|
1681
|
-
else follow_snapshots.raw_meta_json
|
|
1682
|
-
end
|
|
1683
|
-
`,
|
|
1684
|
-
followSnapshots,
|
|
1685
|
-
[
|
|
1686
|
-
"id",
|
|
1687
|
-
"account_id",
|
|
1688
|
-
"direction",
|
|
1689
|
-
"source",
|
|
1690
|
-
"status",
|
|
1691
|
-
"page_count",
|
|
1692
|
-
"result_count",
|
|
1693
|
-
"started_at",
|
|
1694
|
-
"completed_at",
|
|
1695
|
-
"raw_meta_json",
|
|
1696
|
-
],
|
|
1697
|
-
);
|
|
1698
|
-
repository.insertRows(
|
|
1699
|
-
`
|
|
1700
|
-
insert into follow_snapshot_members (
|
|
1701
|
-
snapshot_id, profile_id, external_user_id, position
|
|
1702
|
-
) values (?, ?, ?, coalesce(?, 0))
|
|
1703
|
-
on conflict(snapshot_id, profile_id) do update set
|
|
1704
|
-
external_user_id = coalesce(nullif(excluded.external_user_id, ''), follow_snapshot_members.external_user_id),
|
|
1705
|
-
position = excluded.position
|
|
1706
|
-
`,
|
|
1707
|
-
followSnapshotMembers,
|
|
1708
|
-
["snapshot_id", "profile_id", "external_user_id", "position"],
|
|
1709
|
-
);
|
|
1710
|
-
repository.insertRows(
|
|
1711
|
-
`
|
|
1712
|
-
insert into follow_edges (
|
|
1713
|
-
account_id, direction, profile_id, external_user_id, source, current,
|
|
1714
|
-
first_seen_at, last_seen_at, ended_at, updated_at
|
|
1715
|
-
) values (?, ?, ?, ?, coalesce(?, 'backup'), coalesce(?, 1), ?, ?, ?, ?)
|
|
1716
|
-
on conflict(account_id, direction, profile_id) do update set
|
|
1717
|
-
external_user_id = coalesce(nullif(excluded.external_user_id, ''), follow_edges.external_user_id),
|
|
1718
|
-
source = coalesce(nullif(excluded.source, ''), follow_edges.source),
|
|
1719
|
-
current = case
|
|
1720
|
-
when excluded.updated_at >= follow_edges.updated_at then excluded.current
|
|
1721
|
-
else follow_edges.current
|
|
1722
|
-
end,
|
|
1723
|
-
first_seen_at = min(follow_edges.first_seen_at, excluded.first_seen_at),
|
|
1724
|
-
last_seen_at = max(follow_edges.last_seen_at, excluded.last_seen_at),
|
|
1725
|
-
ended_at = case
|
|
1726
|
-
when excluded.updated_at >= follow_edges.updated_at then excluded.ended_at
|
|
1727
|
-
else follow_edges.ended_at
|
|
1728
|
-
end,
|
|
1729
|
-
updated_at = max(follow_edges.updated_at, excluded.updated_at)
|
|
1730
|
-
`,
|
|
1731
|
-
followEdges,
|
|
1732
|
-
[
|
|
1733
|
-
"account_id",
|
|
1734
|
-
"direction",
|
|
1735
|
-
"profile_id",
|
|
1736
|
-
"external_user_id",
|
|
1737
|
-
"source",
|
|
1738
|
-
"current",
|
|
1739
|
-
"first_seen_at",
|
|
1740
|
-
"last_seen_at",
|
|
1741
|
-
"ended_at",
|
|
1742
|
-
"updated_at",
|
|
1743
|
-
],
|
|
1744
|
-
);
|
|
1745
|
-
repository.insertRows(
|
|
1746
|
-
`
|
|
1747
|
-
insert into follow_events (
|
|
1748
|
-
id, account_id, direction, profile_id, external_user_id, kind, event_at,
|
|
1749
|
-
snapshot_id
|
|
1750
|
-
) values (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1751
|
-
on conflict(id) do update set
|
|
1752
|
-
account_id = coalesce(nullif(excluded.account_id, ''), follow_events.account_id),
|
|
1753
|
-
direction = coalesce(nullif(excluded.direction, ''), follow_events.direction),
|
|
1754
|
-
profile_id = coalesce(nullif(excluded.profile_id, ''), follow_events.profile_id),
|
|
1755
|
-
external_user_id = coalesce(nullif(excluded.external_user_id, ''), follow_events.external_user_id),
|
|
1756
|
-
kind = coalesce(nullif(excluded.kind, ''), follow_events.kind),
|
|
1757
|
-
event_at = coalesce(nullif(excluded.event_at, ''), follow_events.event_at),
|
|
1758
|
-
snapshot_id = coalesce(nullif(excluded.snapshot_id, ''), follow_events.snapshot_id)
|
|
1759
|
-
`,
|
|
1760
|
-
followEvents,
|
|
1761
|
-
[
|
|
1762
|
-
"id",
|
|
1763
|
-
"account_id",
|
|
1764
|
-
"direction",
|
|
1765
|
-
"profile_id",
|
|
1766
|
-
"external_user_id",
|
|
1767
|
-
"kind",
|
|
1768
|
-
"event_at",
|
|
1769
|
-
"snapshot_id",
|
|
1770
|
-
],
|
|
1771
|
-
);
|
|
1772
|
-
repository.insertRows(
|
|
1773
|
-
`
|
|
1774
|
-
insert into tweets (
|
|
1775
|
-
id, account_id, author_profile_id, kind, text, created_at, is_replied,
|
|
1776
|
-
reply_to_id, like_count, media_count, bookmarked, liked, entities_json,
|
|
1777
|
-
media_json, quoted_tweet_id
|
|
1778
|
-
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1779
|
-
on conflict(id) do update set
|
|
1780
|
-
account_id = coalesce(nullif(excluded.account_id, ''), tweets.account_id),
|
|
1781
|
-
author_profile_id = coalesce(nullif(excluded.author_profile_id, ''), tweets.author_profile_id),
|
|
1782
|
-
kind = case
|
|
1783
|
-
when tweets.kind in ('home', 'mention', 'authored', 'search') then tweets.kind
|
|
1784
|
-
when excluded.kind in ('home', 'mention', 'authored', 'search') then excluded.kind
|
|
1785
|
-
else coalesce(nullif(excluded.kind, ''), tweets.kind)
|
|
1786
|
-
end,
|
|
1787
|
-
text = coalesce(nullif(excluded.text, ''), tweets.text),
|
|
1788
|
-
created_at = min(tweets.created_at, excluded.created_at),
|
|
1789
|
-
is_replied = max(tweets.is_replied, excluded.is_replied),
|
|
1790
|
-
reply_to_id = coalesce(excluded.reply_to_id, tweets.reply_to_id),
|
|
1791
|
-
like_count = max(tweets.like_count, excluded.like_count),
|
|
1792
|
-
media_count = max(tweets.media_count, excluded.media_count),
|
|
1793
|
-
bookmarked = max(tweets.bookmarked, excluded.bookmarked),
|
|
1794
|
-
liked = max(tweets.liked, excluded.liked),
|
|
1795
|
-
entities_json = case
|
|
1796
|
-
when excluded.entities_json not in ('', '{}', 'null') then excluded.entities_json
|
|
1797
|
-
else tweets.entities_json
|
|
1798
|
-
end,
|
|
1799
|
-
media_json = case
|
|
1800
|
-
when excluded.media_json not in ('', '[]', 'null') then excluded.media_json
|
|
1801
|
-
else tweets.media_json
|
|
1802
|
-
end,
|
|
1803
|
-
quoted_tweet_id = coalesce(excluded.quoted_tweet_id, tweets.quoted_tweet_id)
|
|
1804
|
-
`,
|
|
1805
|
-
sanitizedTweets,
|
|
1806
|
-
[
|
|
1807
|
-
"id",
|
|
1808
|
-
"account_id",
|
|
1809
|
-
"author_profile_id",
|
|
1810
|
-
"kind",
|
|
1811
|
-
"text",
|
|
1812
|
-
"created_at",
|
|
1813
|
-
"is_replied",
|
|
1814
|
-
"reply_to_id",
|
|
1815
|
-
"like_count",
|
|
1816
|
-
"media_count",
|
|
1817
|
-
"bookmarked",
|
|
1818
|
-
"liked",
|
|
1819
|
-
"entities_json",
|
|
1820
|
-
"media_json",
|
|
1821
|
-
"quoted_tweet_id",
|
|
1822
|
-
],
|
|
1823
|
-
);
|
|
1824
|
-
repository.insertFtsRows({
|
|
1825
|
-
target: { table: "tweets_fts", idColumn: "tweet_id" },
|
|
1826
|
-
rows: sanitizedTweets,
|
|
1827
|
-
idKey: "id",
|
|
1828
|
-
textKey: "text",
|
|
1829
|
-
existingIds: tweetFtsIds,
|
|
1830
|
-
});
|
|
1831
|
-
repository.insertRows(
|
|
1832
|
-
`
|
|
1833
|
-
insert into tweet_collections (
|
|
1834
|
-
account_id, tweet_id, kind, collected_at, source, raw_json, updated_at
|
|
1835
|
-
) values (?, ?, ?, ?, ?, ?, ?)
|
|
1836
|
-
on conflict(account_id, tweet_id, kind) do update set
|
|
1837
|
-
collected_at = coalesce(tweet_collections.collected_at, excluded.collected_at),
|
|
1838
|
-
source = coalesce(nullif(excluded.source, ''), tweet_collections.source),
|
|
1839
|
-
raw_json = case
|
|
1840
|
-
when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
|
|
1841
|
-
else tweet_collections.raw_json
|
|
1842
|
-
end,
|
|
1843
|
-
updated_at = max(tweet_collections.updated_at, excluded.updated_at)
|
|
1844
|
-
`,
|
|
1845
|
-
collections,
|
|
1846
|
-
[
|
|
1847
|
-
"account_id",
|
|
1848
|
-
"tweet_id",
|
|
1849
|
-
"kind",
|
|
1850
|
-
"collected_at",
|
|
1851
|
-
"source",
|
|
1852
|
-
"raw_json",
|
|
1853
|
-
"updated_at",
|
|
1854
|
-
],
|
|
1855
|
-
);
|
|
1856
|
-
repository.insertRows(
|
|
1857
|
-
`
|
|
1858
|
-
insert into tweet_account_edges (
|
|
1859
|
-
account_id, tweet_id, kind, first_seen_at, last_seen_at, seen_count,
|
|
1860
|
-
source, raw_json, updated_at
|
|
1861
|
-
) values (?, ?, ?, ?, ?, coalesce(?, 1), coalesce(?, 'backup'), coalesce(?, '{}'), ?)
|
|
1862
|
-
on conflict(account_id, tweet_id, kind) do update set
|
|
1863
|
-
first_seen_at = min(tweet_account_edges.first_seen_at, excluded.first_seen_at),
|
|
1864
|
-
last_seen_at = max(tweet_account_edges.last_seen_at, excluded.last_seen_at),
|
|
1865
|
-
seen_count = max(tweet_account_edges.seen_count, excluded.seen_count),
|
|
1866
|
-
source = coalesce(nullif(excluded.source, ''), tweet_account_edges.source),
|
|
1867
|
-
raw_json = case
|
|
1868
|
-
when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
|
|
1869
|
-
else tweet_account_edges.raw_json
|
|
1870
|
-
end,
|
|
1871
|
-
updated_at = max(tweet_account_edges.updated_at, excluded.updated_at)
|
|
1872
|
-
`,
|
|
1873
|
-
timelineEdges,
|
|
1874
|
-
[
|
|
1875
|
-
"account_id",
|
|
1876
|
-
"tweet_id",
|
|
1877
|
-
"kind",
|
|
1878
|
-
"first_seen_at",
|
|
1879
|
-
"last_seen_at",
|
|
1880
|
-
"seen_count",
|
|
1881
|
-
"source",
|
|
1882
|
-
"raw_json",
|
|
1883
|
-
"updated_at",
|
|
1884
|
-
],
|
|
1885
|
-
);
|
|
1886
|
-
repository.insertRows(
|
|
1887
|
-
`
|
|
1888
|
-
insert into dm_conversations (
|
|
1889
|
-
id, account_id, participant_profile_id, title, inbox_kind, last_message_at, unread_count, needs_reply
|
|
1890
|
-
) values (?, ?, ?, ?, coalesce(?, 'accepted'), ?, ?, ?)
|
|
1891
|
-
on conflict(id) do update set
|
|
1892
|
-
account_id = coalesce(nullif(excluded.account_id, ''), dm_conversations.account_id),
|
|
1893
|
-
participant_profile_id = coalesce(nullif(excluded.participant_profile_id, ''), dm_conversations.participant_profile_id),
|
|
1894
|
-
title = coalesce(nullif(excluded.title, ''), dm_conversations.title),
|
|
1895
|
-
inbox_kind = case
|
|
1896
|
-
when excluded.last_message_at > dm_conversations.last_message_at
|
|
1897
|
-
then coalesce(nullif(excluded.inbox_kind, ''), dm_conversations.inbox_kind)
|
|
1898
|
-
else dm_conversations.inbox_kind
|
|
1899
|
-
end,
|
|
1900
|
-
last_message_at = max(dm_conversations.last_message_at, excluded.last_message_at),
|
|
1901
|
-
unread_count = max(dm_conversations.unread_count, excluded.unread_count),
|
|
1902
|
-
needs_reply = max(dm_conversations.needs_reply, excluded.needs_reply)
|
|
1903
|
-
`,
|
|
1904
|
-
conversations,
|
|
1905
|
-
[
|
|
1906
|
-
"id",
|
|
1907
|
-
"account_id",
|
|
1908
|
-
"participant_profile_id",
|
|
1909
|
-
"title",
|
|
1910
|
-
"inbox_kind",
|
|
1911
|
-
"last_message_at",
|
|
1912
|
-
"unread_count",
|
|
1913
|
-
"needs_reply",
|
|
1914
|
-
],
|
|
1915
|
-
);
|
|
1916
|
-
repository.insertRows(
|
|
1917
|
-
`
|
|
1918
|
-
insert into dm_messages (
|
|
1919
|
-
id, conversation_id, sender_profile_id, text, created_at, direction, is_replied, media_count
|
|
1920
|
-
) values (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1921
|
-
on conflict(id) do update set
|
|
1922
|
-
conversation_id = coalesce(nullif(excluded.conversation_id, ''), dm_messages.conversation_id),
|
|
1923
|
-
sender_profile_id = coalesce(nullif(excluded.sender_profile_id, ''), dm_messages.sender_profile_id),
|
|
1924
|
-
text = coalesce(nullif(excluded.text, ''), dm_messages.text),
|
|
1925
|
-
created_at = min(dm_messages.created_at, excluded.created_at),
|
|
1926
|
-
direction = coalesce(nullif(excluded.direction, ''), dm_messages.direction),
|
|
1927
|
-
is_replied = max(dm_messages.is_replied, excluded.is_replied),
|
|
1928
|
-
media_count = max(dm_messages.media_count, excluded.media_count)
|
|
1929
|
-
`,
|
|
1930
|
-
messages,
|
|
1931
|
-
[
|
|
1932
|
-
"id",
|
|
1933
|
-
"conversation_id",
|
|
1934
|
-
"sender_profile_id",
|
|
1935
|
-
"text",
|
|
1936
|
-
"created_at",
|
|
1937
|
-
"direction",
|
|
1938
|
-
"is_replied",
|
|
1939
|
-
"media_count",
|
|
1940
|
-
],
|
|
1941
|
-
);
|
|
1942
|
-
repository.insertFtsRows({
|
|
1943
|
-
target: { table: "dm_fts", idColumn: "message_id" },
|
|
1944
|
-
rows: messages,
|
|
1945
|
-
idKey: "id",
|
|
1946
|
-
textKey: "text",
|
|
1947
|
-
existingIds: dmFtsIds,
|
|
1948
|
-
});
|
|
1949
|
-
repository.insertRows(
|
|
1950
|
-
`
|
|
1951
|
-
insert into url_expansions (
|
|
1952
|
-
short_url, expanded_url, final_url, status, expanded_tweet_id,
|
|
1953
|
-
expanded_handle, title, description, image_url, site_name, error, source,
|
|
1954
|
-
updated_at
|
|
1955
|
-
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1956
|
-
on conflict(short_url) do update set
|
|
1957
|
-
expanded_url = excluded.expanded_url,
|
|
1958
|
-
final_url = excluded.final_url,
|
|
1959
|
-
status = excluded.status,
|
|
1960
|
-
expanded_tweet_id = excluded.expanded_tweet_id,
|
|
1961
|
-
expanded_handle = excluded.expanded_handle,
|
|
1962
|
-
title = excluded.title,
|
|
1963
|
-
description = excluded.description,
|
|
1964
|
-
image_url = excluded.image_url,
|
|
1965
|
-
site_name = excluded.site_name,
|
|
1966
|
-
error = excluded.error,
|
|
1967
|
-
source = excluded.source,
|
|
1968
|
-
updated_at = excluded.updated_at
|
|
1969
|
-
`,
|
|
1970
|
-
sanitizedUrlExpansions,
|
|
1971
|
-
[
|
|
1972
|
-
"short_url",
|
|
1973
|
-
"expanded_url",
|
|
1974
|
-
"final_url",
|
|
1975
|
-
"status",
|
|
1976
|
-
"expanded_tweet_id",
|
|
1977
|
-
"expanded_handle",
|
|
1978
|
-
"title",
|
|
1979
|
-
"description",
|
|
1980
|
-
"image_url",
|
|
1981
|
-
"site_name",
|
|
1982
|
-
"error",
|
|
1983
|
-
"source",
|
|
1984
|
-
"updated_at",
|
|
1985
|
-
],
|
|
1986
|
-
);
|
|
1987
|
-
repository.insertRows(
|
|
1988
|
-
`
|
|
1989
|
-
insert into link_occurrences (
|
|
1990
|
-
source_kind, source_id, source_position, short_url, account_id,
|
|
1991
|
-
conversation_id, direction, created_at
|
|
1992
|
-
) values (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1993
|
-
on conflict(source_kind, source_id, source_position, short_url) do update set
|
|
1994
|
-
account_id = excluded.account_id,
|
|
1995
|
-
conversation_id = excluded.conversation_id,
|
|
1996
|
-
direction = excluded.direction,
|
|
1997
|
-
created_at = excluded.created_at
|
|
1998
|
-
`,
|
|
1999
|
-
linkOccurrences,
|
|
2000
|
-
[
|
|
2001
|
-
"source_kind",
|
|
2002
|
-
"source_id",
|
|
2003
|
-
"source_position",
|
|
2004
|
-
"short_url",
|
|
2005
|
-
"account_id",
|
|
2006
|
-
"conversation_id",
|
|
2007
|
-
"direction",
|
|
2008
|
-
"created_at",
|
|
2009
|
-
],
|
|
2010
|
-
);
|
|
2011
|
-
repository.insertRows(
|
|
2012
|
-
`
|
|
2013
|
-
insert into blocks (account_id, profile_id, source, created_at)
|
|
2014
|
-
values (?, ?, ?, ?)
|
|
2015
|
-
on conflict(account_id, profile_id) do update set
|
|
2016
|
-
source = coalesce(nullif(excluded.source, ''), blocks.source),
|
|
2017
|
-
created_at = min(blocks.created_at, excluded.created_at)
|
|
2018
|
-
`,
|
|
2019
|
-
blocks,
|
|
2020
|
-
["account_id", "profile_id", "source", "created_at"],
|
|
2021
|
-
);
|
|
2022
|
-
repository.insertRows(
|
|
2023
|
-
`
|
|
2024
|
-
insert into mutes (account_id, profile_id, source, created_at)
|
|
2025
|
-
values (?, ?, ?, ?)
|
|
2026
|
-
on conflict(account_id, profile_id) do update set
|
|
2027
|
-
source = coalesce(nullif(excluded.source, ''), mutes.source),
|
|
2028
|
-
created_at = min(mutes.created_at, excluded.created_at)
|
|
2029
|
-
`,
|
|
2030
|
-
mutes,
|
|
2031
|
-
["account_id", "profile_id", "source", "created_at"],
|
|
2032
|
-
);
|
|
2033
|
-
repository.insertRows(
|
|
2034
|
-
`
|
|
2035
|
-
insert into tweet_actions (id, account_id, tweet_id, kind, body, created_at)
|
|
2036
|
-
values (?, ?, ?, ?, ?, ?)
|
|
2037
|
-
on conflict(id) do update set
|
|
2038
|
-
account_id = coalesce(nullif(excluded.account_id, ''), tweet_actions.account_id),
|
|
2039
|
-
tweet_id = coalesce(excluded.tweet_id, tweet_actions.tweet_id),
|
|
2040
|
-
kind = coalesce(nullif(excluded.kind, ''), tweet_actions.kind),
|
|
2041
|
-
body = coalesce(nullif(excluded.body, ''), tweet_actions.body),
|
|
2042
|
-
created_at = min(tweet_actions.created_at, excluded.created_at)
|
|
2043
|
-
`,
|
|
2044
|
-
actions,
|
|
2045
|
-
["id", "account_id", "tweet_id", "kind", "body", "created_at"],
|
|
2046
|
-
);
|
|
2047
|
-
repository.insertRows(
|
|
2048
|
-
`
|
|
2049
|
-
insert into ai_scores (
|
|
2050
|
-
entity_kind, entity_id, model, score, summary, reasoning, updated_at
|
|
2051
|
-
) values (?, ?, ?, ?, ?, ?, ?)
|
|
2052
|
-
on conflict(entity_kind, entity_id) do update set
|
|
2053
|
-
model = coalesce(nullif(excluded.model, ''), ai_scores.model),
|
|
2054
|
-
score = max(ai_scores.score, excluded.score),
|
|
2055
|
-
summary = coalesce(nullif(excluded.summary, ''), ai_scores.summary),
|
|
2056
|
-
reasoning = coalesce(nullif(excluded.reasoning, ''), ai_scores.reasoning),
|
|
2057
|
-
updated_at = max(ai_scores.updated_at, excluded.updated_at)
|
|
2058
|
-
`,
|
|
2059
|
-
scores,
|
|
2060
|
-
[
|
|
2061
|
-
"entity_kind",
|
|
2062
|
-
"entity_id",
|
|
2063
|
-
"model",
|
|
2064
|
-
"score",
|
|
2065
|
-
"summary",
|
|
2066
|
-
"reasoning",
|
|
2067
|
-
"updated_at",
|
|
2068
|
-
],
|
|
2069
|
-
);
|
|
2070
|
-
return getBackupDatabaseFingerprint(writeDb);
|
|
2071
|
-
}, db);
|
|
2072
|
-
|
|
2073
|
-
return {
|
|
2074
|
-
ok: true,
|
|
2075
|
-
repoPath: resolvedRepoPath,
|
|
2076
|
-
mode,
|
|
2077
|
-
manifest,
|
|
2078
|
-
...(validation ? { validation } : {}),
|
|
2079
|
-
fingerprint,
|
|
2080
|
-
};
|
|
2081
|
-
});
|
|
2082
|
-
}
|
|
2083
|
-
|
|
2084
|
-
export function importBackup(
|
|
2085
|
-
options: BackupImportOptions,
|
|
2086
|
-
): Promise<BackupImportResult> {
|
|
2087
|
-
return runEffectPromise(importBackupEffect(options));
|
|
2088
|
-
}
|
|
2089
|
-
|
|
2090
|
-
export interface SyncBackupOptions {
|
|
2091
|
-
repoPath: string;
|
|
2092
|
-
remote?: string;
|
|
2093
|
-
db?: Database;
|
|
2094
|
-
message?: string;
|
|
2095
|
-
}
|
|
2096
|
-
|
|
2097
|
-
export function syncBackupEffect({
|
|
2098
|
-
repoPath,
|
|
2099
|
-
remote,
|
|
2100
|
-
message = "archive: sync birdclaw backup",
|
|
2101
|
-
db,
|
|
2102
|
-
}: SyncBackupOptions): Effect.Effect<BackupSyncResult, unknown> {
|
|
2103
|
-
return Effect.gen(function* () {
|
|
2104
|
-
const resolvedRepoPath = path.resolve(repoPath);
|
|
2105
|
-
const database =
|
|
2106
|
-
db ?? (yield* trySync(() => getNativeDb({ seedDemoData: false })));
|
|
2107
|
-
yield* ensureBackupGitRepoEffect({ repoPath: resolvedRepoPath, remote });
|
|
2108
|
-
const pulled = yield* pullBackupGitRepoEffect(resolvedRepoPath);
|
|
2109
|
-
const manifestExists = yield* trySync(() =>
|
|
2110
|
-
existsSync(path.join(resolvedRepoPath, MANIFEST_PATH)),
|
|
2111
|
-
);
|
|
2112
|
-
const importResult = manifestExists
|
|
2113
|
-
? yield* importBackupEffect({
|
|
2114
|
-
repoPath: resolvedRepoPath,
|
|
2115
|
-
db: database,
|
|
2116
|
-
mode: "merge",
|
|
2117
|
-
})
|
|
2118
|
-
: undefined;
|
|
2119
|
-
const exportResult = yield* exportBackupEffect({
|
|
2120
|
-
repoPath: resolvedRepoPath,
|
|
2121
|
-
db: database,
|
|
2122
|
-
commit: true,
|
|
2123
|
-
push: true,
|
|
2124
|
-
message,
|
|
2125
|
-
});
|
|
2126
|
-
|
|
2127
|
-
return {
|
|
2128
|
-
ok: true,
|
|
2129
|
-
repoPath: resolvedRepoPath,
|
|
2130
|
-
...(remote ? { remote: redactSecretUrl(remote) } : {}),
|
|
2131
|
-
pulled,
|
|
2132
|
-
imported: Boolean(importResult),
|
|
2133
|
-
...(importResult ? { importResult } : {}),
|
|
2134
|
-
exportResult,
|
|
2135
|
-
};
|
|
2136
|
-
});
|
|
2137
|
-
}
|
|
2138
|
-
|
|
2139
|
-
export function syncBackup(
|
|
2140
|
-
options: SyncBackupOptions,
|
|
2141
|
-
): Promise<BackupSyncResult> {
|
|
2142
|
-
return runEffectPromise(syncBackupEffect(options));
|
|
2143
|
-
}
|
|
2144
|
-
|
|
2145
|
-
export interface UpdateBackupFromGitOptions {
|
|
2146
|
-
repoPath: string;
|
|
2147
|
-
remote?: string;
|
|
2148
|
-
db?: Database;
|
|
2149
|
-
}
|
|
2150
|
-
|
|
2151
|
-
export interface UpdateBackupFromGitResult {
|
|
2152
|
-
ok: true;
|
|
2153
|
-
repoPath: string;
|
|
2154
|
-
remote?: string;
|
|
2155
|
-
pulled: boolean;
|
|
2156
|
-
imported: boolean;
|
|
2157
|
-
importResult?: BackupImportResult;
|
|
2158
|
-
}
|
|
2159
|
-
|
|
2160
|
-
export function updateBackupFromGitEffect({
|
|
2161
|
-
repoPath,
|
|
2162
|
-
remote,
|
|
2163
|
-
db,
|
|
2164
|
-
}: UpdateBackupFromGitOptions): Effect.Effect<
|
|
2165
|
-
UpdateBackupFromGitResult,
|
|
2166
|
-
unknown
|
|
2167
|
-
> {
|
|
2168
|
-
return Effect.gen(function* () {
|
|
2169
|
-
const resolvedRepoPath = path.resolve(repoPath);
|
|
2170
|
-
const database =
|
|
2171
|
-
db ?? (yield* trySync(() => getNativeDb({ seedDemoData: false })));
|
|
2172
|
-
yield* ensureBackupGitRepoEffect({ repoPath: resolvedRepoPath, remote });
|
|
2173
|
-
const pulled = yield* pullBackupGitRepoEffect(resolvedRepoPath);
|
|
2174
|
-
const manifestExists = yield* trySync(() =>
|
|
2175
|
-
existsSync(path.join(resolvedRepoPath, MANIFEST_PATH)),
|
|
2176
|
-
);
|
|
2177
|
-
const importResult = manifestExists
|
|
2178
|
-
? yield* importBackupEffect({
|
|
2179
|
-
repoPath: resolvedRepoPath,
|
|
2180
|
-
db: database,
|
|
2181
|
-
mode: "merge",
|
|
2182
|
-
})
|
|
2183
|
-
: undefined;
|
|
2184
|
-
|
|
2185
|
-
return {
|
|
2186
|
-
ok: true,
|
|
2187
|
-
repoPath: resolvedRepoPath,
|
|
2188
|
-
...(remote ? { remote: redactSecretUrl(remote) } : {}),
|
|
2189
|
-
pulled,
|
|
2190
|
-
imported: Boolean(importResult),
|
|
2191
|
-
...(importResult ? { importResult } : {}),
|
|
2192
|
-
};
|
|
2193
|
-
});
|
|
2194
|
-
}
|
|
2195
|
-
|
|
2196
|
-
export function updateBackupFromGit(
|
|
2197
|
-
options: UpdateBackupFromGitOptions,
|
|
2198
|
-
): Promise<UpdateBackupFromGitResult> {
|
|
2199
|
-
return runEffectPromise(updateBackupFromGitEffect(options));
|
|
2200
|
-
}
|
|
2201
|
-
|
|
2202
|
-
function readAutoSyncState(db: Database) {
|
|
2203
|
-
const row = db
|
|
2204
|
-
.prepare("select value_json from sync_cache where cache_key = ?")
|
|
2205
|
-
.get(AUTO_SYNC_CACHE_KEY) as { value_json: string } | undefined;
|
|
2206
|
-
if (!row) {
|
|
2207
|
-
return null;
|
|
2208
|
-
}
|
|
2209
|
-
try {
|
|
2210
|
-
return JSON.parse(row.value_json) as {
|
|
2211
|
-
checkedAt?: string;
|
|
2212
|
-
ok?: boolean;
|
|
2213
|
-
error?: string;
|
|
2214
|
-
};
|
|
2215
|
-
} catch {
|
|
2216
|
-
return null;
|
|
2217
|
-
}
|
|
2218
|
-
}
|
|
2219
|
-
|
|
2220
|
-
function writeAutoSyncState(
|
|
2221
|
-
db: Database,
|
|
2222
|
-
value: { checkedAt: string; ok: boolean; error?: string },
|
|
2223
|
-
) {
|
|
2224
|
-
db.prepare(
|
|
2225
|
-
`
|
|
2226
|
-
insert into sync_cache (cache_key, value_json, updated_at)
|
|
2227
|
-
values (?, ?, ?)
|
|
2228
|
-
on conflict(cache_key) do update set
|
|
2229
|
-
value_json = excluded.value_json,
|
|
2230
|
-
updated_at = excluded.updated_at
|
|
2231
|
-
`,
|
|
2232
|
-
).run(AUTO_SYNC_CACHE_KEY, JSON.stringify(value), value.checkedAt);
|
|
2233
|
-
}
|
|
2234
|
-
|
|
2235
|
-
function resolveAutoSyncConfig() {
|
|
2236
|
-
const backup = getBirdclawConfig().backup;
|
|
2237
|
-
if (!backup || backup.autoSync === false) {
|
|
2238
|
-
return null;
|
|
2239
|
-
}
|
|
2240
|
-
const repoPath = backup.repoPath?.trim();
|
|
2241
|
-
const remote = backup.remote?.trim();
|
|
2242
|
-
if (!repoPath && !remote) {
|
|
2243
|
-
return null;
|
|
2244
|
-
}
|
|
2245
|
-
const staleAfterSeconds =
|
|
2246
|
-
typeof backup.staleAfterSeconds === "number" &&
|
|
2247
|
-
Number.isFinite(backup.staleAfterSeconds) &&
|
|
2248
|
-
backup.staleAfterSeconds >= 0
|
|
2249
|
-
? Math.floor(backup.staleAfterSeconds)
|
|
2250
|
-
: DEFAULT_STALE_AFTER_SECONDS;
|
|
2251
|
-
|
|
2252
|
-
return {
|
|
2253
|
-
repoPath:
|
|
2254
|
-
repoPath ||
|
|
2255
|
-
path.join(process.env.HOME || ".", "Projects", "backup-birdclaw"),
|
|
2256
|
-
remote,
|
|
2257
|
-
staleAfterSeconds,
|
|
2258
|
-
};
|
|
2259
|
-
}
|
|
2260
|
-
|
|
2261
|
-
function autoSyncConfigError(error: unknown): BackupAutoUpdateResult {
|
|
2262
|
-
return {
|
|
2263
|
-
ok: false,
|
|
2264
|
-
enabled: true,
|
|
2265
|
-
skipped: false,
|
|
2266
|
-
error: error instanceof Error ? error.message : String(error),
|
|
2267
|
-
};
|
|
2268
|
-
}
|
|
2269
|
-
|
|
2270
|
-
function runMaybeAutoUpdateBackupEffect(
|
|
2271
|
-
db?: Database,
|
|
2272
|
-
): Effect.Effect<BackupAutoUpdateResult, never> {
|
|
2273
|
-
return Effect.gen(function* () {
|
|
2274
|
-
if (process.env.BIRDCLAW_BACKUP_AUTO_SYNC === "0") {
|
|
2275
|
-
return {
|
|
2276
|
-
ok: true,
|
|
2277
|
-
enabled: false,
|
|
2278
|
-
skipped: true,
|
|
2279
|
-
reason: "disabled by BIRDCLAW_BACKUP_AUTO_SYNC=0",
|
|
2280
|
-
};
|
|
2281
|
-
}
|
|
2282
|
-
const configResult = yield* trySync(() => resolveAutoSyncConfig()).pipe(
|
|
2283
|
-
Effect.map((config) => ({ ok: true as const, config })),
|
|
2284
|
-
Effect.catchAll((error) => Effect.succeed({ ok: false as const, error })),
|
|
2285
|
-
);
|
|
2286
|
-
if (!configResult.ok) return autoSyncConfigError(configResult.error);
|
|
2287
|
-
const { config } = configResult;
|
|
2288
|
-
if (!config) {
|
|
2289
|
-
return {
|
|
2290
|
-
ok: true,
|
|
2291
|
-
enabled: false,
|
|
2292
|
-
skipped: true,
|
|
2293
|
-
reason: "backup auto-sync is not configured",
|
|
2294
|
-
};
|
|
2295
|
-
}
|
|
2296
|
-
|
|
2297
|
-
const database = yield* trySync(
|
|
2298
|
-
() => db ?? getNativeDb({ seedDemoData: false }),
|
|
2299
|
-
).pipe(Effect.orDie);
|
|
2300
|
-
const state = yield* trySync(() => readAutoSyncState(database)).pipe(
|
|
2301
|
-
Effect.catchAll(() => Effect.succeed(null)),
|
|
2302
|
-
);
|
|
2303
|
-
const checkedAt = state?.checkedAt
|
|
2304
|
-
? new Date(state.checkedAt).getTime()
|
|
2305
|
-
: 0;
|
|
2306
|
-
const ageMs = Date.now() - checkedAt;
|
|
2307
|
-
if (ageMs >= 0 && ageMs < config.staleAfterSeconds * 1000) {
|
|
2308
|
-
return {
|
|
2309
|
-
ok: true,
|
|
2310
|
-
enabled: true,
|
|
2311
|
-
skipped: true,
|
|
2312
|
-
reason: "backup auto-sync is fresh",
|
|
2313
|
-
repoPath: config.repoPath,
|
|
2314
|
-
...(config.remote ? { remote: redactSecretUrl(config.remote) } : {}),
|
|
2315
|
-
};
|
|
2316
|
-
}
|
|
2317
|
-
|
|
2318
|
-
const now = new Date().toISOString();
|
|
2319
|
-
const result = yield* updateBackupFromGitEffect({
|
|
2320
|
-
repoPath: config.repoPath,
|
|
2321
|
-
remote: config.remote,
|
|
2322
|
-
db: database,
|
|
2323
|
-
}).pipe(
|
|
2324
|
-
Effect.map((value) => ({ ok: true as const, value })),
|
|
2325
|
-
Effect.catchAll((error) => Effect.succeed({ ok: false as const, error })),
|
|
2326
|
-
);
|
|
2327
|
-
|
|
2328
|
-
if (result.ok) {
|
|
2329
|
-
yield* trySync(() =>
|
|
2330
|
-
writeAutoSyncState(database, { checkedAt: now, ok: true }),
|
|
2331
|
-
).pipe(Effect.orDie);
|
|
2332
|
-
return {
|
|
2333
|
-
ok: true,
|
|
2334
|
-
enabled: true,
|
|
2335
|
-
skipped: false,
|
|
2336
|
-
repoPath: result.value.repoPath,
|
|
2337
|
-
...(result.value.remote
|
|
2338
|
-
? { remote: redactSecretUrl(result.value.remote) }
|
|
2339
|
-
: {}),
|
|
2340
|
-
pulled: result.value.pulled,
|
|
2341
|
-
imported: result.value.imported,
|
|
2342
|
-
};
|
|
2343
|
-
}
|
|
2344
|
-
|
|
2345
|
-
const message =
|
|
2346
|
-
result.error instanceof Error
|
|
2347
|
-
? result.error.message
|
|
2348
|
-
: String(result.error);
|
|
2349
|
-
yield* trySync(() =>
|
|
2350
|
-
writeAutoSyncState(database, {
|
|
2351
|
-
checkedAt: now,
|
|
2352
|
-
ok: false,
|
|
2353
|
-
error: message,
|
|
2354
|
-
}),
|
|
2355
|
-
).pipe(Effect.orDie);
|
|
2356
|
-
return {
|
|
2357
|
-
ok: false,
|
|
2358
|
-
enabled: true,
|
|
2359
|
-
skipped: false,
|
|
2360
|
-
repoPath: config.repoPath,
|
|
2361
|
-
...(config.remote ? { remote: redactSecretUrl(config.remote) } : {}),
|
|
2362
|
-
error: redactSecretUrl(message),
|
|
2363
|
-
};
|
|
2364
|
-
});
|
|
2365
|
-
}
|
|
2366
|
-
|
|
2367
|
-
export function maybeAutoUpdateBackupEffect(
|
|
2368
|
-
db?: Database,
|
|
2369
|
-
): Effect.Effect<BackupAutoUpdateResult, never> {
|
|
2370
|
-
if (autoUpdateInFlight) {
|
|
2371
|
-
return Effect.promise(() => autoUpdateInFlight!);
|
|
2372
|
-
}
|
|
2373
|
-
|
|
2374
|
-
return Effect.promise(() => {
|
|
2375
|
-
const promise = runEffectPromise(
|
|
2376
|
-
runMaybeAutoUpdateBackupEffect(db),
|
|
2377
|
-
).finally(() => {
|
|
2378
|
-
if (autoUpdateInFlight === promise) {
|
|
2379
|
-
autoUpdateInFlight = null;
|
|
2380
|
-
}
|
|
2381
|
-
});
|
|
2382
|
-
autoUpdateInFlight = promise;
|
|
2383
|
-
return promise;
|
|
2384
|
-
});
|
|
2385
|
-
}
|
|
2386
|
-
|
|
2387
|
-
export function maybeAutoUpdateBackup(
|
|
2388
|
-
db?: Database,
|
|
2389
|
-
): Promise<BackupAutoUpdateResult> {
|
|
2390
|
-
return runEffectPromise(maybeAutoUpdateBackupEffect(db));
|
|
2391
|
-
}
|
|
2392
|
-
|
|
2393
|
-
export function maybeAutoSyncBackupEffect(
|
|
2394
|
-
db?: Database,
|
|
2395
|
-
): Effect.Effect<BackupAutoUpdateResult, never> {
|
|
2396
|
-
return Effect.gen(function* () {
|
|
2397
|
-
if (process.env.BIRDCLAW_BACKUP_AUTO_SYNC === "0") {
|
|
2398
|
-
return {
|
|
2399
|
-
ok: true,
|
|
2400
|
-
enabled: false,
|
|
2401
|
-
skipped: true,
|
|
2402
|
-
reason: "disabled by BIRDCLAW_BACKUP_AUTO_SYNC=0",
|
|
2403
|
-
};
|
|
2404
|
-
}
|
|
2405
|
-
const configResult = yield* trySync(() => resolveAutoSyncConfig()).pipe(
|
|
2406
|
-
Effect.map((config) => ({ ok: true as const, config })),
|
|
2407
|
-
Effect.catchAll((error) => Effect.succeed({ ok: false as const, error })),
|
|
2408
|
-
);
|
|
2409
|
-
if (!configResult.ok) return autoSyncConfigError(configResult.error);
|
|
2410
|
-
const { config } = configResult;
|
|
2411
|
-
if (!config) {
|
|
2412
|
-
return {
|
|
2413
|
-
ok: true,
|
|
2414
|
-
enabled: false,
|
|
2415
|
-
skipped: true,
|
|
2416
|
-
reason: "backup auto-sync is not configured",
|
|
2417
|
-
};
|
|
2418
|
-
}
|
|
2419
|
-
const database = yield* trySync(
|
|
2420
|
-
() => db ?? getNativeDb({ seedDemoData: false }),
|
|
2421
|
-
).pipe(Effect.orDie);
|
|
2422
|
-
const now = new Date().toISOString();
|
|
2423
|
-
const result = yield* syncBackupEffect({
|
|
2424
|
-
repoPath: config.repoPath,
|
|
2425
|
-
remote: config.remote,
|
|
2426
|
-
db: database,
|
|
2427
|
-
}).pipe(
|
|
2428
|
-
Effect.map((value) => ({ ok: true as const, value })),
|
|
2429
|
-
Effect.catchAll((error) => Effect.succeed({ ok: false as const, error })),
|
|
2430
|
-
);
|
|
2431
|
-
|
|
2432
|
-
if (result.ok) {
|
|
2433
|
-
yield* trySync(() =>
|
|
2434
|
-
writeAutoSyncState(database, { checkedAt: now, ok: true }),
|
|
2435
|
-
).pipe(Effect.orDie);
|
|
2436
|
-
return {
|
|
2437
|
-
ok: true,
|
|
2438
|
-
enabled: true,
|
|
2439
|
-
skipped: false,
|
|
2440
|
-
repoPath: result.value.repoPath,
|
|
2441
|
-
...(result.value.remote ? { remote: result.value.remote } : {}),
|
|
2442
|
-
pulled: result.value.pulled,
|
|
2443
|
-
imported: result.value.imported,
|
|
2444
|
-
};
|
|
2445
|
-
}
|
|
2446
|
-
|
|
2447
|
-
const message =
|
|
2448
|
-
result.error instanceof Error
|
|
2449
|
-
? result.error.message
|
|
2450
|
-
: String(result.error);
|
|
2451
|
-
yield* trySync(() =>
|
|
2452
|
-
writeAutoSyncState(database, {
|
|
2453
|
-
checkedAt: now,
|
|
2454
|
-
ok: false,
|
|
2455
|
-
error: message,
|
|
2456
|
-
}),
|
|
2457
|
-
).pipe(Effect.orDie);
|
|
2458
|
-
return {
|
|
2459
|
-
ok: false,
|
|
2460
|
-
enabled: true,
|
|
2461
|
-
skipped: false,
|
|
2462
|
-
repoPath: config.repoPath,
|
|
2463
|
-
...(config.remote ? { remote: config.remote } : {}),
|
|
2464
|
-
error: message,
|
|
2465
|
-
};
|
|
2466
|
-
});
|
|
2467
|
-
}
|
|
2468
|
-
|
|
2469
|
-
export function maybeAutoSyncBackup(
|
|
2470
|
-
db?: Database,
|
|
2471
|
-
): Promise<BackupAutoUpdateResult> {
|
|
2472
|
-
return runEffectPromise(maybeAutoSyncBackupEffect(db));
|
|
2473
|
-
}
|
|
2474
|
-
|
|
2475
|
-
export function validateBackupEffect(
|
|
2476
|
-
repoPath: string,
|
|
2477
|
-
): Effect.Effect<BackupValidationResult, unknown> {
|
|
2478
|
-
return Effect.gen(function* () {
|
|
2479
|
-
const resolvedRepoPath = yield* trySync(() => path.resolve(repoPath));
|
|
2480
|
-
const errors: string[] = [];
|
|
2481
|
-
const manifestResult = yield* readManifestEffect(resolvedRepoPath).pipe(
|
|
2482
|
-
Effect.match({
|
|
2483
|
-
onFailure: (error) => ({ ok: false as const, error }),
|
|
2484
|
-
onSuccess: (manifest) => ({ ok: true as const, manifest }),
|
|
2485
|
-
}),
|
|
2486
|
-
);
|
|
2487
|
-
if (!manifestResult.ok) {
|
|
2488
|
-
return {
|
|
2489
|
-
ok: false,
|
|
2490
|
-
repoPath: resolvedRepoPath,
|
|
2491
|
-
files: [],
|
|
2492
|
-
counts: {},
|
|
2493
|
-
backupHash: "",
|
|
2494
|
-
errors: [
|
|
2495
|
-
manifestResult.error instanceof Error
|
|
2496
|
-
? manifestResult.error.message
|
|
2497
|
-
: String(manifestResult.error),
|
|
2498
|
-
],
|
|
2499
|
-
};
|
|
2500
|
-
}
|
|
2501
|
-
const { manifest } = manifestResult;
|
|
2502
|
-
|
|
2503
|
-
const results = yield* Effect.forEach(
|
|
2504
|
-
manifest.files,
|
|
2505
|
-
(expected) =>
|
|
2506
|
-
Effect.gen(function* () {
|
|
2507
|
-
const fileErrors: string[] = [];
|
|
2508
|
-
let file: BackupFileManifest | undefined;
|
|
2509
|
-
const filePath = yield* trySync(() =>
|
|
2510
|
-
resolveBackupFilePath(resolvedRepoPath, expected.path),
|
|
2511
|
-
).pipe(
|
|
2512
|
-
Effect.match({
|
|
2513
|
-
onFailure: (error) => {
|
|
2514
|
-
fileErrors.push(
|
|
2515
|
-
`${expected.path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
2516
|
-
);
|
|
2517
|
-
return undefined;
|
|
2518
|
-
},
|
|
2519
|
-
onSuccess: (value) => value,
|
|
2520
|
-
}),
|
|
2521
|
-
);
|
|
2522
|
-
if (!filePath) {
|
|
2523
|
-
return { file, errors: fileErrors };
|
|
2524
|
-
}
|
|
2525
|
-
const stat = yield* assertReadableBackupFileEffect(
|
|
2526
|
-
resolvedRepoPath,
|
|
2527
|
-
filePath,
|
|
2528
|
-
expected.path,
|
|
2529
|
-
).pipe(
|
|
2530
|
-
Effect.match({
|
|
2531
|
-
onFailure: (error) => {
|
|
2532
|
-
fileErrors.push(
|
|
2533
|
-
`${expected.path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
2534
|
-
);
|
|
2535
|
-
return undefined;
|
|
2536
|
-
},
|
|
2537
|
-
onSuccess: (value) => value,
|
|
2538
|
-
}),
|
|
2539
|
-
);
|
|
2540
|
-
if (!stat) {
|
|
2541
|
-
return { file, errors: fileErrors };
|
|
2542
|
-
}
|
|
2543
|
-
const content = yield* tryPromise(() => fs.readFile(filePath)).pipe(
|
|
2544
|
-
Effect.match({
|
|
2545
|
-
onFailure: (error) => {
|
|
2546
|
-
fileErrors.push(
|
|
2547
|
-
`${expected.path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
2548
|
-
);
|
|
2549
|
-
return undefined;
|
|
2550
|
-
},
|
|
2551
|
-
onSuccess: (value) => value,
|
|
2552
|
-
}),
|
|
2553
|
-
);
|
|
2554
|
-
if (content) {
|
|
2555
|
-
const text = content.toString("utf8");
|
|
2556
|
-
const rows = text.split("\n").filter((line) => line.length > 0);
|
|
2557
|
-
for (const [index, line] of rows.entries()) {
|
|
2558
|
-
const parseError = yield* trySync(() => JSON.parse(line)).pipe(
|
|
2559
|
-
Effect.match({
|
|
2560
|
-
onFailure: (error) => error,
|
|
2561
|
-
onSuccess: () => undefined,
|
|
2562
|
-
}),
|
|
2563
|
-
);
|
|
2564
|
-
if (parseError) {
|
|
2565
|
-
fileErrors.push(
|
|
2566
|
-
`${expected.path}:${index + 1}: ${
|
|
2567
|
-
parseError instanceof Error
|
|
2568
|
-
? parseError.message
|
|
2569
|
-
: String(parseError)
|
|
2570
|
-
}`,
|
|
2571
|
-
);
|
|
2572
|
-
}
|
|
2573
|
-
}
|
|
2574
|
-
file = {
|
|
2575
|
-
path: expected.path,
|
|
2576
|
-
rows: rows.length,
|
|
2577
|
-
sha256: sha256(content),
|
|
2578
|
-
bytes: content.byteLength,
|
|
2579
|
-
};
|
|
2580
|
-
}
|
|
2581
|
-
return { file, errors: fileErrors };
|
|
2582
|
-
}),
|
|
2583
|
-
{ concurrency: "unbounded" },
|
|
2584
|
-
);
|
|
2585
|
-
|
|
2586
|
-
const files: BackupFileManifest[] = [];
|
|
2587
|
-
for (const result of results) {
|
|
2588
|
-
errors.push(...result.errors);
|
|
2589
|
-
if (result.file) {
|
|
2590
|
-
files.push(result.file);
|
|
2591
|
-
}
|
|
2592
|
-
}
|
|
2593
|
-
|
|
2594
|
-
for (const expected of manifest.files) {
|
|
2595
|
-
const file = files.find((entry) => entry.path === expected.path);
|
|
2596
|
-
if (!file) {
|
|
2597
|
-
continue;
|
|
2598
|
-
}
|
|
2599
|
-
if (file.rows !== expected.rows) {
|
|
2600
|
-
errors.push(`${file.path}: row count ${file.rows} != ${expected.rows}`);
|
|
2601
|
-
}
|
|
2602
|
-
if (file.sha256 !== expected.sha256) {
|
|
2603
|
-
errors.push(
|
|
2604
|
-
`${file.path}: sha256 ${file.sha256} != ${expected.sha256}`,
|
|
2605
|
-
);
|
|
2606
|
-
}
|
|
2607
|
-
if (file.bytes !== expected.bytes) {
|
|
2608
|
-
errors.push(`${file.path}: bytes ${file.bytes} != ${expected.bytes}`);
|
|
2609
|
-
}
|
|
2610
|
-
}
|
|
2611
|
-
|
|
2612
|
-
const counts = computeCounts(files);
|
|
2613
|
-
const backupHash = computeBackupHash(files);
|
|
2614
|
-
if (backupHash !== manifest.backupHash) {
|
|
2615
|
-
errors.push(`backup hash ${backupHash} != ${manifest.backupHash}`);
|
|
2616
|
-
}
|
|
2617
|
-
if (canonicalStringify(counts) !== canonicalStringify(manifest.counts)) {
|
|
2618
|
-
errors.push("manifest counts do not match backup files");
|
|
2619
|
-
}
|
|
2620
|
-
|
|
2621
|
-
return {
|
|
2622
|
-
ok: errors.length === 0,
|
|
2623
|
-
repoPath: resolvedRepoPath,
|
|
2624
|
-
files,
|
|
2625
|
-
counts,
|
|
2626
|
-
backupHash,
|
|
2627
|
-
errors,
|
|
2628
|
-
};
|
|
2629
|
-
});
|
|
2630
|
-
}
|
|
2631
|
-
|
|
2632
|
-
export function validateBackup(
|
|
2633
|
-
repoPath: string,
|
|
2634
|
-
): Promise<BackupValidationResult> {
|
|
2635
|
-
return runEffectPromise(validateBackupEffect(repoPath));
|
|
2636
|
-
}
|
|
2637
|
-
|
|
2638
|
-
export function getBackupDatabaseFingerprint(
|
|
2639
|
-
db = getNativeDb({ seedDemoData: false }),
|
|
2640
|
-
): BackupDatabaseFingerprint {
|
|
2641
|
-
const counts: Record<string, number> = {};
|
|
2642
|
-
const hash = createHash("sha256");
|
|
2643
|
-
for (const rowSet of getExportRowSets(db)) {
|
|
2644
|
-
counts[rowSet.logicalName] = rowSet.rows.length;
|
|
2645
|
-
hash.update(`${rowSet.logicalName}\n`);
|
|
2646
|
-
for (const row of rowSet.rows) {
|
|
2647
|
-
hash.update(canonicalStringify(row));
|
|
2648
|
-
hash.update("\n");
|
|
2649
|
-
}
|
|
2650
|
-
}
|
|
2651
|
-
return { counts, hash: hash.digest("hex") };
|
|
2652
|
-
}
|