@dbx-tools/falkor-db 0.0.0-stage → 0.9.43

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.
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Restore and change-aware durable backup lifecycle for embedded FalkorDB.
3
+ *
4
+ * Redis owns dirty detection and RDB creation. This manager only observes
5
+ * completed saves, stages immutable copies, and advances durable storage after
6
+ * a successful upload. Reuse it instead of adding application-local backup
7
+ * timers, manifest ordering, retry, or retention logic.
8
+ *
9
+ * @module
10
+ */
11
+
12
+ import { existsSync } from "node:fs";
13
+ import { mkdir, rename, rm } from "node:fs/promises";
14
+ import { join } from "node:path";
15
+ import { asyncUtils, log } from "@dbx-tools/shared-core";
16
+ import {
17
+ enforceRetention,
18
+ LATEST_MANIFEST_PATH,
19
+ nextSnapshotSequence,
20
+ parseSnapshotManifest,
21
+ snapshotPath,
22
+ type SnapshotManifest,
23
+ } from "./manifest.ts";
24
+ import {
25
+ getPersistenceInfo,
26
+ waitForBackgroundSave,
27
+ type RedisPersistenceClient,
28
+ type RedisPersistenceInfo,
29
+ } from "./redis-info.ts";
30
+ import { stageSnapshot, verifySnapshot } from "./snapshot.ts";
31
+ import type { VolumeStorage } from "./volume.ts";
32
+
33
+ const logger = log.logger("falkor-db:persistence");
34
+ const DEFAULT_RETRY_DELAYS_MS = [5_000, 15_000, 30_000, 60_000, 300_000] as const;
35
+
36
+ /** Configuration for {@link FalkorPersistenceManager}. */
37
+ export interface FalkorPersistenceOptions {
38
+ dataDir: string;
39
+ storage?: VolumeStorage;
40
+ pollIntervalMs?: number;
41
+ retention?: number;
42
+ retryDelaysMs?: readonly number[];
43
+ forceBackupOnShutdown?: boolean;
44
+ shutdownTimeoutMs?: number;
45
+ staleBackupWarningMs?: number;
46
+ }
47
+
48
+ /** Observable state for health reporting and metrics adapters. */
49
+ export interface FalkorPersistenceStatus {
50
+ rdbLastSaveTime?: number;
51
+ rdbChangesSinceLastSave?: number;
52
+ backupLastSuccessTime?: string;
53
+ backupLastSequence?: number;
54
+ backupSizeBytes?: number;
55
+ backupDurationMs?: number;
56
+ backupFailures: number;
57
+ restoreDurationMs?: number;
58
+ restoreSnapshotSequence?: number;
59
+ }
60
+
61
+ /** Coordinates restore, RDB observation, durable upload, retry, and retention. */
62
+ export class FalkorPersistenceManager {
63
+ private readonly dumpPath: string;
64
+ private readonly stagingDirectory: string;
65
+ private readonly statusValue: FalkorPersistenceStatus = { backupFailures: 0 };
66
+ private manifest: SnapshotManifest | undefined;
67
+ private redis: RedisPersistenceClient | undefined;
68
+ private timer: ReturnType<typeof setInterval> | undefined;
69
+ private backupPromise: Promise<void> | undefined;
70
+ private checkAgain = false;
71
+ private lastUploadedSaveTime = 0;
72
+ private retryAttempt = 0;
73
+ private retryAfter = 0;
74
+ private lastLoggedLocalSaveTime = 0;
75
+
76
+ constructor(private readonly options: FalkorPersistenceOptions) {
77
+ this.dumpPath = join(options.dataDir, "dump.rdb");
78
+ this.stagingDirectory = join(options.dataDir, ".dbx-tools-backups");
79
+ }
80
+
81
+ get status(): Readonly<FalkorPersistenceStatus> {
82
+ return { ...this.statusValue };
83
+ }
84
+
85
+ /** Restore `latest.json` before FalkorDB starts, or keep the local database. */
86
+ async restore(): Promise<SnapshotManifest | undefined> {
87
+ await mkdir(this.options.dataDir, { recursive: true });
88
+ if (!this.options.storage) {
89
+ logger.info("durable storage is not configured; using local RDB persistence", {
90
+ dataDir: this.options.dataDir,
91
+ });
92
+ return undefined;
93
+ }
94
+
95
+ const raw = await this.options.storage.readJson<unknown>(LATEST_MANIFEST_PATH);
96
+ if (raw === null) {
97
+ logger.info("no durable FalkorDB snapshot found; using local database", {
98
+ dataDir: this.options.dataDir,
99
+ });
100
+ return undefined;
101
+ }
102
+
103
+ const started = Date.now();
104
+ const manifest = parseSnapshotManifest(raw);
105
+ const temporary = `${this.dumpPath}.restore`;
106
+ await rm(temporary, { force: true });
107
+ try {
108
+ await this.options.storage.download(manifest.snapshot, temporary);
109
+ await verifySnapshot(temporary, manifest.sha256);
110
+ await rename(temporary, this.dumpPath);
111
+ } catch (error) {
112
+ await rm(temporary, { force: true });
113
+ throw error;
114
+ }
115
+ this.manifest = manifest;
116
+ this.lastUploadedSaveTime = manifest.redisSaveTime ?? 0;
117
+ this.statusValue.restoreDurationMs = Date.now() - started;
118
+ this.statusValue.restoreSnapshotSequence = manifest.sequence;
119
+ logger.info("restored FalkorDB snapshot", {
120
+ sequence: manifest.sequence,
121
+ size: manifest.size,
122
+ durationMs: this.statusValue.restoreDurationMs,
123
+ });
124
+ return manifest;
125
+ }
126
+
127
+ /** Begin polling Redis persistence metadata for completed new snapshots. */
128
+ async start(redis: RedisPersistenceClient): Promise<void> {
129
+ if (this.timer) return;
130
+ this.redis = redis;
131
+ const initial = await this.observe(redis);
132
+ if (this.manifest) {
133
+ this.lastUploadedSaveTime = initial.rdbLastSaveTime;
134
+ }
135
+ await this.checkNow();
136
+ this.timer = setInterval(() => {
137
+ void this.checkNow().catch((error) => {
138
+ logger.error("FalkorDB persistence check failed; retrying later", { error });
139
+ });
140
+ }, this.options.pollIntervalMs ?? 10_000);
141
+ this.timer.unref?.();
142
+ }
143
+
144
+ /** Stop background polling without forcing a save or upload. */
145
+ stop(): void {
146
+ if (this.timer) clearInterval(this.timer);
147
+ this.timer = undefined;
148
+ }
149
+
150
+ /** Inspect persistence now, coalescing concurrent checks into the newest RDB. */
151
+ async checkNow(): Promise<void> {
152
+ if (!this.redis) return;
153
+ if (this.backupPromise) {
154
+ this.checkAgain = true;
155
+ return this.backupPromise;
156
+ }
157
+ this.backupPromise = this.runChecks();
158
+ try {
159
+ await this.backupPromise;
160
+ } finally {
161
+ this.backupPromise = undefined;
162
+ }
163
+ }
164
+
165
+ /** Optionally force one dirty save/upload, bounded so shutdown cannot hang. */
166
+ async prepareShutdown(): Promise<void> {
167
+ this.stop();
168
+ if (!this.options.forceBackupOnShutdown || !this.redis) return;
169
+ const timeoutMs = this.options.shutdownTimeoutMs ?? 30_000;
170
+ const work = this.finishShutdownBackup(this.redis);
171
+ const timeout = new AbortController();
172
+ try {
173
+ await Promise.race([
174
+ work,
175
+ asyncUtils.sleep(timeoutMs, timeout.signal).then(() => {
176
+ throw new Error(`FalkorDB shutdown backup exceeded ${timeoutMs}ms`);
177
+ }),
178
+ ]);
179
+ } catch (error) {
180
+ logger.error("shutdown backup failed; continuing shutdown", { error });
181
+ } finally {
182
+ timeout.abort();
183
+ }
184
+ }
185
+
186
+ private async finishShutdownBackup(redis: RedisPersistenceClient): Promise<void> {
187
+ if (this.backupPromise) {
188
+ try {
189
+ await this.backupPromise;
190
+ } catch (error) {
191
+ logger.warn("in-flight FalkorDB backup failed; retrying during shutdown", { error });
192
+ }
193
+ }
194
+ await this.forceDirtySnapshot(redis);
195
+ }
196
+
197
+ private async runChecks(): Promise<void> {
198
+ do {
199
+ this.checkAgain = false;
200
+ await this.inspectAndBackup();
201
+ } while (this.checkAgain);
202
+ }
203
+
204
+ private async inspectAndBackup(): Promise<void> {
205
+ const redis = this.redis;
206
+ if (!redis) return;
207
+ const info = await this.observe(redis);
208
+ if (info.rdbBgSaveInProgress) return;
209
+ if (info.rdbLastBgSaveStatus !== "ok") {
210
+ logger.error("Redis reported an unsuccessful background save", {
211
+ status: info.rdbLastBgSaveStatus,
212
+ });
213
+ return;
214
+ }
215
+ this.warnIfStale(info);
216
+ if (!this.options.storage) {
217
+ if (info.rdbLastSaveTime > this.lastLoggedLocalSaveTime) {
218
+ this.lastLoggedLocalSaveTime = info.rdbLastSaveTime;
219
+ logger.info("local FalkorDB RDB snapshot completed", {
220
+ lastSaveTime: info.rdbLastSaveTime,
221
+ changesSinceSave: info.rdbChangesSinceLastSave,
222
+ path: this.dumpPath,
223
+ });
224
+ }
225
+ return;
226
+ }
227
+ if (info.rdbLastSaveTime <= this.lastUploadedSaveTime || !existsSync(this.dumpPath)) return;
228
+ if (Date.now() < this.retryAfter) return;
229
+
230
+ try {
231
+ await this.upload(info);
232
+ this.retryAttempt = 0;
233
+ this.retryAfter = 0;
234
+ const newest = await this.observe(redis);
235
+ if (newest.rdbLastSaveTime > this.lastUploadedSaveTime) this.checkAgain = true;
236
+ } catch (error) {
237
+ this.statusValue.backupFailures += 1;
238
+ const delays = this.options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;
239
+ const delay = asyncUtils.boundedRetryDelay(this.retryAttempt++, delays);
240
+ this.retryAfter = Date.now() + delay;
241
+ logger.error("durable FalkorDB backup failed; local database remains available", {
242
+ error,
243
+ retryInMs: delay,
244
+ });
245
+ }
246
+ }
247
+
248
+ private async upload(info: RedisPersistenceInfo): Promise<void> {
249
+ const storage = this.options.storage;
250
+ if (!storage) return;
251
+ const started = Date.now();
252
+ const sequence = await nextSnapshotSequence(storage, this.manifest);
253
+ const remote = snapshotPath(sequence);
254
+ const stagedPath = join(this.stagingDirectory, `snapshot-${sequence}.rdb`);
255
+ const staged = await stageSnapshot(this.dumpPath, stagedPath);
256
+ try {
257
+ await storage.upload(staged.path, remote, { overwrite: false });
258
+ const manifest: SnapshotManifest = {
259
+ snapshot: remote,
260
+ createdAt: new Date().toISOString(),
261
+ size: staged.size,
262
+ sha256: staged.sha256,
263
+ sequence,
264
+ redisSaveTime: info.rdbLastSaveTime,
265
+ };
266
+ await storage.writeJson(LATEST_MANIFEST_PATH, manifest);
267
+ this.manifest = manifest;
268
+ this.lastUploadedSaveTime = info.rdbLastSaveTime;
269
+ this.statusValue.backupLastSuccessTime = manifest.createdAt;
270
+ this.statusValue.backupLastSequence = sequence;
271
+ this.statusValue.backupSizeBytes = staged.size;
272
+ this.statusValue.backupDurationMs = Date.now() - started;
273
+ logger.info("uploaded durable FalkorDB snapshot", {
274
+ sequence,
275
+ size: staged.size,
276
+ durationMs: this.statusValue.backupDurationMs,
277
+ });
278
+ await enforceRetention(storage, manifest, this.options.retention ?? 5).catch((error) => {
279
+ logger.warn("FalkorDB snapshot retention cleanup failed", { error });
280
+ });
281
+ } finally {
282
+ await rm(stagedPath, { force: true });
283
+ }
284
+ }
285
+
286
+ private async forceDirtySnapshot(redis: RedisPersistenceClient): Promise<void> {
287
+ let info = await this.observe(redis);
288
+ if (info.rdbChangesSinceLastSave > 0) {
289
+ const previous = info.rdbLastSaveTime;
290
+ await redis.bgSave();
291
+ info = await waitForBackgroundSave(redis, previous, {
292
+ timeoutMs: this.options.shutdownTimeoutMs ?? 30_000,
293
+ });
294
+ }
295
+ if (this.options.storage && info.rdbLastSaveTime > this.lastUploadedSaveTime) {
296
+ this.retryAfter = 0;
297
+ await this.upload(info);
298
+ }
299
+ }
300
+
301
+ private async observe(redis: RedisPersistenceClient): Promise<RedisPersistenceInfo> {
302
+ const info = await getPersistenceInfo(redis);
303
+ this.statusValue.rdbLastSaveTime = info.rdbLastSaveTime;
304
+ this.statusValue.rdbChangesSinceLastSave = info.rdbChangesSinceLastSave;
305
+ return info;
306
+ }
307
+
308
+ private warnIfStale(info: RedisPersistenceInfo): void {
309
+ if (!this.options.storage) return;
310
+ const threshold = this.options.staleBackupWarningMs;
311
+ if (!threshold || info.rdbChangesSinceLastSave === 0) return;
312
+ const last = this.statusValue.backupLastSuccessTime
313
+ ? Date.parse(this.statusValue.backupLastSuccessTime)
314
+ : 0;
315
+ if (Date.now() - last > threshold) {
316
+ logger.warn("FalkorDB has changes without a recent durable backup", {
317
+ changesSinceSave: info.rdbChangesSinceLastSave,
318
+ lastSuccessTime: this.statusValue.backupLastSuccessTime,
319
+ });
320
+ }
321
+ }
322
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Durable snapshot manifest naming, validation, sequencing, and retention.
3
+ *
4
+ * `latest.json` is updated only after its immutable snapshot exists. Reuse this
5
+ * module for manifest policy so restore and cleanup cannot disagree about the
6
+ * current good snapshot.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import { posix } from "node:path";
12
+ import type { VolumeStorage } from "./volume.ts";
13
+
14
+ /** Durable pointer updated only after a complete immutable snapshot upload. */
15
+ export const LATEST_MANIFEST_PATH = "latest.json";
16
+
17
+ /** Directory containing immutable, monotonically sequenced RDB files. */
18
+ export const SNAPSHOT_DIRECTORY = "snapshots";
19
+
20
+ /** Durable pointer to one verified immutable RDB snapshot. */
21
+ export interface SnapshotManifest {
22
+ snapshot: string;
23
+ createdAt: string;
24
+ size: number;
25
+ sha256: string;
26
+ sequence: number;
27
+ /** Redis `rdb_last_save_time` represented by this snapshot. */
28
+ redisSaveTime?: number;
29
+ }
30
+
31
+ /** Parse an untrusted manifest read from durable storage. */
32
+ export function parseSnapshotManifest(value: unknown): SnapshotManifest {
33
+ if (!isRecord(value)) throw new TypeError("FalkorDB latest.json must be an object");
34
+ const manifest: SnapshotManifest = {
35
+ snapshot: requiredString(value.snapshot, "snapshot"),
36
+ createdAt: requiredString(value.createdAt, "createdAt"),
37
+ size: nonNegativeInteger(value.size, "size"),
38
+ sha256: requiredString(value.sha256, "sha256").toLowerCase(),
39
+ sequence: positiveInteger(value.sequence, "sequence"),
40
+ ...(value.redisSaveTime === undefined
41
+ ? {}
42
+ : { redisSaveTime: nonNegativeInteger(value.redisSaveTime, "redisSaveTime") }),
43
+ };
44
+ if (!manifest.snapshot.startsWith(`${SNAPSHOT_DIRECTORY}/`)) {
45
+ throw new TypeError("FalkorDB manifest snapshot must be under snapshots/");
46
+ }
47
+ if (!/^[a-f0-9]{64}$/.test(manifest.sha256)) {
48
+ throw new TypeError("FalkorDB manifest sha256 must be a 64-character hex digest");
49
+ }
50
+ if (!Number.isFinite(Date.parse(manifest.createdAt))) {
51
+ throw new TypeError("FalkorDB manifest createdAt must be an ISO date");
52
+ }
53
+ return manifest;
54
+ }
55
+
56
+ /** Remote path for an immutable, monotonically sequenced RDB snapshot. */
57
+ export function snapshotPath(sequence: number): string {
58
+ return posix.join(SNAPSHOT_DIRECTORY, `${String(sequence).padStart(8, "0")}.rdb`);
59
+ }
60
+
61
+ /** Choose a sequence above both the manifest and any orphaned uploads. */
62
+ export async function nextSnapshotSequence(
63
+ storage: VolumeStorage,
64
+ manifest?: SnapshotManifest,
65
+ ): Promise<number> {
66
+ const entries = await storage.list(SNAPSHOT_DIRECTORY);
67
+ let maximum = manifest?.sequence ?? 0;
68
+ for (const entry of entries) {
69
+ const match = /^(\d+)\.rdb$/.exec(entry.name);
70
+ if (match) maximum = Math.max(maximum, Number.parseInt(match[1]!, 10));
71
+ }
72
+ return maximum + 1;
73
+ }
74
+
75
+ /** Delete old immutable snapshots while preserving the manifest target. */
76
+ export async function enforceRetention(
77
+ storage: VolumeStorage,
78
+ manifest: SnapshotManifest,
79
+ retain: number,
80
+ ): Promise<void> {
81
+ const snapshots = (await storage.list(SNAPSHOT_DIRECTORY))
82
+ .map((entry) => ({ entry, match: /^(\d+)\.rdb$/.exec(entry.name) }))
83
+ .filter((item): item is typeof item & { match: RegExpExecArray } => item.match !== null)
84
+ .map(({ entry, match }) => ({ entry, sequence: Number.parseInt(match[1]!, 10) }))
85
+ .sort((left, right) => right.sequence - left.sequence);
86
+ const keep = new Set(snapshots.slice(0, Math.max(1, retain)).map(({ entry }) => entry.path));
87
+ keep.add(manifest.snapshot);
88
+ for (const { entry } of snapshots) {
89
+ if (!keep.has(entry.path)) await storage.delete(entry.path);
90
+ }
91
+ }
92
+
93
+ function isRecord(value: unknown): value is Record<string, unknown> {
94
+ return typeof value === "object" && value !== null && !Array.isArray(value);
95
+ }
96
+
97
+ function requiredString(value: unknown, name: string): string {
98
+ if (typeof value !== "string" || !value.trim()) throw new TypeError(`${name} must be a string`);
99
+ return value;
100
+ }
101
+
102
+ function nonNegativeInteger(value: unknown, name: string): number {
103
+ if (!Number.isSafeInteger(value) || Number(value) < 0) {
104
+ throw new TypeError(`${name} must be a non-negative integer`);
105
+ }
106
+ return Number(value);
107
+ }
108
+
109
+ function positiveInteger(value: unknown, name: string): number {
110
+ const parsed = nonNegativeInteger(value, name);
111
+ if (parsed < 1) throw new TypeError(`${name} must be positive`);
112
+ return parsed;
113
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Redis persistence metadata parsing and completed-RDB detection.
3
+ *
4
+ * Reuse this module whenever FalkorDB persistence decisions depend on `INFO
5
+ * persistence`; do not infer snapshot completion from filesystem timestamps
6
+ * alone or trigger `BGSAVE` merely to discover whether the graph changed.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import { asyncUtils } from "@dbx-tools/shared-core";
12
+
13
+ /** Redis persistence fields required by durable snapshot orchestration. */
14
+ export interface RedisPersistenceInfo {
15
+ rdbBgSaveInProgress: boolean;
16
+ rdbLastSaveTime: number;
17
+ rdbLastBgSaveStatus: string;
18
+ rdbChangesSinceLastSave: number;
19
+ }
20
+
21
+ /** Minimal Redis client surface used by the persistence manager. */
22
+ export interface RedisPersistenceClient {
23
+ info(section: "persistence"): Promise<string>;
24
+ bgSave(): Promise<unknown>;
25
+ }
26
+
27
+ /** Parse the fields used by the backup manager from `INFO persistence`. */
28
+ export function parsePersistenceInfo(raw: string): RedisPersistenceInfo {
29
+ const values = new Map<string, string>();
30
+ for (const line of raw.split(/\r?\n/)) {
31
+ if (!line || line.startsWith("#")) continue;
32
+ const separator = line.indexOf(":");
33
+ if (separator < 0) continue;
34
+ values.set(line.slice(0, separator), line.slice(separator + 1));
35
+ }
36
+ return {
37
+ rdbBgSaveInProgress: integer(values, "rdb_bgsave_in_progress") !== 0,
38
+ rdbLastSaveTime: integer(values, "rdb_last_save_time"),
39
+ rdbLastBgSaveStatus: values.get("rdb_last_bgsave_status") ?? "unknown",
40
+ rdbChangesSinceLastSave: integer(values, "rdb_changes_since_last_save"),
41
+ };
42
+ }
43
+
44
+ /** Read and parse the current Redis persistence state. */
45
+ export async function getPersistenceInfo(
46
+ redis: RedisPersistenceClient,
47
+ ): Promise<RedisPersistenceInfo> {
48
+ return parsePersistenceInfo(await redis.info("persistence"));
49
+ }
50
+
51
+ /** Wait until a requested background save finishes successfully. */
52
+ export async function waitForBackgroundSave(
53
+ redis: RedisPersistenceClient,
54
+ previousSaveTime: number,
55
+ options: { intervalMs?: number; timeoutMs?: number } = {},
56
+ ): Promise<RedisPersistenceInfo> {
57
+ let completed: RedisPersistenceInfo | undefined;
58
+ for await (const info of asyncUtils.poll(() => getPersistenceInfo(redis), {
59
+ intervalMs: options.intervalMs ?? 250,
60
+ timeoutMs: options.timeoutMs ?? 30_000,
61
+ predicate: (value) => {
62
+ if (!value.rdbBgSaveInProgress && value.rdbLastBgSaveStatus !== "ok") {
63
+ throw new Error(`Redis background save failed: ${value.rdbLastBgSaveStatus}`);
64
+ }
65
+ completed = value;
66
+ return value.rdbBgSaveInProgress || value.rdbLastSaveTime <= previousSaveTime;
67
+ },
68
+ })) {
69
+ completed = info;
70
+ }
71
+ if (!completed || completed.rdbLastSaveTime <= previousSaveTime) {
72
+ throw new Error("Redis background save completed without a newer RDB timestamp");
73
+ }
74
+ return completed;
75
+ }
76
+
77
+ function integer(values: ReadonlyMap<string, string>, key: string): number {
78
+ const value = Number.parseInt(values.get(key) ?? "0", 10);
79
+ return Number.isFinite(value) ? value : 0;
80
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Immutable local RDB staging, hashing, and restore verification.
3
+ *
4
+ * The live `dump.rdb` is always copied before upload so a later Redis rename
5
+ * cannot change the bytes being transferred. Reuse these helpers instead of
6
+ * hashing or uploading the live database file directly.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import { createHash } from "node:crypto";
12
+ import { createReadStream } from "node:fs";
13
+ import { copyFile, mkdir, stat } from "node:fs/promises";
14
+ import { dirname } from "node:path";
15
+
16
+ /** Local immutable snapshot metadata. */
17
+ export interface LocalSnapshot {
18
+ path: string;
19
+ size: number;
20
+ sha256: string;
21
+ }
22
+
23
+ /** Copy a live RDB into an immutable staging path and hash the copied bytes. */
24
+ export async function stageSnapshot(source: string, destination: string): Promise<LocalSnapshot> {
25
+ await mkdir(dirname(destination), { recursive: true });
26
+ await copyFile(source, destination);
27
+ const [details, sha256] = await Promise.all([stat(destination), sha256File(destination)]);
28
+ return { path: destination, size: details.size, sha256 };
29
+ }
30
+
31
+ /** Compute a file's lowercase SHA-256 digest without buffering it in memory. */
32
+ export async function sha256File(path: string): Promise<string> {
33
+ const hash = createHash("sha256");
34
+ for await (const chunk of createReadStream(path)) hash.update(chunk);
35
+ return hash.digest("hex");
36
+ }
37
+
38
+ /** Fail when a restored snapshot does not match the durable manifest. */
39
+ export async function verifySnapshot(path: string, expectedSha256: string): Promise<void> {
40
+ const actual = await sha256File(path);
41
+ if (actual !== expectedSha256.toLowerCase()) {
42
+ throw new Error(
43
+ `FalkorDB snapshot checksum mismatch: expected ${expectedSha256}, got ${actual}`,
44
+ );
45
+ }
46
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Durable snapshot storage abstraction and Databricks Volume adapter.
3
+ *
4
+ * Backup policy depends only on {@link VolumeStorage}. The Databricks adapter
5
+ * receives an initialized {@link DatabricksFileSystem}, keeping profile and
6
+ * application authentication in `@dbx-tools/databricks` rather than this
7
+ * package. Other durable stores can implement the same narrow contract.
8
+ *
9
+ * @module
10
+ */
11
+
12
+ import { createReadStream, createWriteStream } from "node:fs";
13
+ import { mkdir } from "node:fs/promises";
14
+ import { dirname } from "node:path";
15
+ import { Readable } from "node:stream";
16
+ import { pipeline } from "node:stream/promises";
17
+ import { DatabricksFileSystem } from "@dbx-tools/databricks/databricks-fs";
18
+
19
+ /** One entry in durable snapshot storage. */
20
+ export interface VolumeEntry {
21
+ name: string;
22
+ path: string;
23
+ size?: number;
24
+ }
25
+
26
+ /** Storage contract used by restore, upload, manifest, and retention policy. */
27
+ export interface VolumeStorage {
28
+ exists(path: string): Promise<boolean>;
29
+ download(remote: string, local: string): Promise<void>;
30
+ upload(local: string, remote: string, options?: { overwrite?: boolean }): Promise<void>;
31
+ readJson<T>(path: string): Promise<T | null>;
32
+ writeJson(path: string, value: unknown): Promise<void>;
33
+ list(path: string): Promise<VolumeEntry[]>;
34
+ delete(path: string): Promise<void>;
35
+ }
36
+
37
+ /** Stream local snapshots to and from a rooted Unity Catalog Volume filesystem. */
38
+ export class DatabricksVolumeStorage implements VolumeStorage {
39
+ constructor(private readonly fileSystem: DatabricksFileSystem) {}
40
+
41
+ async exists(path: string): Promise<boolean> {
42
+ return this.fileSystem.exists(path);
43
+ }
44
+
45
+ async download(remote: string, local: string): Promise<void> {
46
+ await mkdir(dirname(local), { recursive: true });
47
+ const source = await this.fileSystem.readStream(remote);
48
+ await pipeline(Readable.fromWeb(source), createWriteStream(local));
49
+ }
50
+
51
+ async upload(
52
+ local: string,
53
+ remote: string,
54
+ options: { overwrite?: boolean } = {},
55
+ ): Promise<void> {
56
+ const source = Readable.toWeb(createReadStream(local)) as globalThis.ReadableStream<Uint8Array>;
57
+ await this.fileSystem.writeStream(remote, source, options);
58
+ }
59
+
60
+ async readJson<T>(path: string): Promise<T | null> {
61
+ if (!(await this.fileSystem.exists(path))) return null;
62
+ const text = await this.fileSystem.readFile(path, { encoding: "utf-8" });
63
+ return JSON.parse(text) as T;
64
+ }
65
+
66
+ async writeJson(path: string, value: unknown): Promise<void> {
67
+ await this.fileSystem.writeFile(path, `${JSON.stringify(value, null, 2)}\n`, {
68
+ overwrite: true,
69
+ });
70
+ }
71
+
72
+ async list(path: string): Promise<VolumeEntry[]> {
73
+ if (!(await this.fileSystem.exists(path))) return [];
74
+ return (await this.fileSystem.readdir(path)).map((entry) => ({
75
+ name: entry.name,
76
+ path: `${path.replace(/\/$/, "")}/${entry.name}`,
77
+ size: entry.size,
78
+ }));
79
+ }
80
+
81
+ async delete(path: string): Promise<void> {
82
+ await this.fileSystem.deleteFile(path, { force: true });
83
+ }
84
+ }