mirror of
https://github.com/jcreek/OpenNetworkDiagram.git
synced 2026-07-14 03:23:44 +00:00
refactor(#5): share network validation and persistence runtime
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
import { constants } from 'node:fs';
|
||||
import {
|
||||
access,
|
||||
copyFile,
|
||||
mkdir,
|
||||
open,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
stat,
|
||||
unlink
|
||||
} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* @typedef {{ machines: unknown[]; devices: unknown[] }} NetworkData
|
||||
* @typedef {{ source: string; updatedAt: string }} NetworkFileMetadata
|
||||
* @typedef {{ data: NetworkData } & NetworkFileMetadata} NetworkFileReadResult
|
||||
*/
|
||||
|
||||
const NETWORK_READ_ONLY = process.env.NETWORK_READ_ONLY === 'true';
|
||||
const DATA_FILE_DEFAULT = path.resolve(process.cwd(), 'data/network.json');
|
||||
const BACKUP_DIR_DEFAULT = path.resolve(process.cwd(), 'data/.backups');
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
function resolveDataFilePath() {
|
||||
return path.resolve(process.env.NETWORK_DATA_FILE ?? DATA_FILE_DEFAULT);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
function resolveBackupDirectory() {
|
||||
return path.resolve(process.env.NETWORK_BACKUP_DIR ?? BACKUP_DIR_DEFAULT);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isWriteEnabled() {
|
||||
return !NETWORK_READ_ONLY;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dataFilePath
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function readUpdatedAt(dataFilePath) {
|
||||
const dataFileStats = await stat(dataFilePath);
|
||||
return dataFileStats.mtime.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<NetworkFileReadResult>}
|
||||
*/
|
||||
export async function readNetworkFile() {
|
||||
const source = resolveDataFilePath();
|
||||
const raw = await readFile(source, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const updatedAt = await readUpdatedAt(source);
|
||||
return {
|
||||
data: parsed,
|
||||
source,
|
||||
updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dataFilePath
|
||||
* @param {string} backupDirectory
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function createBackupIfPresent(dataFilePath, backupDirectory) {
|
||||
try {
|
||||
await access(dataFilePath, constants.F_OK);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
await mkdir(backupDirectory, { recursive: true });
|
||||
const base = path.basename(dataFilePath);
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const backupFilePath = path.join(backupDirectory, `${base}.${timestamp}.bak`);
|
||||
await copyFile(dataFilePath, backupFilePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} backupDirectory
|
||||
* @param {string} baseName
|
||||
* @param {number} [keep]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function trimBackups(backupDirectory, baseName, keep = 5) {
|
||||
/** @type {Array<{ name: string; time: number }>} */
|
||||
let entries = [];
|
||||
try {
|
||||
const files = await readdir(backupDirectory);
|
||||
entries = await Promise.all(
|
||||
files
|
||||
.filter((file) => file.startsWith(`${baseName}.`) && file.endsWith('.bak'))
|
||||
.map(async (file) => {
|
||||
const filePath = path.join(backupDirectory, file);
|
||||
const info = await stat(filePath);
|
||||
return { name: filePath, time: info.mtimeMs };
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
entries.sort((a, b) => b.time - a.time);
|
||||
await Promise.all(entries.slice(keep).map((entry) => unlink(entry.name).catch(() => undefined)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {NetworkData} data
|
||||
* @returns {Promise<NetworkFileMetadata>}
|
||||
*/
|
||||
export async function writeNetworkFile(data) {
|
||||
const source = resolveDataFilePath();
|
||||
const backupDirectory = resolveBackupDirectory();
|
||||
const directory = path.dirname(source);
|
||||
await mkdir(directory, { recursive: true });
|
||||
await createBackupIfPresent(source, backupDirectory);
|
||||
|
||||
const temporaryPath = `${source}.tmp-${Date.now()}-${process.pid}`;
|
||||
const jsonPayload = `${JSON.stringify(data, null, '\t')}\n`;
|
||||
const fileHandle = await open(temporaryPath, 'w');
|
||||
try {
|
||||
await fileHandle.writeFile(jsonPayload, 'utf8');
|
||||
await fileHandle.sync();
|
||||
} finally {
|
||||
await fileHandle.close();
|
||||
}
|
||||
await rename(temporaryPath, source);
|
||||
|
||||
await trimBackups(backupDirectory, path.basename(source), 5);
|
||||
const updatedAt = await readUpdatedAt(source);
|
||||
|
||||
return {
|
||||
source,
|
||||
updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function checkWritableState() {
|
||||
if (!isWriteEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const source = resolveDataFilePath();
|
||||
const directory = path.dirname(source);
|
||||
try {
|
||||
await mkdir(directory, { recursive: true });
|
||||
await access(directory, constants.W_OK);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user