チャットの履歴を1つのJSONファイルに保存する、よくある構成で履歴を全消失させました。原因はコードのバグではなく、壊れたデータの扱い方という設計判断の誤りです。同じ形のコードは大量にあると思うので共有します。
構成
-
messages.jsonに配列で全メッセージを保存 - 追記は「全部読む → 配列に push → 全部書く」
- 読み込みは JSON.parse
function readMessages() {
try { return JSON.parse(fs.readFileSync(MSG_FILE, "utf8")); }
catch { return []; } // ← これが致命傷だった
}
function writeMessages(list) {
fs.writeFileSync(MSG_FILE, JSON.stringify(list, null, 2), "utf8");
}
catch { return [] } は一見親切です。ファイルがまだ無い初回起動でも落ちません。
何が起きたか
私はプログラムからメッセージを追記する際、本文にエスケープしていない二重引用符を混ぜてしまいました。これで messages.json が壊れます。
その後の流れが問題でした。
-
readMessages()が JSON.parse に失敗する - catch が握りつぶして
[]を返す - 呼び出し側は「メッセージは0件」と判断し、新しい1件だけを push する
-
writeMessages()がその1件だけでファイルを上書きする
結果、それまでの全履歴が消え、IDが1から振り直された状態になりました。壊れたのは1文字なのに、失われたのは全データです。
何が設計として間違っていたか
「読めなかった」には2種類あります。
| 状況 | 正しい振る舞い |
|---|---|
| ファイルが存在しない(初回起動) | 空配列で開始してよい |
| ファイルは在るが壊れている | 書き込みを中止すべき |
このコードは両方を同じ [] に潰していました。**後者は「データが無い」ではなく「データを読めていない」**であり、その状態で上書きするのは、内容を確認せずに書類を捨てる行為に相当します。
修正
読み込み失敗を2つに分け、壊れている場合は退避してから例外を投げるようにしました。
function readMessages({ strict = false } = {}) {
let raw;
try {
raw = fs.readFileSync(MSG_FILE, "utf8");
} catch {
return []; // ファイル自体が無い初回のみ、空で開始してよい
}
try {
return JSON.parse(raw);
} catch (e) {
// 壊れた中身は必ず退避してから扱う(捨てない)
const bak = MSG_FILE + ".corrupt-" + new Date().toISOString().replace(/[:.]/g, "-");
try { fs.writeFileSync(bak, raw, "utf8"); } catch {}
console.error("messages.json が壊れています。退避先: " + bak);
if (strict) throw e; // 書き込み経路では中止する
return [];
}
}
呼び出し側では、書き込み前の読み込みだけ strict にします。
// 追記するとき: 壊れていたら書かずに中止する
const list = readMessages({ strict: true });
list.push(msg);
writeMessages(list);
あわせて、書き込み自体も途中で壊れないようにしました。書き込み中にプロセスが落ちると、中途半端なJSONが残って同じ事故を招きます。
function writeMessages(list) {
const tmp = MSG_FILE + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(list, null, 2), "utf8");
fs.renameSync(tmp, MSG_FILE); // 同一ボリューム内のrenameは原子的
}
教訓
「読めなかった」を「空だった」に変換してはいけない。 この2つは意味が正反対です。空なら書いてよく、読めないなら書いてはいけません。
catch { return [] } や catch { return null } は、書き込み経路の手前にあると破壊的になります。同じ形のコードが自分のリポジトリにないか、以下の観点で探してみてください。
- 読み込みの失敗を既定値で握りつぶしていないか
- その既定値が、直後の上書きに使われていないか
- 壊れた入力を退避せずに捨てていないか
- 書き込みが一時ファイル+renameになっているか
私の場合、幸いバージョン管理から履歴を復元できました。復元手段がなければ、1文字の引用符で全部消えていたことになります。