Preserve CLI database continuity after reconstruction

This commit is contained in:
vorotamoroz committed 2026-11-01 05:06:42 +00:00
1 parent 0e458e5e20
commit d50ee9a83e
21 files changed
+1585 -60

No files matched your search

+222
View File
@@ -0,0 +1,222 @@
// This module belongs exclusively to the Node.js CLI host.
import { Sha256 } from "@aws-crypto/sha256-js";
import { fs, path } from "@vrtmrz/livesync-commonlib/node";
import type { CLICommand } from "./commands/types";
function sha256Hex(value: string): string {
const hash = new Sha256();
hash.update(new TextEncoder().encode(value));
return Array.from(hash.digestSync(), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
export function runtimeScopeDirectory(databasePath: string, databaseName: string, vaultPath: string): string {
const scope = sha256Hex(vaultPath);
return path.join(runtimeDatabaseDirectory(databasePath, databaseName), scope);
}
export function runtimeDatabaseDirectory(databasePath: string, databaseName: string): string {
const database = sha256Hex(databaseName);
return path.join(databasePath, ".livesync", "runtime", "scopes", database);
}
export function writeFileAtomically(filePath: string, content: string): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const temporaryPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
try {
const descriptor = fs.openSync(temporaryPath, "wx", 0o600);
try {
fs.writeFileSync(descriptor, content, "utf-8");
fs.fsyncSync(descriptor);
} finally {
fs.closeSync(descriptor);
}
fs.renameSync(temporaryPath, filePath);
syncDirectory(path.dirname(filePath));
} finally {
fs.rmSync(temporaryPath, { force: true });
}
}
function syncDirectory(directory: string): void {
// Windows does not support opening a directory for fsync.
if (process.platform === "win32") return;
const descriptor = fs.openSync(directory, "r");
try {
fs.fsyncSync(descriptor);
} finally {
fs.closeSync(descriptor);
}
}
export function readOptionalFile(filePath: string): string | undefined {
try {
return fs.readFileSync(filePath, "utf-8");
} catch (error) {
if ((error as { code?: string }).code === "ENOENT") return undefined;
throw error;
}
}
export function isReconstructionCommand(command: CLICommand): command is "fetch" | "rebuild" {
return command === "fetch" || command === "rebuild";
}
const READ_COMMANDS = new Set<CLICommand>([
"cat",
"cat-rev",
"ls",
"info",
"remote-ls",
"remote-export",
"remote-status",
"p2p-peers",
]);
export function isMutatingCommand(command: CLICommand): boolean {
return !READ_COMMANDS.has(command);
}
interface ReconstructionMarker {
version: 1;
command: "fetch" | "rebuild";
databasePath: string;
vaultPath: string;
settingsPath: string;
settingsBeforeHash: string;
phase: "running" | "completed";
settingsText?: string;
}
function hashSettings(content: string | undefined): string {
return sha256Hex(JSON.stringify(content ?? null));
}
export class ReconstructionState {
readonly markerPath: string;
private marker: ReconstructionMarker | undefined;
constructor(
private readonly databasePath: string,
private readonly vaultPath: string,
private readonly settingsPath: string
) {
this.markerPath = path.join(databasePath, ".livesync", "reconstruction.json");
}
prepare(command: CLICommand, force: boolean, writeSettings = false): void {
const contents = readOptionalFile(this.markerPath);
if (contents !== undefined) {
const parsed: unknown = JSON.parse(contents);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error(`Invalid reconstruction state: ${this.markerPath}. Preserve it for recovery.`);
}
const marker = parsed as ReconstructionMarker;
if (
marker.version !== 1 ||
!isReconstructionCommand(marker.command) ||
(marker.phase !== "running" && marker.phase !== "completed") ||
typeof marker.settingsPath !== "string" ||
typeof marker.vaultPath !== "string" ||
typeof marker.settingsBeforeHash !== "string" ||
marker.databasePath !== this.databasePath ||
(marker.phase === "completed" && typeof marker.settingsText !== "string")
) {
throw new Error(`Invalid reconstruction state: ${this.markerPath}. Preserve it for recovery.`);
}
this.marker = marker;
if (marker.phase === "completed") {
this.commit();
} else if (
(isMutatingCommand(command) || writeSettings) &&
!(
command === marker.command &&
force &&
marker.vaultPath === this.vaultPath &&
marker.settingsPath === this.settingsPath
)
) {
throw new Error(
`An interrupted ${marker.command} requires recovery. Retry ${marker.command} --force with the same database, Vault, and settings paths. State: ${this.markerPath}`
);
}
}
if (isReconstructionCommand(command)) {
this.marker = {
version: 1,
command,
databasePath: this.databasePath,
vaultPath: this.vaultPath,
settingsPath: this.settingsPath,
phase: "running",
settingsBeforeHash: hashSettings(readOptionalFile(this.settingsPath)),
};
this.save();
}
}
complete(settingsText: string): void {
if (!this.marker || this.marker.phase !== "running") throw new Error("Reconstruction was not started.");
this.marker = { ...this.marker, phase: "completed", settingsText };
this.save();
}
commit(): void {
const marker = this.marker;
if (!marker || marker.phase !== "completed" || marker.settingsText === undefined) {
throw new Error("Reconstruction has no completed settings update.");
}
const current = readOptionalFile(marker.settingsPath);
if (current !== marker.settingsText && hashSettings(current) !== marker.settingsBeforeHash) {
throw new Error(
`The settings file changed after reconstruction: ${marker.settingsPath}. Preserve the reconstruction state and review both settings versions before continuing.`
);
}
if (current !== marker.settingsText) writeFileAtomically(marker.settingsPath, marker.settingsText);
this.clear();
}
clear(): void {
fs.rmSync(this.markerPath, { force: true });
syncDirectory(path.dirname(this.markerPath));
this.marker = undefined;
}
private save(): void {
writeFileAtomically(this.markerPath, JSON.stringify(this.marker, null, 2));
}
}
export interface LegacyStateIssue {
path: string;
reason: "unowned" | "invalid" | "unreadable";
}
export function inspectLegacyState(databasePath: string, vaultPath: string): LegacyStateIssue[] {
const issues: LegacyStateIssue[] = [];
for (const [filePath, expectArray] of [
[path.join(databasePath, ".livesync", "runtime", "keyvalue-db.json"), false],
[path.join(vaultPath, ".livesync-snapshot.json"), true],
] as const) {
let contents: string | undefined;
try {
contents = readOptionalFile(filePath);
} catch {
issues.push({ path: filePath, reason: "unreadable" });
continue;
}
if (contents === undefined) continue;
let value: unknown;
try {
value = JSON.parse(contents);
} catch {
issues.push({ path: filePath, reason: "invalid" });
continue;
}
if (value === null || typeof value !== "object" || Array.isArray(value) !== expectArray) {
issues.push({ path: filePath, reason: "invalid" });
} else if (Object.keys(value).length > 0) {
issues.push({ path: filePath, reason: "unowned" });
}
}
return issues;
}