Files
ThothII/backend/src/settings/settings-store.ts
T

149 lines
4.4 KiB
TypeScript

import {
closeSync,
fsyncSync,
mkdirSync,
openSync,
readFileSync,
renameSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { dirname } from "node:path";
import type { AppConfig } from "../config.js";
export interface Settings {
workspace?: string;
provider?: string;
model?: string;
thinking?: string;
}
export interface SettingsDurability {
syncDirectory(directory: string): void;
}
export interface SettingsSnapshot {
exists: boolean;
rawBase64: string;
}
/**
* Settings files are installation defaults only. Personal workspace/model/thinking choices
* belong to the browser and must never be written back here by request handlers.
*/
/** Read settings from cfg.settingsFile. Returns {} if missing or invalid. */
export function loadSettings(cfg: AppConfig): Settings {
try {
const raw = readFileSync(cfg.settingsFile, "utf8");
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") return parsed as Settings;
return {};
} catch {
return {};
}
}
/** Capture exact file existence and bytes so host-side configuration can compensate losslessly. */
export function captureSettingsSnapshot(cfg: AppConfig): SettingsSnapshot {
try {
return { exists: true, rawBase64: readFileSync(cfg.settingsFile).toString("base64") };
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return { exists: false, rawBase64: "" };
}
throw error;
}
}
/** Restore a previously captured settings file exactly, including the clean absent state. */
export function restoreSettingsSnapshot(
cfg: AppConfig,
snapshot: SettingsSnapshot,
durability: SettingsDurability = defaultDurability,
): void {
if (typeof snapshot.exists !== "boolean" || typeof snapshot.rawBase64 !== "string") {
throw new Error("invalid settings snapshot");
}
const raw = Buffer.from(snapshot.rawBase64, "base64");
if (raw.toString("base64") !== snapshot.rawBase64 || (!snapshot.exists && raw.length !== 0)) {
throw new Error("invalid settings snapshot");
}
const directory = dirname(cfg.settingsFile);
mkdirSync(directory, { recursive: true });
if (snapshot.exists) {
replaceSettingsFile(cfg.settingsFile, raw);
} else {
try {
unlinkSync(cfg.settingsFile);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
durability.syncDirectory(directory);
}
/** Persist settings (pretty JSON). Creates the parent directory if needed. */
export function saveSettings(
cfg: AppConfig,
s: Settings,
durability: SettingsDurability = defaultDurability,
): Settings {
mkdirSync(dirname(cfg.settingsFile), { recursive: true });
const directory = dirname(cfg.settingsFile);
let previous: Buffer | undefined;
try {
previous = readFileSync(cfg.settingsFile);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
replaceSettingsFile(cfg.settingsFile, Buffer.from(JSON.stringify(s, null, 2) + "\n", "utf8"));
try {
durability.syncDirectory(directory);
} catch (durabilityError) {
try {
if (previous === undefined) unlinkSync(cfg.settingsFile);
else replaceSettingsFile(cfg.settingsFile, previous);
durability.syncDirectory(directory);
} catch {
throw new Error("settings durability failed and previous settings could not be restored", {
cause: durabilityError,
});
}
throw durabilityError;
}
return s;
}
let temporarySequence = 0;
function replaceSettingsFile(path: string, contents: Buffer): void {
const temporary = `${path}.tmp-${process.pid}-${Date.now()}-${temporarySequence++}`;
const fd = openSync(temporary, "wx", 0o600);
try {
writeFileSync(fd, contents);
fsyncSync(fd);
} catch (error) {
try { closeSync(fd); } catch { /* preserve the write error */ }
try { unlinkSync(temporary); } catch { /* best effort */ }
throw error;
}
closeSync(fd);
try {
renameSync(temporary, path);
} catch (error) {
try { unlinkSync(temporary); } catch { /* best effort */ }
throw error;
}
}
const defaultDurability: SettingsDurability = {
syncDirectory(directory: string): void {
// The core image runs Linux. Windows durability is owned by the host-side Go executable.
if (process.platform === "win32") return;
const dirFd = openSync(directory, "r");
try { fsyncSync(dirFd); } finally { closeSync(dirFd); }
},
};