import { promises as fs } from "node:fs";
import path from "node:path";
/**
* DFA 独自ストア(JSON1ファイル)の読み書きを安全に行う共通部品。
*
* ★メインDBには書きません★(DBはTeamWorks管理で読み取り専用。CLAUDE.md §7 禁止事項)。
* 保存先は `data/settings/` 配下で、`/data` は .gitignore されています
* (= ★git のバックアップがありません★。だから壊れたファイルは .bak に退避します)。
*
* ★ロックは入れません★ — 施設20件・実質1人の運用で、rename によるアトミック差し替えで
* 「壊れたファイルが残る」ことは防げるため。同時保存は後勝ちになります。
* 利用者が増えたらここにロックを入れること(呼び出し側は変えずに済みます)。
*
* ★`lib/logger` を import しません★ — `lib/logger/index.ts` は `@/lib/db` 経由で
* fixtures 全体を引き込むため、ストア層の依存グラフが不必要に重くなります。
* 警告は `onWarn` で外に渡すだけにし、`logEvent` を呼ぶのは呼び出し側(APIルート)の仕事です。
*/
export interface JsonStore<T extends object> {
read(): Promise<T>;
write(value: T): Promise<void>;
}
export interface JsonStoreWarning {
filePath: string;
backupPath: string;
reason: string;
}
export interface JsonStoreOptions<T extends object> {
/** 保存先の絶対パス。★テストから差し替えられるように引数で受ける★ */
filePath: string;
/** 空の初期値を作る。★Object.create(null) を土台にすること★ */
empty: () => T;
/** 壊れた JSON を検知したときの通知。★lib/logger を import しないための注入★ */
onWarn?: (info: JsonStoreWarning) => void;
}
export function createJsonStore<T extends object>(
options: JsonStoreOptions<T>
): JsonStore<T> {
const { filePath, empty, onWarn } = options;
return {
async read(): Promise<T> {
let raw: string;
try {
raw = await fs.readFile(filePath, "utf-8");
} catch (err) {
// 「一度も保存していない」は正常な状態。ここで .bak を作ると
// 初回起動のたびにゴミが増えるので、ENOENT だけは静かに空で返す。
// 権限エラー等は握り潰さずそのまま投げる(設定が読めないのに
// 「未編集」として動くと、次の保存で既存の設定を消してしまう)
if (isErrnoException(err) && err.code === "ENOENT") return empty();
throw err;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
return quarantine(
filePath,
empty,
onWarn,
`JSONとして解析できません: ${messageOf(err)}`
);
}
// 配列・文字列・数値・null は「キーと値の集まり」ではないので、
// 中身が読めても仕様外=破損として扱う(そのまま返すと呼び出し側が壊れる)
if (!isRecord(parsed)) {
return quarantine(
filePath,
empty,
onWarn,
`JSONのトップレベルがオブジェクトではありません: ${describeType(parsed)}`
);
}
return sanitize(parsed, empty);
},
async write(value: T): Promise<void> {
const tmpPath = `${filePath}.tmp`;
await fs.mkdir(path.dirname(filePath), { recursive: true });
try {
// 直接 filePath へ書くと、既存ファイルを切り詰めてから書くため
// 途中で落ちると空 or 途中までのファイルが残る。
// 一時ファイルに書き切ってから rename で差し替えれば、
// 読み手からは「古い中身」か「新しい中身」のどちらかしか見えない
await fs.writeFile(
tmpPath,
`${JSON.stringify(value, null, 2)}\n`,
"utf-8"
);
// rename は Windows / Linux どちらでも同名の既存ファイルを上書きする
await fs.rename(tmpPath, filePath);
} catch (err) {
// 失敗した .tmp を残すと、次回の書き込みが古い残骸と紛らわしくなる。
// 片付け自体の失敗で元のエラーを覆い隠さないよう握り潰す
await fs.rm(tmpPath, { force: true }).catch(() => undefined);
throw err;
}
},
};
}
/**
* 壊れたファイルを .bak へ退避し、空の値を返す。
*
* ★退避に失敗したら例外を投げる★(空の値を返さない)。
* 空の値を返してしまうと、呼び出し側が次に write() した瞬間に壊れたファイルが
* 上書きされ、二度と復旧できません。/data は .gitignore なので他に控えもありません。
* 「データが消えるより例外の方がまし」という判断です。
*/
async function quarantine<T extends object>(
filePath: string,
empty: () => T,
onWarn: ((info: JsonStoreWarning) => void) | undefined,
reason: string
): Promise<T> {
const backupPath = backupPathFor(filePath);
try {
// コピーではなく rename。コピーだと壊れたファイルが残り続け、
// 読むたびに警告と .bak が増えてしまう
await fs.rename(filePath, backupPath);
} catch (err) {
throw new Error(
`設定ファイルが壊れていますが、退避に失敗したため読み込みを中止しました: ${filePath} → ${backupPath}(${reason})`,
{ cause: err }
);
}
// 退避できたときだけ通知する(退避できていないのに「退避した」と伝えない)
onWarn?.({ filePath, backupPath, reason });
return empty();
}
/**
* 退避先の名前は★タイムスタンプ付き★。
* 固定名(`advice.json.bak`)にすると、2回目の破損で1回目の証拠が消えます。
* `:` は Windows のファイル名に使えないので `-` に置換します。
*/
function backupPathFor(filePath: string): string {
const stamp = new Date()
.toISOString()
.replace(/\.\d+Z$/, "")
.replace(/:/g, "-");
return `${filePath}.${stamp}.bak`;
}
/**
* パース結果をそのまま返さず、`empty()` を土台にキーを1つずつ写す。
*
* ★プロトタイプ汚染対策★。JSON 側に `__proto__` / `constructor` / `prototype` という
* キー名があっても、`Object.create(null)` の土台へ写す分には安全です。
* 代入に `defineProperty` を使うのは、万一 `empty()` が素のオブジェクトを返しても
* プロトタイプ側のセッターを踏まないようにするため(代入演算子だと踏みます)。
*
* 写すのは浅いコピーで十分です。ネストした `__proto__` は JSON.parse の時点で
* 通常の自前プロパティになっており、プロトタイプには繋がりません。
*/
function sanitize<T extends object>(
parsed: Record<string, unknown>,
empty: () => T
): T {
const out = empty();
for (const key of Object.keys(parsed)) {
if (!Object.prototype.hasOwnProperty.call(parsed, key)) continue;
Object.defineProperty(out, key, {
value: parsed[key],
writable: true,
enumerable: true,
configurable: true,
});
}
return out;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
return err instanceof Error && "code" in err;
}
function messageOf(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function describeType(value: unknown): string {
if (value === null) return "null";
if (Array.isArray(value)) return "array";
return typeof value;
}