Newer
Older
client_dwatch / docs / handoff / 指示書AH_実データで初めて出る不具合を先に潰す.md

指示書AH — 実データで初めて出る不具合を先に潰す

項目 値
実行環境 ★Cursor 内の Claude が実装します★
実行状態 ★★完了(2026-09-14)★★ — ★段階1〜5 すべて実施済み。★決定番号は D-133★(docs/decisions.md:9600)★
作成日 2026-09-14
起点 develop の 880c8f5(docs: SQL降ろし後のセキュリティ確認を記録する(D-132))
★着手前の基準★ ★作成時点の実測: tsc 0 / eslint 0 / vitest 67ファイル 1599件 全通過★。★着手直前に自分で測り直すこと★ → ★★完了時の実測: tsc 0 / eslint 0 / vitest 70ファイル 1,632件 全通過(+33)★★
位置づけ ★D-132 の6節★で見つけた問題の手当て。★ユーザー判断(2026-09-14)で着手が決まった★
着手前確認 ★なし★(下の §1 を読むこと)
段階数 ★5段階 / コミット5つ★
取得する決定番号 ★★着手直前に `grep -nE "^### D-(0\ 1)[0-9][0-9]" docs/decisions.md \ tail -3で実測すること★★。★作成時点の最終はD-132★ → ★★実測してD-133を採番済み★★(実測結果もD-132` が最終だった)

0. この指示書の目的

★★ローカルでは絶対に再現しない、実データを入れた日に初めて出る不具合を3つ潰す。★★

# 何が いつ発動するか どうなるか
★1★ ★control_reports の全件読み★ ★実データ投入★ ★★/ds-logs を開くとプロセスが落ちる★★
2 ★備考の保存が同じ全件読みを起こす★ 同上 ★備考が保存できない★
3 ★顧客サマリを顧客で絞っていない★ ★2社目のデータ★ ★KPIが別会社の数字になる(エラーは出ない)★

★3つとも「いま測っても何も起きない」★。★ローカルの control_reports は14行、顧客は1件★だから。


1. 着手前確認がない理由

★通常なら止まる論点が4つあったが、すべて起草時に実測・確認済み★。

論点 解消
最終通信をどう取るか ★実測で groupBy に確定★(§2 D-1)
時間で足切りするか ★実測で「しない」に確定★(§2 D-2)
顧客サマリを止めるか記録に留めるか ★ユーザー判断(2026-09-14)= 止める★
施設サマリにも広げるか ★広げない★(§2 D-6)

2. 決定事項

D-1. ★最終通信は groupBy で取る。行を運ばない★

★画面に出るのは reported_at 1個だけ★。実測で確認済み:

層 control_reports 由来の項目
lib/types/index.ts の DsDevice ★last_reported_at のみ★
d-watch-api.yaml の DsDevice 同じ
components/features/ds-logs/DsLogScreen.tsx ★表示1箇所のみ★
フィルタ / ソート / サマリ ★どれも見ていない★

★いまは8カラム × 全行を読んで、1式だけ使って捨てている。★

やること: lib/db/index.ts の dsDevices() から control_reports の入れ子 include を外し、 prisma.controlReport.groupBy({ by: ["control_id"], _max: { reported_at: true } }) に分ける。

★★take: 1 は1文字もSQLに出ない★★(実測)。★Prisma がアプリ側で切っているだけ★で、 ★relationLoadStrategy(LATERAL JOIN)は Prisma 7.10.0 / MySQL adapter に存在しない★ので回避できない。

★往復数は増えない★(include も裏で3本発行していた)。

★なぜこれが本質的な改善なのか★ ★破綻の性質は「遅い」ではなく「メモリ」★。実測:

合成行数 現状の実装
100万行 8.6秒 / ヒープ 2.7GB
★300万行★ ★★JavaScript heap out of memory でプロセス即死★★

Node の既定ヒープ上限は 4,288MB(実測)。★想定1,100万行はその4倍★。 ★groupBy なら戻りは制御盤数(11行)固定で、行数総量に依存しない★。

D-2. ★★時間の足切り(reported_at >= X)は入れない★★

★起草の途中で「24時間で足切りする」案を検討したが、実測で否定された。★

EXPLAIN の実測:

groupBy + reported_at >= '2026-09-07 00:00:00'
  type=index  key=control_reports_control_id_foreign
  ★rows=14(足切り無しと同じ)★  Extra=Using where
  -> Index scan on control_reports ... (★actual rows=14★)   ← 走査は全件のまま

★key_len が control_id の8バイトのまま増えない★=★索引で絞れていない証拠★。 ★reported_at の条件は後段フィルタでしかない★(reported_at に索引が無いため)。

走査行数 Nodeに届く行数
足切りあり ★減らない★ 減る
★groupBy★ 減らない ★11行固定★

★groupBy を採れば戻り行数はもともと11行なので、足切りを足す意味が消える。★

★★そのうえ新しいバグを作る★★ — ★足切りの外にしか心拍が無い制御盤は、最終通信が空欄になる★。 ★通信断の判定そのものは controls.status(TeamWorks が書く)なので狂わない★が、 ★「いつから切れているか」が見えなくなる★。

D-3. ★MAX(id) を使わないこと★

★既存索引だけで速くなる誘惑があるが、採らない。★

★InnoDB の二次索引には主キーが暗黙に付く★ので、control_reports_control_id_foreign は 物理的に ★(control_id, id)★ の複合索引である。だから MAX(id) なら loose index scan が効く(実測):

MAX() の対象 索引に含まれるか type rows Extra
reported_at ★含まれない★ index(フルスキャン) 14 Using where
★id★ ★含まれる★ ★range★ ★3★ ★Using index for group-by★

★★しかし id の大小順と reported_at の大小順が一致する保証はどこにも無い★★。 ★書き込むのは TeamWorks のデータ収集処理★で、★その順序は仕様として確認していない★。

★CLAUDE.md §7「仕様が不明な箇所を推測で実装しない」に該当する。★ ★確認が取れるまで MAX(reported_at) を使うこと。★

★この実験自体は残す価値がある★ — ★同じテーブル・同じ索引で、MAX() の対象列が索引の2列目かどうかだけで 実行計画が切り替わる★ことを示しており、★複合索引を依頼する根拠★になる(§段階4)。

D-4. ★実在確認は controls 1行 + JS の完全一致で判定する★

★シリアルを SQL の where に降ろすと事故る。★

DBの照合順序が大小文字を区別しない(既定は ci)
   ↓
`ds-001` が `DS-001` に当たって 404 → 200 になる
   ↓
★保存キーはリクエストの生文字列 `ds-001`★(`lib/settings/ds-device-notes.ts` は `Record<string, string>`)
   ↓
一覧は `notes["DS-001"]` で引く
   ↓
★★保存されたのに一生表示されない孤児行が残る★★

★実在チェックが防ぐはずの事故が、実在チェックを直したせいで起きる。★

やること: ★SQL には絞り込みだけさせ、当たり外れは JS の === で決める★。

  • findFirst ではなく findMany を使う(★ci 照合順序で複数行が当たったとき findFirst が「別の行」を返して偽404になる★)
  • select で serial_number だけ持ち帰る
  • ★take は付けないこと★(D-1 と同じ「take が SQL に降りない」問題を踏む可能性がある)

★controls.serial_number に索引は無いが、controls は実測11〜12行なので依頼不要。★

D-5. ★顧客サマリは customer_id が1種類でなければ例外にする(案A)★

★ユーザー判断(2026-09-14)★: 「★レポートのKPIは運用担当が判断に使う数字。別会社の数字で判断されるより、画面が出ないほうがまし★」。

★壊れ方は2系統ある★:

何が起きるか
★前期比★ 期間キー(customer_id を含まない)の Map で★後勝ち上書き★
★当期の値★ selectKpiRow() の rows.find() が★最初の1行★を採る

★別々に選ばれるので、「当期はA社・前期比はB社」という組み合わせが起こる★。★差分は両社の差という無意味な数字★になる。

★★「同じ期間キーが2回来たら例外」では足りない★★ — ★A社が当期の行だけ、B社が前期の行だけを持つとキーが衝突せず、前期比だけが静かに汚染される★。 ★★検知は「全行の customer_id が1種類か」で行うこと★★(キー衝突も併せて見る)。

★★同一顧客で同じ期間キーが2行来た場合も例外にする★★(★起草者の判断。ユーザー決定の拡張★)。 理由: ★スキーマに一意制約が無いので起こりえて、壊れ方(後勝ちで不定)が同じ★だから。

★customer_id で where を絞って回避しないこと★ — ★どの顧客かを決める根拠が無い★(D-053。 /reports/kpi の customer_id は受け取って無視する契約)。★静かに1社を選ぶと、選び間違いに誰も気づけない。★

★「期待する customer_id」を lib/config/ に置かないこと★ — それは★ユーザーが採らなかった案B★になる。

D-6. ★施設サマリには広げない★

★壊れ方が違う★:

テーブル 壊れ方 手当て
★customer_summaries★ ★どちらの会社か不定★ ★例外(本書)★
facility_summaries ★上書きは起きない★(Map キーに facility_id が入っている) 不要
facility_error_summaries ★他社ぶんが足される(決定的なズレ)★ ★本書では扱わない★
customer_error_summaries ★どこからも読んでいない★ 不要

★ユーザー決定の理由は「どちらの会社の数字が出るか不定」★であり、★施設系は不定ではない★。 ★しかも施設一覧の取得自体が顧客で絞っていない★ので、★サマリだけ止めても一貫しない★。

★同じ根(D-053 の顧客マスタ未整備)を持つ別の問題として §段階4 に記録する。★

D-7. ★d-watch-api.yaml は変更しない★

  • 段階2: ★status コードの集合が変わらない★(200 / 404 / 422 のまま)
  • 段階3: 500 が新たに出うるが、★YAML に 5xx の宣言が1本も無い★(全ルート共通)。 ★今回だけ足すと既存APIとの一貫性が崩れる★

★「全ルートに 500 を宣言する」こと自体は別作業として価値がある★ — ★契約を変えるのではなく、既に存在する挙動の記載漏れ★なので CLAUDE.md §6-1 に反しない。 ★§段階4 の申し送りに書くこと。本書では実施しない。★


3. 段階

★段階ごとにコミットすること。★ ★決定番号は着手直前に実測してから採番すること。★

段階1. ★dsDevices() の全件読みを止める★(コミット1)

変更: lib/db/index.ts の prismaSource.dsDevices()

const controls = await prisma.control.findMany({
  where: { deleted_at: null },
  include: { facility: true },          // ★control_reports を include しない★
});
const latest = await prisma.controlReport.groupBy({
  by: ["control_id"],
  where: { control_id: { in: controls.map((c) => c.id) } },
  _max: { reported_at: true },
});
const byControl = new Map(latest.map((g) => [String(g.control_id), g._max.reported_at]));

★String() でキーを作ること★ — BigInt をそのまま Map のキーにすると、 ★1n と "1" と 1 が別のキーになる★。

★心拍が0件の制御盤に対する既存の例外は、そのまま残すこと★ (byControl.get(...) が undefined なら同じメッセージで throw)。 ★これは「一覧に出るのに最終通信が無い」を検知している★。

★関数コメントに書くこと★:

  • ★なぜ入れ子 include をやめたか★(take: 1 が SQL に降りない。実測)
  • ★なぜ時間で足切りしないか★(D-2。走査が減らず、バグを作る)
  • ★なぜ MAX(id) を使わないか★(D-3。順序の保証が無い)

テスト(期待値表):

# 状況 期待
1 通常 ★従来と同じ DsDevice[]★
2 ★発行クエリの検査★ ★controlReport.findMany が0回 / controlReport.groupBy が1回★
3 ★制御盤に心拍が0件★ ★従来と同じ例外★
4 論理削除された制御盤 ★一覧に出ない(従来どおり)★
5 USE_FIXTURES true/false ★同じ結果★

コミット: fix: 最終通信の取得で control_reports を全件読むのをやめる


段階2. ★備考の保存の実在確認を1行にする★(コミット2)

★いま PATCH /ds-devices/{serial}/note が db.dsDevices() を呼んでいる★。 ★シリアル1件の実在を確かめるためだけに、全制御盤と全心拍を読む。★

★段階1 で control_reports は読まなくなるが、それでも全制御盤+全施設を読む★。 ★実在確認に施設名も最終通信も要らない。★

★さらに、いまは無関係な1台に心拍が0件だと、例外が飛んで全台の備考保存が500になる★。 ★段階1 で読まなくなるので同時に解消する★が、★経路を分けること自体に価値がある★。

変更: DataSource に新メソッドを足す。

dsDeviceExists(serialNumber: string): Promise<boolean>;

★dsDevices() に引数を足す形にしないこと★ — ★一覧側はサマリ用に全件が要る★(D-054)。

★facilityNameIndex() が既に前例★(「facilities() と用途が違うから別メソッドにした」)。

★deleted_at: null を dsDevices() と揃えること★ — ★絞りをやめると、一覧に出ないデバイスの 備考が保存できてしまい、どこからも参照されない行がストアに残る★(このチェックが防いでいる当のもの)。

★照合は D-4 のとおり★。

テスト(期待値表。★このルートにはテストが1本も無いので新規★):

# 入力 データ 期待 何を守るか
1 DS-001 存在する 200 / 保存 正常系
2 DS-999 存在しない 404 実在確認が効く
3 DS-004 ★論理削除済み★ ★404★ ★挙動を変えない★
4 ★ds-001★ DBは DS-001 のみ ★404★ ★★照合が緩まない(孤児行の再発防止)★★
5 DS-001 ★別の制御盤に心拍0件★ ★200★ ★他台の欠損に巻き込まれない★
6 101文字 — 422 ★検証順序(形式→長さ→実在)を変えない★
7 note 201文字 存在する 422 同上
8 DS-001 — ★control.findMany 1本のみ★ ★全件読みに戻っていない★
9 1・2・5 USE_FIXTURES 両方 ★status が一致★ 禁止事項(両系統で挙動を変えない)

★#3・#4 は fixtures に表現できない★ので Prisma 経路のみ。

コミット: fix: 備考の保存の実在確認を制御盤1件の照会にする


段階3. ★顧客サマリの上書きを検知して例外にする★(コミット3)

変更: lib/db/report-mapping.ts に純粋関数を足し、lib/db/index.ts の手書き Map ループを置き換える。

export function customerSummariesByPeriodKey<T extends CustomerSummaryOwnerLike>(
  rows: readonly T[]
): Map<string, T>

★純粋関数に置く理由★: CLAUDE.md §9.4。★fixtures 経路ではこの条件を再現できない★ (ReportKpiRow に customer_id が無い)ので、★DBスタブ経由でしか触れない場所に置くと境界値を直接テストできない★。

★年次除外(month === 0 && week === 0)より後に呼ぶこと★ — ★読まない契約の行(D-072 項4)で500にしない★ため。

★例外の作法は既存に揃えること★(lib/db/ds-status.ts / lib/db/index.ts の既存の throw を読むこと。 ★素の Error に [モジュール名] 前置★)。

★ルート層の変更は不要★ — app/api/reports/kpi/route.ts が既に例外を受けて { error: { code: "internal_error", message: "データの取得に失敗しました", details: null } } を返す。 ★body は定数なので customer_id は1バイトも漏れない★。

テスト(期待値表):

# 入力(customer_id / year-month-week) 期待
1 [] 空 Map(例外なし)
2 1 / 2026-9-2 1件
3 1 / 2026-9-2 + 1 / 2026-9-1 2件(同一顧客の当期+前期)
4 ★1 / 2026-9-2 + 2 / 2026-9-2★ ★throw★
5 ★1 / 2026-9-2 + 2 / 2026-9-1★ ★★throw★★(★キーは衝突しないが前期比が汚染される★)
6 ★1 / 2026-9-2 ×2(同一顧客の重複)★ ★throw★(D-5)
7 1 / 2026-9-0 + 1 / 2026-8-0 2件(week=0 の予約値を壊さない)
8 例外メッセージ ★customer_id を含み、施設名等の PII を含まない★

ルート側:

# スタブ 期待
9 1顧客・当期+前期 200
10 ★2顧客・当期2行★ ★500 / body に customer_id が含まれないこと★
11 ★2顧客・前期だけ2行★ ★500★
12 0行(確定期間) 200(既存挙動。404 にしない)
13 ★当期0行 + 前期が2顧客★ ★500★

コミット: fix: 顧客サマリに複数顧客の行が混ざったら止める


段階4. ★申し送りを書く★(コミット4)

docs/handoff.md に追記。書くこと:

  1. ★★control_reports に複合索引 (control_id, reported_at) を依頼する★★
    • ★根拠★: ★同じテーブル・同じ索引で、MAX() の対象列が索引の2列目かどうかだけで type=index(フルスキャン)↔ type=range / Using index for group-by が切り替わることを実測★
    • ★効果(推測)★: 1,100万行でも走査が制御盤数オーダーに落ちる
    • ★control_error_histories.occurred_at の索引依頼と同じ枠にまとめること★(D-132 7節)
    • ★書き込みを少し遅くする★ので、依頼時に一言添える
  2. ★段階1 だけでは DB 側の走査行数は減らない★ — ★Node は落ちなくなるが DB は重いまま★。 ★複合索引が入って初めて解決する★
  3. ★facility_error_summaries の合算問題★(D-6)— ★同じ根を持つ別の問題★
  4. ★当期(進行中の週・月)の集計は顧客で絞っていない★ — ★2顧客いると静かに合算される★。 ★段階3 の検知は通らない★
  5. ★d-watch-api.yaml に 5xx の宣言が1本も無い★(D-7)— 別作業
  6. ★MariaDB 10.11 での挙動は未確認★ — Using index for group-by は MariaDB にも存在するが未実測
  7. ★制御盤が200台を超えたら control_id IN (...) の実行計画を再測定すること★
  8. ★本番の行数見積もりが資料に存在しない★ — ★link_schedules.intervals の実運用値を確認しないと 1,100万なのか7,000万なのかが決まらない★

コミット: docs: 複合索引の依頼と、残る問題を申し送る


段階5. ★記録★(コミット5)

  • docs/decisions.md に決定を1本(★採番は着手直前に実測★)。書くこと:
    • ★take: 1 が SQL に降りないという事実★(★これが一番失われやすい★)
    • ★破綻の性質は「遅い」ではなく「メモリ」★(300万行でOOMの実測)
    • ★時間の足切りを検討して否定した経緯★(D-2。★否定した理由を残さないと、また誰かが提案する★)
    • ★MAX(id) を採らなかった理由★(D-3)
    • ★照合順序の罠★(D-4)
    • ★施設サマリに広げなかった理由★(D-6)
  • 本書の冒頭表と実行状態表を更新(★段階1〜4 のハッシュを埋める。段階5 は監督が埋める★)

コミット: docs: 指示書AHの完了を記録する


4. ★踏みうる罠★

# 罠 対策
★1★ ★BigInt を Map のキーにする★ ★String() で揃える★。1n / "1" / 1 が別キーになる
★2★ ★時間の足切りを「良かれと思って」足す★ ★D-2。走査は減らず、バグを作る★
★3★ ★MAX(id) に変える★ ★D-3。順序の保証が無い★
★4★ ★実在確認でシリアルを where に降ろす★ ★D-4。孤児行が残る★
★5★ ★段階3 の検知を「キー衝突」だけで書く★ ★D-5。前期だけ汚染される場合を取り逃がす★
★6★ ★年次除外より前に検知を呼ぶ★ ★読まない契約の行で500になる★
★7★ ★deleted_at の条件を落とす★ ★段階2。孤児行がストアに残る★
8 ★take を足す★ ★段階1 で「SQL に降りない」ことが判明している★

5. ★禁止事項★

  • ❌ ★生SQL★(ESLint が落とす。D-122)
  • ❌ ★スキーマ変更★(索引追加を含む。CLAUDE.md §7。★依頼は段階4★)
  • ❌ ★d-watch-api.yaml を変更すること★(D-7)
  • ❌ ★customer_id で where を絞ること★(D-5。★どの顧客かを決める根拠が無い★)
  • ❌ ★「期待する customer_id」を lib/config/ に置くこと★(D-5)
  • ❌ ★施設サマリに同じ検知を広げること★(D-6)
  • ❌ ★any★ / ★閾値のハードコード★
  • ❌ ★git add -A★ — 必ずファイルパスを明示する
  • ❌ ★次のファイルに触ること★: README.md / docs/code-map.md / docs/handoff/管理画面_設計検討_20260910.md / .claude/** / lib/fixtures/** / prisma/schema.prisma

6. 完了条件

  • npx tsc --noEmit = ★0★
  • npx eslint . = ★0★
  • npx vitest run ★全通過★(★着手前は 67ファイル 1599件。着手直前に測り直すこと★)
  • ★段階1 の発行クエリに controlReport.findMany が0回であることをテストで固定★
  • ★段階2 のルートにテストが新設されていること★
  • ★git status に、着手前から増えた無関係な変更が無いこと★

7. 実行状態

段階 内容 決定番号 コミット 状態
1 dsDevices() の全件読みを止める ★D-133★ 3683ba3 ★★完了★★
2 備考の実在確認 ★D-133★ 21a1335 ★★完了★★
3 顧客サマリの検知 ★D-133★ c0267c6 ★★完了★★
4 申し送り(docs/handoff.md 追記46) ★D-133★ 3a0a0c2 ★★完了★★
5 記録(D-133 + 本書の実行状態) ★D-133★ (本コミット) ★★完了★★

★完了時の実測★: npx tsc --noEmit = ★0★ / npm run lint = ★0★ / npm test = ★★70ファイル・1,632テスト 全パス★★(★着手前 67ファイル・1,599件 → +33★)。

★§6 完了条件の確認★:

条件 結果
npx tsc --noEmit = 0 ★★満たす★★
npx eslint . = 0 ★★満たす★★(npm run lint)
npx vitest run 全通過 ★★満たす★★(70ファイル・1,632件)
★段階1 の発行クエリに controlReport.findMany が0回であることをテストで固定★ ★★満たす★★(lib/db/__tests__/ds-devices-source.test.ts #2)
★段階2 のルートにテストが新設されていること★ ★★満たす★★(app/api/ds-devices/[serial_number]/note/__tests__/ に2ファイル・13件)
★git status に着手前から増えた無関係な変更が無いこと★ ★★満たす★★(★着手前から README.md / docs/code-map.md / docs/handoff/管理画面_設計検討_20260910.md の3件は担当外として未変更のまま残置★)

★★本作業では git push していません★★(develop にローカルコミットのみ)。