2026-09-05 · typescript · api · 設計 · 個人開発 · cli
作成時に安全だった既定値が、更新時には破壊的になる
公開済みの記事9本に、後からリンクを足したくなりました。 自作の投稿ツールで一括更新するだけの作業です。
そのツールは、更新すると記事を非公開にしようとしました。
既定値の向きは、操作によって反転する
ツールはこういう設計でした。
const publish = rest.includes("--publish");
const article = {
title: fm.title,
body_markdown: body.trim(),
published: publish, // frontmatter ではなく明示フラグで決める
tags: fm.tags ?? [],
};
published を記事の frontmatter から読まず、コマンドラインの --publish で決める。
付けなければ下書きです。
理由はコメントに書いてありました。
既定は下書き。
--publishを明示したときだけ公開する。 事故で公開されるより、公開し忘れるほうが安全なため。
新規作成では、これは正しい判断です。 書きかけを誤って世に出すより、 出し忘れて後から出すほうが安い。
更新では、同じ既定値が逆を向きます。
--publish を忘れて既存記事を更新すると、published: false が送られ、
公開中の記事が下書きに落ちます。 URL は残ったまま 404 になります。
- 公開し忘れ → 気づいたら公開すればいい
- 公開の取り下げ → 集めた被リンクと検索の評価が死ぬ
同じ「安全側」という言葉が、操作が変わると反対側を指していました。
直し方は「賢く推測する」ではなかった
最初に考えたのは、更新のときだけ既存の公開状態を引き継ぐことでした。
--publish が無ければ、いまの状態を維持する。
やめました。「意図した取り下げ」と「フラグの付け忘れ」を区別できないからです。 区別できないものを推測すると、今度は本当に取り下げたいときに黙って無視されます。
拒否することにしました。
// published は明示フラグで決めるので、--publish 無しの更新は
// 公開済みの記事を下書きへ引きずり下ろす。取り下げは事故のほうが多いので拒否する。
// 誤って下書きに戻すと URL は残るが 404 になり、集めた被リンクが死ぬ。
if (existing?.published && !publish) {
throw new Error(
`"${fm.title}" は公開済みです。--publish 無しで更新すると下書きに戻ります。\n` +
" 更新するなら --publish を付けてください。",
);
}
実際に付けずに叩いて、終了コード 1 で止まることを確認しました。
取り下げたいなら、取り下げ用の操作を作るべきです。 「更新のついでに起きる」形にしてはいけません。
もう1つ。同一性を、変わりうる値に置いていた
同じツールに、別の穴がありました。既存記事の探し方です。
// 同じタイトルが既にあれば更新、無ければ新規作成
const mine = await call("/articles/me/all?per_page=100");
const existing = mine.find((a: any) => a.title === fm.title);
タイトルで探していました。
同じ日に、frontmatter のパーサのバグを直しました。
title: "Treating \"It Worked\" as ..." の外側の引用符だけ外して、
中のエスケープを戻していなかったので、記事名にバックスラッシュが出たまま公開していた
というものです。
// 直す前
if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
// 直した後
if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1).replace(/\\(["\\])/g, "$1");
}
直した瞬間、fm.title の値が変わります。
そして既存記事のタイトルは、公開したときの古い値のままです。
つまり、find は一致しません。「既存なし」と判定され、新規作成に落ちます。
もし気づかずに push していたら、内容の同じ記事がもう1本、公開状態で作られていました。
バグを直すと壊れる、という構造
これは「バグを2つ踏んだ」話ではありません。1つを直したら、もう1つが顕在化した話です。
同一性の判定を、そのツール自身が計算している派生値に置いていました。 計算方法を直せば、値が変わります。値が変われば、同一性が壊れます。
同じ形は他にもあります。
- 正規化した名前をキーにする → 正規化の規則を直すと、過去のデータと一致しなくなる
- 本文のハッシュをキーにする → 整形の仕方を変えるだけで別物になる
- 表示用の文字列をキーにする → 表記ゆれの修正が同一性の破壊になる
「直すと変わりうる値」を同一性に使ってはいけません。
対処: 相手が決めた id を記録する
id は API 側が採番する値で、こちらの都合では変わりません。
一度作った記事の id を記録して持つことにしました。
{
"_comment": "ファイル名 -> 記事 id。タイトルは変わりうるので、同一性は id で持つ",
"ids": {
"composite-github-action": 4576296,
"linter-false-positives": 4577641
}
}
const knownId = store.ids[slug];
let existing = knownId ? mine.find((a) => a.id === knownId) : undefined;
if (knownId && !existing) {
throw new Error(
`id=${knownId} (${slug}) が見つかりません。\n` +
" 記事が削除されたか、別アカウントの鍵を使っています。\n" +
" そのまま進めると重複記事を作るので止めます。",
);
}
記録があるのに見つからないときは、止めます。 黙って新規作成に落ちるのが、いちばん困る挙動だからです。
検証
タイトルをわざと変えて push しました。
title: "... (Without npm Publishing) (id lookup test)"
-> PUT /articles/4576296
POST /articles ではなく PUT /articles/4576296 が飛んでいます。
タイトルが違っても、id で同じ記事だと判定できています。記事の総数も増えていません。
なぜ最初からタイトルだったのか
ローカルに状態ファイルを置きたくなかったからです。 記事は Markdown ファイルだけで完結していてほしかった。
その気持ちは分かりますが、代償は**「同一性を毎回推測する」**ことでした。 推測できるうちは動きます。推測の材料が変わった日に壊れます。
状態を持たない設計は、状態を推測する設計とセットになりがちです。 持たない代わりに何を推測しているのかは、意識しておく必要がありました。
まとめ
- 安全な既定値の向きは、操作によって反転する。 作成では「公開しない」が安全側で、更新では同じ既定値が「取り下げ」になる
- 区別できない意図を推測しない。拒否して、明示させる
- 破壊的な操作は、別の操作として分ける。「ついでに起きる」形にしない
- 同一性を、自分が計算した派生値に置かない。 計算を直した瞬間に、同一性が壊れる
- 相手が採番した id を記録する。記録があるのに見つからないときは止める
- 状態を持たない設計は、状態を推測する設計になりやすい。何を推測しているかは把握しておく
あわせて読む
- 二分法で正しく詰めても答えに届かなかった。失敗の理由が手元に返ってこない系のデバッグ
- 「動いた」を検証と見なすと、1コミットでだけ通るコードが出荷される
- linter を作るとき、何を検査しないかで品質が決まる
Claude Code に設計・実装・レビューを別々の人格として分担させ、GitHub Issue と
ブランチを軸に並列開発を回すための設定一式を MIT で公開しています。
コピーして ./setup.sh を叩けば動きます。技術スタックには依存しません。
https://github.com/quintetkit/quartet
このワークフローだけで実際にツールを 1 つ作りました。Issue の分割から PR、 レビュー、マージまで記録が全部残っています。うまくいかなかった箇所も消していません。
https://github.com/quintetkit/mdlinkcheck
UI 設計人格・レビュー基準・Issue 単位の並列実行スクリプト・実践ガイド 10 章を 足したものは製品ページにあります。
実践ガイド全10章は Zenn Book で読めます(¥1,500・2章まで無料)。
https://zenn.dev/quintetkit/books/claude-code-parallel-workflow
設定一式(5人格・スクリプト・ガイド全文)の配布はこちらです。