173 lines
5.0 KiB
TypeScript
173 lines
5.0 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
import { basename, dirname, join, relative } from "node:path";
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
const isErrnoException = (error: unknown): error is NodeJS.ErrnoException =>
|
|
error instanceof Error && "code" in error;
|
|
|
|
export const ensureDirectory = async (path: string) => {
|
|
await mkdir(path, { recursive: true });
|
|
};
|
|
|
|
export const atomicWriteFile = async (path: string, content: string) => {
|
|
await ensureDirectory(dirname(path));
|
|
const tempPath = `${path}.${process.pid}.${Date.now().toString(36)}.tmp`;
|
|
await writeFile(tempPath, content, "utf8");
|
|
await rename(tempPath, path);
|
|
};
|
|
|
|
type HistoricalWriteOptions = {
|
|
afterWrite?: () => Promise<void> | void;
|
|
historyDir: string;
|
|
rootDir: string;
|
|
};
|
|
|
|
export const atomicWriteFileWithHistory = async (
|
|
path: string,
|
|
content: string,
|
|
options: HistoricalWriteOptions,
|
|
) => {
|
|
const previous = await readTextFile(path);
|
|
if (previous === content) {
|
|
return { backupPath: null as string | null, changed: false };
|
|
}
|
|
|
|
let backupPath: string | null = null;
|
|
if (previous !== null) {
|
|
// 仅在覆盖已有文件时保留历史版本,避免为首次创建产生空备份。
|
|
backupPath = buildHistoryBackupPath(path, options);
|
|
await atomicWriteFile(backupPath, previous);
|
|
}
|
|
|
|
try {
|
|
await atomicWriteFile(path, content);
|
|
// 给调用方预留一个写后钩子;若后续步骤失败,这里仍会回滚到旧内容。
|
|
await options.afterWrite?.();
|
|
} catch (error) {
|
|
try {
|
|
if (previous === null) {
|
|
await removeFileIfExists(path);
|
|
} else {
|
|
await atomicWriteFile(path, previous);
|
|
}
|
|
} catch (rollbackError) {
|
|
throw new AggregateError(
|
|
[error, rollbackError],
|
|
`write failed and rollback failed for ${path}`,
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
return { backupPath, changed: true };
|
|
};
|
|
|
|
export const atomicWriteJson = async (path: string, value: JsonRecord | unknown[]) => {
|
|
await atomicWriteFile(path, JSON.stringify(value, null, 2));
|
|
};
|
|
|
|
export const readJsonFile = async <T>(path: string): Promise<T | null> => {
|
|
try {
|
|
const content = await readFile(path, "utf8");
|
|
return JSON.parse(content) as T;
|
|
} catch (error) {
|
|
if (isErrnoException(error) && error.code === "ENOENT") {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
export const readTextFile = async (path: string): Promise<string | null> => {
|
|
try {
|
|
return await readFile(path, "utf8");
|
|
} catch (error) {
|
|
if (isErrnoException(error) && error.code === "ENOENT") {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
export const listJsonFiles = async (path: string) => {
|
|
try {
|
|
const names = await readdir(path);
|
|
return names.filter((name) => name.endsWith(".json")).map((name) => join(path, name));
|
|
} catch (error) {
|
|
if (isErrnoException(error) && error.code === "ENOENT") {
|
|
return [];
|
|
}
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
export const listFiles = async (path: string) => {
|
|
try {
|
|
const names = await readdir(path);
|
|
return names.map((name) => join(path, name));
|
|
} catch (error) {
|
|
if (isErrnoException(error) && error.code === "ENOENT") {
|
|
return [];
|
|
}
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
export const removeFileIfExists = async (path: string) => {
|
|
try {
|
|
await rm(path, { force: true });
|
|
} catch (error) {
|
|
if (isErrnoException(error) && error.code === "ENOENT") {
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
export const getFileStat = async (path: string) => {
|
|
try {
|
|
return await stat(path);
|
|
} catch (error) {
|
|
if (isErrnoException(error) && error.code === "ENOENT") {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
export const toScopedKey = (prefix: string, value?: string) => {
|
|
const normalized = value?.trim() || `${prefix}-default`;
|
|
return `${prefix}-${createHash("sha256").update(normalized).digest("hex").slice(0, 16)}`;
|
|
};
|
|
|
|
export const toActorKey = (userId?: string) => toScopedKey("actor", userId);
|
|
|
|
export const toProjectKey = (projectId?: string) => toScopedKey("project", projectId);
|
|
|
|
export const toStableId = (...parts: string[]) =>
|
|
createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 24);
|
|
|
|
export const toConversationScopeKey = (
|
|
actorKey: string,
|
|
projectKey: string,
|
|
sessionId: string,
|
|
) => `conversation-${toStableId(actorKey, projectKey, sessionId)}`;
|
|
|
|
export const slugify = (value: string) =>
|
|
value
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9._-]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 64) || "entry";
|
|
|
|
const buildHistoryBackupPath = (path: string, options: HistoricalWriteOptions) => {
|
|
const relativePath = relative(options.rootDir, path);
|
|
const scopedPath =
|
|
relativePath && !relativePath.startsWith("..") ? relativePath : basename(path);
|
|
// 备份目录尽量复用原始相对路径,便于按业务目录回看历史。
|
|
const backupName = `${basename(path)}.${Date.now().toString(36)}.bak`;
|
|
return join(options.historyDir, dirname(scopedPath), backupName);
|
|
};
|