Newer
Older
client_dwatch / lib / settings / json-store.ts
import { randomUUID } from "node:crypto";
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 に退避します)。
 *
 * ★書き込みはファイルパスごとにプロセス内で直列化します★(`writeQueues`。指示書BB §10 Q6・表12 J1。監督の判断 2026-10-19)。
 * Windows では同じ宛先への rename が同時に走ると `EPERM` になる(2026-10-19 実測で5回中5回)ため。
 * ★リトライは入れません★。★呼んだ順に1件ずつ書くので、後勝ち(最後に呼んだ write() の値が残る)★。
 * ★失敗した書き込みは後続を止めません★(待ち行列はそのまま次へ進む)。
 * ★別プロセス間のロックではありません★ — 本番は Node.js 1プロセス常駐(施設20件・実質1人の運用)なので足りる。
 * 複数プロセスで同じファイルに書く構成にしたら、ここにファイルロックを入れること(呼び出し側は変えずに済みます)。
 * ★読み → 書きの間の上書き(read-modify-write の後勝ち)は今までどおり防ぎません★。
 * ★一時ファイルは書き込みごとに別の名前★(`<filePath>.<pid>.<uuid>.tmp`)— 同時保存で同じ `.tmp` を共有して
 * 中身が混ざる・片方の rename が消えた `.tmp` を探して失敗するのを防ぐ(指示書BB A12・D20)。
 *
 * ★`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);
    },

    write(value: T): Promise<void> {
      // ★同じファイルへの書き込みは呼んだ順に1件ずつ★(同時の rename が Windows で EPERM になるため。指示書BB §10 Q6)
      return enqueueWrite(filePath, () => writeAtomically(filePath, value));
    },
  };
}

/**
 * ファイルパスごとの書き込み待ち行列(末尾の Promise)。
 *
 * ★モジュールに1つ★ — 同じパスに別々の `createJsonStore()` から書いても同じ列に並ぶように、
 * ストアごとではなくモジュールで持つ。キーは `path.resolve()` した絶対パス(表記ゆれで別の列にしない)。
 * ★列の末尾は失敗を握り潰した Promise★ — 前の書き込みが失敗しても次は走る。
 * 失敗そのものは `enqueueWrite()` の戻り値で呼び出し側に返る(握り潰すのは列の繋ぎ目だけ)。
 */
const writeQueues = new Map<string, Promise<void>>();

function enqueueWrite(filePath: string, task: () => Promise<void>): Promise<void> {
  const key = path.resolve(filePath);
  const previous = writeQueues.get(key) ?? Promise.resolve();
  const run = previous.then(task);
  const tail = run.catch(() => undefined);
  writeQueues.set(key, tail);
  // 最後の書き込みが終わったら列を消す(パスが増えても Map が膨らみ続けないように)。
  // 後ろに別の書き込みが並んでいたら、その書き込みが末尾なので消さない
  void tail.then(() => {
    if (writeQueues.get(key) === tail) writeQueues.delete(key);
  });
  return run;
}

async function writeAtomically<T extends object>(filePath: string, value: T): Promise<void> {
  // ★一時ファイルは書き込みごとに別の名前(同時保存で同じ .tmp を共有して中身が混ざるのを防ぐ。指示書BB A12・D20)★。
  //   ★読み → 書きの間の上書き(read-modify-write の後勝ち)は今までどおり★(ファイル冒頭の説明)
  const tmpPath = `${filePath}.${process.pid}.${randomUUID()}.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;
}