Newer
Older
client_dwatch / lib / settings / json-store.ts
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;
}