SCALE_共通_トラブルシューティング集
SCALE 共通 トラブルシューティング集
問題発生時にまず最初にこれを舐めて、過去パターンに該当するか順番に潰す。
「闇雲に対策→失敗」を繰り返さないための、しらみつぶしリスト。
Claude / 開発者は、システム不具合報告を受けたら まずこのノートを Read すること。
なぜこのノートが必要か(v9.155 反省)
2026-05-11 に SCALE CRM の架電リスト消失バグで 17回(v9.137 → v9.155) 修正を試みたが、真因(localStorage QuotaExceededError)に辿り着けなかった。
原因: 防御層を 4経路(S/poll/init/bc)→ API側 → リトライ → 起動時自動PUT、と積み重ねたが、「ローカル書き込み自体が失敗する」可能性 を一度も疑わなかった。
教訓: 推測で防御を積むより、ログを仕込んで真因を観測 する方が圧倒的に速い。
最上位ルール — ラリー最小化バッチ対応(2026-05-13 大串FB・絶対遵守)
エラー発生時は「1個ずつ試す」を絶対にやらない。想定原因 全部 + 修正 全部 + トレース 全部 を 1セット で投入する。
NGフロー(やったらタイムロス確定)
- 推測で原因 A を1つだけ修正 → デプロイ → ユーザー確認 → 違う
- じゃあ原因 B を1つだけ修正 → デプロイ → ユーザー確認 → 違う
- じゃあ原因 C を…(ラリー無限ループ)
OKフロー(1ラリーで真因確定+根治)
Step 1: 全仮説リサーチ
- 想定される原因を 3〜10個 リストアップ(grep / 過去FB / 既存トラブルシューティング集)
- 全経路の関連コードを読む
Step 2: 全経路にトレース仕込み
- 1経路だけではなく N 経路 全部にトレース
- console.log / setter 傍受 / Proxy で 同セッション全部キャッチ
- スタックトレース出力で犯人を特定可能に
Step 3: 想定原因 全部 に対する保険的修正を一気に実装
- 「とりあえず A だけ試す」を禁止
- A + B + C + D + E 全部の修正コードを同リリースで投入
- 保険的防御も同時投入
Step 4: ユーザーへの検証依頼も1回でまとめて
- 「これだけ試して」ではなく「① + ② + ③ + ④ を 1回でやって・結果まとめて共有して」
- ログ取り手順 / キャッシュクリア / バージョン確認 / シークレットウィンドウ確認 を 1メッセージに同梱
Step 5: 1ラリーで真因確定 → 残作業なし
適用シーン
- バグ報告を受けた時
- 新規システム実装時(想定派生機能・保険コードも一気に)
- リファクタリング時(影響範囲全部・テスト全部・rollback 経路全部)
大串からのFB原文(2026-05-13)
「今回みたいにエラーが起きたときに何回やっても治らないのが一番タイムロスでしんどい。1個ずつ検証して・1個ずつ依頼して、ではなく一気に人間が試すこと依頼しつつ、一気に実装、全部やれることリサーチして、全部一気に修正、みたいにまとめてやりたい。ラリーは最小化したい、一回が重くても。」
問題発生時の標準フロー(必ずこの順序)
Step 0: トレーサを仕込む(最優先)
- データ消失系 →
_logCallListWrite等の 書き込みトレーサ を全経路に仕込む - API 失敗系 → fetch のレスポンスを全部 console.log
- UI 崩れ系 → 該当 DOM のイベントリスナーをログ化
- ユーザーに「コンソールログを送ってもらう」体制を作る
Step 1: ブラウザ環境を疑う
- [ ] localStorage QuotaExceededError(5-10MB 上限)→ DevTools > Application > Storage で使用量確認
- [ ] ブラウザキャッシュで古い JS が動いてないか(バージョン表示で確認)
- [ ] Cookie / sessionStorage 容量
- [ ] Service Worker のキャッシュ
Step 2: データ型を疑う
- [ ] 配列前提のフィールドが 文字列 / object / null になってないか(
Array.isArrayチェック) - [ ] object 前提のフィールドが 配列 / null になってないか
- [ ] 数値が NaN / undefined になってないか
- [ ] 日付が Invalid Date になってないか
Step 3: API/D1 を疑う
- [ ] API PUT が成功してるか(response.ok 確認)
- [ ] API レスポンスが期待形式か(json.ok, json.data の確認)
- [ ] D1 上の実データを GET で直接確認
- [ ] 403 拒否(サーバー側防御発動)が出てないか
- [ ] リトライ機構が動いてるか(指数バックオフ)
- [ ] キーの typo / 不一致
Step 4: クライアント同期を疑う
- [ ] 二重書き込み(PS + 独自fetch)でレース発生してないか
- [ ] debounce 中のリロードでデータ消えないか
- [ ]
_initSupabase起動時の上書き - [ ]
_d1PollTickの差分 pull - [ ]
BroadcastChannel別タブ通知 - [ ] snapshot/Undo 復元 で過去状態に戻ってないか
Step 5: UI レンダリングを疑う
- [ ] レンダリング前後で
_cacheの値変化を console.log - [ ] 描画ループになってないか
- [ ] DOM の入れ子・閉じタグ漏れ(特に SVG ファビコン)
- [ ] event リスナーの多重バインド
Step 6: 権限・mode 切替を疑う
- [ ] PM限定機能で
isPM()が false になってないか - [ ] mode (script/service 等) の保存・復元
- [ ] session の永続化
- [ ] currentProjectId の不一致
過去エラー要因リスト(v9.x 時系列・全パターン)
A. データ消失系
| No | Ver | 症状 | 真因 | 対策 |
|---|---|---|---|---|
| A1 | v9.135 | CSV取込で「リスト入らない」 | 重複検知で deleted_at 行も含めていた | existingByName 構築時に deleted_at 行を skip |
| A2 | v9.137 | 取込直後のレースで消失 | PS()と独自fetch PUTの二重書き込み | 独自fetch削除、PS統一+await |
| A3 | v9.142 | 縮小 PUT で消失 | S/poll に防御なし | 縮小 block 防御を S/poll に追加 |
| A4 | v9.143 | initSupabase/BC で消失 | D1<localStorage で localStorage上書き | _initSupabase/BC に縮小 block + 逆同期 |
| A5 | v9.144 | クライアント側突破で消失 | クライアント側防御の漏れ | API ハンドラに 403 拒否(究極防御) |
| A6 | v9.145 | 自動再フェッチで PM データ消失 | forceFetch が D1→ローカル無条件上書き | v9.146 で双方向防御追加 |
| A7 | v9.150 | PUT 失敗時に放置 | リトライなしで諦め | _syncToSupabase に指数バックオフリトライ |
| A8 | v9.152 | 起動時 D1 欠落キーが放置 | _initSupabase が D1 にあるキーしか処理しない | localStorage にあるが D1 にないキーを起動時自動PUT |
| A9 | v9.155 | localStorage QuotaExceededError でローカル保存失敗→リロードで消失 | ブラウザ容量超過(5-10MB上限)で setItem 例外 | _safeSetItem で自動クリーンアップ→再試行→失敗時メモリ動作 |
B. UI 表示崩れ系
| No | Ver | 症状 | 真因 | 対策 |
|---|---|---|---|---|
| B1 | v9.134 | 使い方マニュアルで「謎のマーク」 | 絵文字→SVG置換でファビコン残骸が本文に残った | favicon link 正規表現で全体ブロックを正しく置換 |
| B2 | v9.140 | サイトのリニューアル「filter is not a function」 | svc.effectiveWords が文字列化 | _svcNormalize で配列フィールド型ガード |
| B3 | v9.141 | クライアント要確認タブが開けない | pendingConfirm の null 値で v.status TypeError | object 型ガード + filter |
| B4 | v9.148 | サービス概要 完成度 33% | campaign=100%固定が平均に含まれてた | basic+target のみで算出 |
| B6 | v10.46→v10.50 | Slackチャンネルが各案件名で自動作成されない・「SCALE Lead」固定名しか作られない (退化) | 案件名作成 autoCreateProjectSlackChannels の呼出ボタンは案件編集UIの1箇所のみ → v10.46 で当該UIコンテナを display:none 化した際、移植先(設定の案件別Slackチャンネル設定)に自動作成導線を作らず、かつ本関数が ep_slack_slug/ep_channel/getProject の案件編集DOM依存だったため、機能到達不能化。残ったのは設定の全一括作成(SLACK_GLOBAL_CHANNELS=固定名)のみ | ①autoCreateProjectSlackChannels(btn,opts) 汎用化 — opts.projectId 時は案件編集DOM非依存(slug/channelType/p/保存先を選択案件から導出)。引数なし=従来同一で後方互換 ②設定の案件別セクションに「🔧この案件名で自動作成」ボタン新設(選択案件ID渡し) |
| B5 | v9.210→v10.11-rc1 | TERASU 307件 等で「行 28 ステータス変更 → スクロール最上部に戻る」(クレーム②再発) | clInlineSave / saveCallStatusModal 内で setTimeout(50, () => _rerenderKeepScroll(renderCallList)) が走る → 200件超は chunked rendering 経路 → 最初の200件描画 → scrollHeight 一時縮小 → _rerenderKeepScroll の scroll 復元値が clamping → 最上部戻り / v9.238 の chunked 完了後復元はあるが scrollTop=0 になった後に発火するので一度ガクン来る |
calllist.js に <div id="callListStatsContainer">${getTimerHTML()}</div> 追加 / 新規 _updateCallListStatsOnly() 関数 (該当コンテナだけ getTimerHTML() で innerHTML 更新) / clInlineSave/saveCallStatusModal で renderCallList 全体再描画を廃止 → 該当行 cl-cell 個別更新 + 上部統計だけ更新 / 行 DOM 触らない → chunked rendering 再実行なし → scroll 完全維持 |
B6 から学ぶ普遍ルール(UI隠蔽=機能到達不能の罠)
UI を display:none / 削除 / inert化 する時、その内部にある「機能ボタン(関数呼出元)」が一緒に消えて、機能自体が到達不能化していないか必ず確認する。
# UIを隠す/消す前に、その中の onclick 関数の「他の呼出元」を必ず grep
grep -rn "onclick=\"<関数名>\|<関数名>(" --include="*.js" . | grep -v "function <関数名>"
# → 呼出元が「隠す対象UIの中だけ」なら、その機能は到達不能になる
判別ルール:
- 「設定に一本化」「別画面へ移動」を理由にUIを隠す時は、移植先に同じ機能の導線(ボタン/関数呼出)を必ず実装してから隠す。隠すだけ=機能消失(退化)。
- 隠す対象UIのDOM(getElementById等)に依存している関数は、移植先から呼べるよう 引数(opts)でコンテキストを渡せる汎用化 を行い、旧経路は引数なしで従来同一=後方互換にする(退化ゼロ)。
- v10.29/v10.33 の「inert残置」は renderer が R-map から到達可能 な前提でのみ安全。ユーザー操作起点の機能ボタンを隠す場合は inert では不十分(誰も呼べない)。
B5 から学ぶ普遍ルール
「大規模リスト + chunked rendering + 全体再描画」 = scroll 戻りの温床:
// ✗ NG: 大規模リストで _rerenderKeepScroll が clamping される
setTimeout(function(){
_rerenderKeepScroll(renderCallList); // chunked 描画中に scrollHeight 縮小 → scroll 値が範囲外 → 最上部
}, 50);
// ✓ OK: 該当部分だけ DOM 直接更新で全体再描画を回避
// 1. 該当行: cl-cell を clCellDisplay() で個別更新
var anyTd = document.querySelector('td.cl-cell[data-id="'+callId+'"]');
var row = anyTd ? anyTd.closest('tr') : null;
if(row) row.querySelectorAll('td.cl-cell').forEach(function(cell){
cell.innerHTML = clCellDisplay(c, cell.dataset.field);
});
// 2. 上部統計: 独立コンテナを innerHTML 更新
document.getElementById('callListStatsContainer').innerHTML = getTimerHTML();
判別ルール: 「大規模リスト (200件超 / chunked rendering 経路) で 部分変更後に全体再描画」する箇所を grep で全部洗う:
grep -rn "_rerenderKeepScroll(renderXxx)\|renderXxxList()" --include="*.js"
→ 「部分更新で済むか? 」を一個ずつ判定。済むなら DOM 直接更新に置き換え。
C. AI共創系
| No | Ver | 症状 | 真因 | 対策 |
|---|---|---|---|---|
| C1 | v9.133 | mode 共通で混在 | script モードが service prompt 使用 | _coCreateBuildScriptSystemPrompt 分離 |
| C2 | v9.136 | script モード中に svc に書き込み | data.field 形式が mode 問わず保存 | mode='script' 時 data.field を block + system prompt 強化 |
| C3 | v9.136 | 再開で mode 失われる | session.mode 未保存 | session に mode 保存・復元 |
| C4 | v9.138 | スクリプトボタンで過去 service セッション再開 | _coCreateGetActiveSession が mode 関係なく最新 | mode 引数追加・mode一致セッションのみ resume |
| C5 | v9.148 | ページ遷移後に [CHOICES] タグ残骸表示 | restoreChat でタグ除去なし | text 復元前に [CHOICES]/[LEARN] 除去 |
| C6 | v9.148 | 再開後ボタン表示されない | meta.options 未保存 | text の [CHOICES] からフォールバック抽出 |
D. 連携・同期系
| No | Ver | 症状 | 真因 | 対策 |
|---|---|---|---|---|
| D1 | v9.130 | メンバー権限 type 変更が反映遅い | visibilitychange poll なし | 即時 poll + _onRemoteChange 強化 |
| D2 | v9.150 | PM→メンバー伝播 30秒遅延 | _d1PollTick の差分間隔依存 | renderCallList でローカル空時に forceFetch 自動実行 |
| D3 | v9.192 | メンバー編集 (権限/タイプ) が保存後に元に戻る・モーダル再オープンで旧データ表示 | getMembers() の stored[idx]={...stored[idx],...dm} で DEFAULT_MEMBERS(dm) がユーザー編集を上書き / 大串・細川 が DEFAULT_MEMBERS にハードコードされていたため、 getMembers() 呼び出しのたびに role/type が admin/pm に強制リセット |
merge 順序を逆転: {...dm,...stored[idx]} に変更。stored (ユーザー編集) を優先 / DEFAULT_MEMBERS は不足フィールド補完のみ |
| A10 | v9.194 | 架電リスト 選択して削除を押した → toast「削除しました」表示 → リロードすると削除が消えて復活する | PS('call_list',calls) 内部の _syncToSupabase は 300ms debounce で D1 PUT する設計 / 削除直後にリロードすると debounce 待ち中で D1 PUT が走らず、D1 には削除前データのまま / _initSupabase が古い D1 値で local を上書きして deleted_at が消える |
1ラリーで7経路根治: ①_flushCallListToD1 ヘルパー新設(debounce バイパスして即時 D1 PUT await) / ②全削除関数(_clBulkDelete/delCall/restoreCall/permanentDelCall/emptyCallTrash) async化 + flush 呼び出し / ③window._clJustPurged フラグを個別削除にも 5秒間適用 / ④_initSupabase 防御に _localHasMoreDeletes (local の deleted_at 件数 > D1) 判定追加 / ⑤forceFetchCallListFromD1 同様 / ⑥beforeunload で pending PUT を keepalive fetch / ⑦[FLUSH-CL-D1] トレース |
| A11 | v11.6.8 (2026-07-07) | 新メンバーのSlackID・担当案件を設定したのに消える(本人は全画面「担当案件がありません」で業務不能) | ①認証の全行書き戻し(実行犯): login遅延ハッシュ化/set-pass●●●●●●が対象1名のPW処理のために writeMembers で全メンバーを「read時点のスナップショット」で上書き。誰かのログイン1回で直前の他画面保存(SlackID/担当案件)が巻き戻るレース窓が常時存在(D1物証=member_rows全12行が同時刻 updated_by=auth-lazy-hash・本人行にpass●●●●●●有=初回ログインが引き金) ②保存の完了保証欠如: saveMember/アサイン保存は blob のみ即時PUT+await で、正本の per-row(member_rows=readMembers/権限ゲート/認証が読む)は300msデバウンス+バックグラウンド送信任せ=画面遷移/タブ閉じで未達の余地 | ①writeMemberOne新設(auth.ts): 該当メンバー1行だけupsert・login/set-pass●●●●●●を切替(認証ロジック無変更・書き込み範囲12行→1行でレース窓が構造的に消滅) ②保存経路に行単位PUT(/api/members {memberId,data})の即時awaitを追加(サーバーapplyCredsInvariantが資格情報を保全マージ=安全) ③保存後検証 _memVerifySavedOne: 1.5秒後にD1実読→projects/slackUserId/type/role一致検証→不一致は行単位PUTで自動修復→再検証→ダメなら警告toast=「保存したのに消えてる」を人が必ず気づける([MEM-VERIFY]ログ) |
| A12 | v11.6.13 (2026-07-15) | メンバーを削除してもリロードすると復活する | ①delMemberがローカル削除+バックグラウンド送信任せ=DELETE送信前のリロードで削除が未達のまま消える(編集はv11.6.8で即時化済みだが削除が漏れ) ②blob(app_data members)に残ったメンバーを orphan自己修復(self-heal-v11.5.83=消失対策の安全網)が per-row へ復活INSERT(per-row先行削除だとblob除去までの数秒でゾンビ化・実測で現行犯確認) | delMemberを完了保証型に: blob除去→per-row DELETE の順(順序重要)で即時await + 1.5秒後にD1実読で消えたことを検証(_memVerifyDeleted・残存なら自動再DELETE→警告toast)。教訓=①書き込み系は「ローカル反映+バックグラウンド送信」でなく重要操作は即時await+実読検証 ②自己修復(自動復元)機構がある系では削除は「復元材料(blob等)を先に断つ」順序が必須 |
| A13 | v11.6.14 (2026-07-17) | リスト依頼が人によって見えない/Slack通知が来たり来なかったり/依頼時刻が9時間ズレ | ①per-rowフラグ未ロード端末の保存先分裂: makePerRowLayerのperRow()がflag未ロード(_cache未着+localStorage無し=新メンバー初回端末)を「false=blob経路」に落とし、端末によって読み書きが per-row(正本) と blob の別世界に分裂(データは無消失・見え方だけ分裂) ②createdAtのUTC直表示(slice(0,16))=JSTより9時間前に見える ③botToken解決が現在案件のローカルキャッシュ依存で、boot未ロード端末は「Token未設定」扱い→通知が静かに欠落 | ①perRow()の未ロード時既定をONに昇格(本番全14フラグ=\"1\"実測が根拠・ロールバック=window._XXX_PERROW_FORCE=false) ②new Date()経由のJST表示 ③_slackResolveBotToken 3段フォールバック(ローカル→他案件ローカル→D1直接fetch)。教訓=①「フラグで経路が分かれる設計」はフラグ未ロード時の既定が全端末で一意でないと世界が分裂する ②時刻表示はISO切り出し禁止・必ずDate経由 ③調査で「0件/空」が出たら生出力で再確認(パース失敗を0と誤断定した反省) |
| A11 | v9.195 | A10 (v9.194) を入れたが「またリロードで復活」「ゴミ箱が空」と再発 | v9.194 では塞ぎきれなかった理由: (a) window._clJustPurged は 5秒タイマー+リロードで消える / (b) localStorage 自体の deleted_at が消えると _initSupabase 防御の deleted_at 差分判定も発動しない / (c) call_list本体 と ゴミ箱 両方から削除情報が消失 |
8経路二重三重防御: ①削除ログ独立永続化 sb__deletelog_call_list_<projId> 別キーに {id, deleted_at, deleted_by} 記録 / ②_clRecordDeleteLog/_clApplyDeleteLog/_clRemoveDeleteLog API + 90日 purge / ③_clMarkJustDeleted/_clIsJustDeleted で 60秒 localStorage 永続化 / ④_initSupabase で D1 fetch 後に _clApplyDeleteLog 適用 + 防御判定 / ⑤localStorage 再シリアライズで deleted_at 再付与済の値を書き戻す / ⑥showCallTrash で食い違い自動修復 + 通知 toast / ⑦_flushCallListToD1 3回リトライ / ⑧[DEL-LOG]/[CL-TRASH v9.195] トレース |
A11 から学ぶ普遍ルール
「同じ場所に1つしか真実を置かない」アーキテクチャの脆さ:
// ✗ NG: deleted_at が call_list 配列の中にしかない → 配列上書きで失われる
calls[idx].deleted_at = now; // この情報の生存は配列の生存に完全に依存
// ✓ OK: 重要操作 (削除/取消) は独立した別キーにも記録 → 本体が失われても復元可能
calls[idx].deleted_at = now;
_clRecordDeleteLog(projectId, [id]); // sb__deletelog_call_list_<projId> に独立記録
// → call_list が D1 値で上書きされても、_clApplyDeleteLog で deleted_at を再付与可能
判別ルール: ユーザー操作(削除/設定変更/権限変更)の結果が「別経路の上書きで消える可能性がある」場合、独立した「操作ログ」キーに記録 + 描画前に再適用 という冗長設計をデフォルトにする。1箇所しか保存しないと、その場所が消えた瞬間に情報がロストする。
A10 から学ぶ普遍ルール
「debounce で書き込み遅延」+「リロード時に外部ストレージから上書き」のコンボは事故の温床:
// ✗ NG: PS は debounce → D1 PUT。削除直後リロードで D1 にまだ届かない
PS('call_list', calls); // debounce 300ms
toast('削除しました'); // ユーザーは成功だと思ってリロード → 復活
// ✓ OK: 削除/更新系は debounce バイパスして即時 await PUT
PS('call_list', calls);
await _flushCallListToD1('delete'); // 即時 D1 PUT を待つ
toast('削除しました'); // 確実に D1 に届いてから成功表示
判別ルール: 「local では消える/変わるが、リロードで復活/巻き戻る」と報告されたら、まず debounce バイパス + beforeunload 保険 + リロード時の外部上書き経路 の3点を疑う。
| D4 | v9.193 | メンバー編集でPM権限なくしたメンバー(細川)が「案件編集のPM担当セレクト」と「Slack自動招待」に残り続ける / 担当チェックもしていないのに自動チャンネル作成で招待される | 複合4経路: ① fallbackNames=['大串','細川'] ハードコードでPM候補強制追加 (core.js:2589) / ② DEFAULT_MEMBERS で細川 admin/pm 固定 (core.js:1747) → stored から消えると stored.unshift(dm) で復活 / ③ slackInviteProjectMembers の m.name==='大串'\|\|m.role==='admin'\|\|m.type==='pm' で admin/pm なら案件問わず強制招待 / ④ p.pms 永続データに過去の「細川」文字列残骸 | 1ラリーで4経路全部根治: ①fallback削除 / ②DEFAULT_MEMBERS から細川削除 (大串のみ残す) / ③招待ロジックを「validPms (現在PM権限ある人だけ) + 案件アサインパートナー」のみに簡素化 / ④保存時+招待時に validPmNames でフィルタ二重防御 + [SLACK-INVITE]/[PMS-FILTER] トレース |
| A12 | v9.219→v9.220 | 架電リスト/リストプール で「表示エラー calls.filter is not a function」がたまに発生 | v9.219 で導入した chunked モードの本体キー sentinel "__CHUNKED__" が、BroadcastChannel / _d1PollTick / forceFetchCallListFromD1 / 古い localStorage 残骸 などの経路で _cache に文字列のまま混入 → 後段 .filter() で「filter is not a function」 | 8経路全部塞ぐ: ①D() に sentinel ガード + 混入時自動 cleanup / ②起動時 localStorage 強制掃除 (_autoCleanupOldLocalStorage で sentinel sb キーを起動最速で removeItem) / ③BC onmessage 防御 / ④_d1PollTick 防御 / ⑤forceFetchCallListFromD1 で sentinel 検知時は復元せず toast / ⑥_initChunkedJoin 失敗時に配列前提キーは [] フォールバック / ⑦renderCallList/_lpGetPool/filterCL に Array.isArray 保険ガード |
| D5 | v9.221→v9.223 | メンバー管理の画面 8名が D1 上で大串1名に縮小される事故 / リロードしても一瞬1名になってから8名に切り替わる | 3層複合: ① heartbeat (_sendHeartbeat) が /api/data/members を直接 fetch PUT していて S() 縮小防御を完全迂回 → D1 が事故で 1件になった状態で heartbeat 走ると永久ループで上書き / ② サーバー側 PUT 防御も「既存値との比較」だったため、既に 1件になった D1 → 1件 PUT は縮小判定発動せず通る / ③ _appBoot で showApp() を呼ぶ時点で _cache が空 のため初回 renderMembers が DEFAULT_MEMBERS (大串1名) で描画 → _initSupabase 完了後に 8名に切替 | 3層全部根治 (v9.221+v9.222+v9.223)*: ①S() に members 縮小防御 (3→1 / 半分以下 / 2件以上減 を block) + サーバー側 PUT も同条件で 403 / ②heartbeat の直接 fetch PUT を廃止し saveMembers (S()経由) に統一 + members.length<2 で skip + サーバー側「新値が 1件以下なら X-Allow-Shrink:1 必須」 / ③_appBoot 冒頭で localStorage の主要キー (members/projects/lastProject/list_pool_companies) を _cache に即時 hydrate (sentinel "__CHUNKED__" はスキップ) → 初回描画から正しい件数で表示 |
A12 / D5 から学ぶ普遍ルール
「新しい保存形式の sentinel 値が同期経路で混入する」「直接 fetch PUT が S() 防御を迂回する」:
// ✗ NG: sentinel 値を 通常の forEach で parse 経路に流すと文字列混入
json.data.forEach(r => { _cache[r.key] = JSON.parse(r.value); }); // → "__CHUNKED__" 文字列が _cache に
// ✓ OK: 同期経路 全部 に sentinel スキップを入れる
if(_isChunkedSubKey(r.key, r.value)) return; // BC / _d1PollTick / forceFetch 全部に必要
// ✗ NG: 直接 fetch PUT が防御を迂回する
fetch('/api/data/members', {method:'PUT', body: JSON.stringify(members)}); // S() 縮小防御を通らない
// ✓ OK: 必ず S()/PS() 経由で書き込む
S('members', members); // 縮小防御 + サーバー側防御 + BC 通知 全部を享受
判別ルール:
- 新しい保存形式 (sentinel / 圧縮 / 暗号化 等) を導入したら、全同期経路 (S/BC/_d1PollTick/forceFetch/_initSupabase) で 生値のまま _cache に書く経路がないか grep で全部潰す
- 直接 fetch('/api/data/...', PUT) するコードを grep で全部洗い、S()/PS() 経由に統一する
- サーバー側 PUT 防御は「既存値との比較」だけでなく「新値の絶対値で危険判定」(空配列・1件以下等) も入れる
- 起動時の初回描画は _initSupabase 完了前 に走るので、_appBoot 冒頭で localStorage を _cache にハイドレートする
D4 から学ぶ普遍ルール
「ハードコード値の強制復活」と「フィルタ条件のOR羅列」の組み合わせ は危険:
// ✗ NG: 3経路で「特定メンバーを強制復活」させているパターン
const fallbackNames=['大串','細川']; // ← UI に強制追加
DEFAULT_MEMBERS=[{name:'細川',role:'admin'}]; // ← データに強制追加
if(m.name==='大串'||m.role==='admin'||m.type==='pm'||pms.includes(m.name)){...} // ← 招待に強制追加
// ✓ OK: メンバー編集の現在値だけを単一の真実として扱う
const pmCandidates=members.filter(m=>m.role==='admin'||m.type==='pm');
if(validPms.includes(m.name) || m.projects.includes(p.id)){...}
判別ルール: ユーザーが「メンバー編集で◯◯権限を外したのに、別UIで◯◯が残る」と報告したら、「ハードコード強制追加」「OR条件で複数経路の混在」「過去データの残骸」の3点を同時に疑う。
D3 から学ぶ普遍ルール
DEFAULT_ / FALLBACK_ / SAMPLE_* など「初期値ハードコード」と「ユーザー編集データ」を merge する処理を書く時は、必ず:
// ✗ NG: ハードコードがユーザー編集を上書き
result = {...stored, ...DEFAULT_VALUES};
// ✓ OK: ユーザー編集を優先、ハードコードは不足分のみ補完
result = {...DEFAULT_VALUES, ...stored};
判別ルール: spread 順序の右側が優先される。ハードコード値は「初期化時の保険」「不足フィールドの補完」用途なので 左側 に配置する。
同種バグを発見する grep パターン
# DEFAULT_*.forEach + spread merge を探す
grep -rn "DEFAULT_.*\.forEach\|{\.\.\..*DEFAULT" --include="*.js"
# stored = {...stored, ...DEFAULT} 系のミス merge
grep -rn '{\.\.\.\w*\[idx\],\s*\.\.\.\w*}' --include="*.js"
A13 — Realtime UPDATE が chunked データを破壊する race condition (v10.4-rc3 緊急修正)
| No | Ver | 症状 | 真因 | 対策 |
|---|---|---|---|---|
| A13 | v10.2-beta1→v10.4-rc3 | chunked 案件 (RYS / 結グループ) でステータス変更 → 画面が「データなし / 0件」に。リロードで復活 | psv2-realtime.js の _applyCallListEvent が chunked sentinel __CHUNKED__ を考慮せず: PD() で空配列に正規化 → unshift で 1行追加 → _cache[key] と localStorage[key] を 1行配列で上書き → chunked 復元経路が動かず 0件表示 |
chunked sentinel 検知時に Realtime UPDATE 経路で _cache を一切上書きしない / v9 _d1PollTick / _initChunkedJoin / BroadcastChannel に同期を任せる (並走方式の本来意図) |
A13 から学ぶ普遍ルール
新しい同期経路 (Realtime / Push) を 既存の chunked / sentinel パターンと組み合わせる時の鉄則:
// ✗ NG: PD() でデフォルト [] に化けるが、_cache の sentinel は破壊される
var calls = PD("call_list", []); // sentinel → [] に正規化
calls.push(newRow); // [] → [1行]
_cache[key] = calls; // chunked sentinel を [1行] で上書き → データ破壊
// ✓ OK: 同期経路の冒頭で chunked sentinel を検知して skip
var rawCache = _cache[key];
var lsRaw = localStorage.getItem(key);
if (rawCache === "__CHUNKED__" || lsRaw === '"__CHUNKED__"') {
console.warn("chunked mode active → skip _cache overwrite");
return; // 別経路 (v9 polling / chunked join) が同期を担当
}
判別ルール:
- 新規同期経路 (Realtime / WebSocket / SSE / push) を実装する時は、必ず 既存 sentinel / chunked / encoding パターン を grep で全部洗う
- sentinel を見つけたら、その経路でも sentinel を尊重するガード を入れる
- PD() / D() 経由でデフォルト化される場合でも、_cache[key] の生値も並行チェック する
同種バグを発見する grep パターン
# 同期経路で _cache / localStorage に書き込んでる箇所を全部洗う
grep -rn "_cache\[.*\]\s*=\|localStorage\.setItem.*sb_" --include="*.js"
# chunked sentinel チェックなしの書き込みを検出
grep -rn "_cache\[.*\]\s*=\s*calls\|_cache\[.*\]\s*=\s*\[" --include="*.js"
動き方ガイド「根本解決優先ルール」の実機適用例
これは大串FB「その場しのぎ禁止・根本解決」(2026-05-15) を完璧に実践した事例:
- 真因を特定 (Realtime UPDATE の上書きロジック / chunked sentinel 無視)
- 症状だけ隠す対処 (例: if (calls.length<5) skip) を採用しない
- 構造的に正しい修正 (chunked sentinel を尊重するガード) を入れる
- 再発リスクゼロ
A14 — 【SCALE Base】計測中の作業セッションが「勝手に終了/消失」(pullが全置換でローカル未伝播分を消す) (v1.287)
| No | Ver | 症状 | 真因 | 対策 |
|---|---|---|---|---|
| A14 | scale-base v1.287 (2026-06-03) | 作業開始で計測中なのに、別画面でタスク追加等の操作をした後に作業中セッションが「勝手に終了」して消える。本番KVにも今日のセッションが0件=PUT前に消失 | WorkLogProvider の pullFromKV が nextSessions = snapshot ?? remoteSessions で KV を丸ごとローカルへ全置換。スナップショット(自分のPUT直後)は3秒で切れるため、3秒以降の pull(30秒ポーリング/focus/画面遷移)で KVに今朝セッションのPUTが未伝播/取得タイミングずれだと、ローカルの計測中セッションが KV(今朝分なし) で上書きされ消える。PUT時は read-merge済(ce-29-04)だが pull時に穴が残存 |
pull を「KV優先 read-merge」に: mergeKvPreferred(KV, local, tomb) = KVを基本採用 + KVに無いローカル項目を補完 + tombstone除外。①他端末更新はKV最新反映(PC↔スマホ同期維持) ②ローカルにしか無い未伝播の進行中セッションは消えない ③自端末削除は復活しない ④他userId分も温存 |
A14 から学ぶ普遍ルール(SCALE Base 同期の鉄則)
「pull (サーバ→ローカル) は絶対に全置換しない。必ず KV優先 read-merge」:
- ❌ NG: localStorage = remoteFromKV(KV伝播遅延中だとローカルの未送信データが消える)
- ✅ OK: localStorage = KV ∪ (KVに無いローカル項目, tombstone除外)(未伝播分を保護しつつ他端末更新も反映)
- スナップショット方式(PUT後N秒だけ権威)は PUT直後しか守らない → その窓を過ぎた pull に別の保護(read-merge)が必須
- データ消失系は「PUT側の堅牢化」だけでなく「pull側の非破壊性」も必ずセットで確認する
- 関連: tasks-api.tsx の TasksProvider も同種の全置換が無いか定期監査(同じ穴を持ちやすい)
- データロストゼロ (Supabase + D1 5 chunks に完全保存されていた)
A15 — 【SCALE Base】作業中に追加したタスクがリロード/タブ移動で消える(同一IDの中身が古いKVに巻き戻る)(v1.318)
| No | Ver | 症状 | 真因 | 対策 |
|---|---|---|---|---|
| A15 | scale-base v1.318 (2026-07-11) | 作業中画面でタスク追加(例「今井さん 返信」)→ 他タブ移動 or リロードで消える。KV実データにも残っていない=完全消失 | A14対策の mergeKvPreferred は「セッションの有無」は守るが「同一IDセッションの中身の新旧」は無条件KV優先だった。Cloudflare KV は結果整合(伝搬数秒〜60秒)のため、追加→PUT成功→リロード(保護snapshotはメモリなので必ず消滅)→初回pullが古いprogressを返す→ローカル上書きでタスク消滅→次の自端末PUT(ローカル優先マージ)が古い内容でKVを確定上書き=復元不能 | LWW(last-write-wins)化: ① WorkSession/AnyReport に updatedAt 追加・saveSessions/saveReports が「内容が変わった項目」に自動スタンプ(全書き込み経路網羅・無変更は時刻維持) ② pull側=同一IDは updatedAt が新しい方を採用(同点/欠損はKV優先=従来互換) ③ PUT側 mergeByIdExcludingTomb も LWW(同点/欠損はローカル優先=従来互換・古いローカルが他端末の新しい更新を潰す逆消失も防止) ④ snapshot窓 3秒→15秒(補助) ⑤ [WL-MERGE] 発動トレース。再現テスト11ケース全pass |
A15 から学ぶ普遍ルール(A14 の進化形)
- 「項目の有無のマージ」(A14)だけでは足りない。「同一IDの中身の新旧」も守る必要がある — 有無はread-mergeで守れても、中身が古いレプリカに巻き戻る穴は残る
- 結果整合ストア(Cloudflare KV等)に対する pull は「自分が直前に書いた内容より古い値が返る」前提で設計する。メモリ上の保護(snapshot/フラグ)はリロードで必ず消えるので、永続化された時刻(updatedAt)による LWW が唯一リロードを跨いで効く防御
- updatedAt は書き込みAPIの入口で自動スタンプ(呼び出し側に任せると漏れる)。無変更項目の時刻は進めない(pull書き戻しで時刻が進むと他端末の正しい更新に勝ってしまう)
- 同点/欠損時のデフォルトは従来挙動に合わせる(pull=KV優先・PUT=ローカル優先)と、旧データ互換と既存の同期特性を壊さない
- 防御の発動は console トレース([WL-MERGE]等)で観測可能にする — 「守られたのか・たまたまか」を再発調査で区別できる
A16 — 【SCALE Base】A15の当日に「タスクは残ったが推定時間が消えた」(項目単位LWWの構造限界→フィールド単位LWW共通基盤で根本解決)(v1.319)
| No | Ver | 症状 | 真因 | 対策 |
|---|---|---|---|---|
| A16 | scale-base v1.319 (2026-07-11) | A15対策直後の検証で「タスクは残ったけど、入れた推定時間が消えた」。大串FB「根本対策できてない。上書きし合う問題どっかでまた起きるよ」 | 二重の穴: ①推定時間=Task.estimateMinutes は tasks-api(別ストア)管轄で、A15のLWWは work-log 側だけだった。tasks-api は localStorage 正本を持たず pull は snapshot(メモリ15秒)切れ後「KV丸ごと採用」→リロード直後は古いKVが全てになり、1操作保存で古い内容を確定上書き ②そもそも項目単位LWWは「同一項目の別フィールドを別々に編集」すると必ず片方の編集が丸ごと消える構造限界(タスク追加 vs 推定時間入力のような併存編集を守れない) | 共通基盤 lib/lww.ts 新設・全6データ種(tasks/projects/定例/メンバー/sessions/reports)を統一: ① stampLww=save入口で「変わったフィールドだけ」fieldTs自動スタンプ ② mergeLwwLists=同一IDをフィールドごとに新しい方採用(異フィールド編集が相殺しない・同一フィールド同時編集のみ後勝ち) ③ tasks-api に localStorageミラー+tombstone新設(リロード直後も手元の時刻付き最新が古いKVに勝つ) ④ 実装直import(tsx)テスト23ケース全pass |
A16 から学ぶ普遍ルール(同期設計の到達点)
- 項目単位LWWの構造限界: 「同一項目の異なるフィールドを別タイミングで編集」は項目単位の新旧比較では必ず片方が消える。リスト全体ブロブ同期の限界を埋める最終形はフィールド単位LWW(変更フィールドだけ時刻を持ち、フィールドごとに勝敗判定)
- 「同種の穴は全ストアを一斉に塞ぐ」: A14の普遍ルールに「tasks-api も同種の全置換が無いか定期監査」と書いてあったのに、A15では work-log しか直さず当日に tasks-api で再発した。データ消失系の対策は共通基盤に括り出して全データ種へ同時適用する(1ストアずつ直すと「直した端から別ストアで再発」する)
- 過大主張バグに注意: フィールド時刻の欠損を updatedAt にフォールバックすると「推定時間だけ変えた項目」が変えていない status まで新時刻を主張し、他端末の正しい変更を潰す。fieldTs管理下でエントリが無いフィールドは最古(0)扱いにし、旧データ移行時は「未変更フィールドに旧updatedAtをシード」して情報を失わない
- テストは実装ファイルを直接 import して書く(tsx・ロジックコピーではなく本物)。今回テストが実装の過大主張バグを実際に検出→リリース前に修正できた
- 正本を localStorage に持たないストアはリロードで無防備: state のみのストア(tasks-api)は「pull結果とsaveのミラー」を localStorage に常時保存して初めてリロード跨ぎの LWW が機能する
- さらに上の根治はサーバー側の行単位化(D1移行・リスト全体ブロブ同期そのものの廃止)。クライアント側LWWはブロブ同期の限界内での最終形
A17 — 【SCALE Base】夜日報「今日やったタスク」に別日のタスクが混入(日付OR照合の誤爆)(v1.320)
| No | Ver | 症状 | 真因 | 対策 |
|---|---|---|---|---|
| A17 | scale-base v1.320 (2026-07-20) | 夜日報の「今日やったタスク」に7/13〜7/17の別日タスク(カレンダー由来MTG含む)が混入して表示。大串FB「おかしなの入ってる」 | 日付照合 matchesWorkDate が「date一致 OR 提出時刻(createdAt/startedAt)のJST日付一致」のOR判定だった。深夜跨ぎ・遅延提出では date(業務日=正確) と createdAt(提出時刻) の日付がズレるのが正常なのに、これを誤ヒット: ①7/16の作業を7/17朝に終了した end(date=7/16, createdAt=7/17) が 7/17 の夜日報集計に混入 ②7/17の夜日報を7/20朝に遅延提出したもの(createdAt=7/20)が「今日(7/20)の既存夜日報」として誤ヒット→画面に別日リストが表示。そのまま提出すると7/17分を今日の内容で上書きする所だった | date が正しい YYYY-MM-DD 形式のレコードは date を唯一の真実とし不一致=別日で確定。createdAt/startedAt フォールバックは date 欠損/不正形式のレガシー(旧UTCズレ救済の本来の対象)限定に。実装直import(tsx)テスト8ケース全pass |
A17 から学ぶ普遍ルール
- 「救済のためのORフォールバック」は、正データに対して誤ヒット経路になる。旧データ救済(date欠損/UTCズレ)の照合は「救済が必要なデータ(欠損/不正形式)」に限定し、正しい値を持つデータには適用しない(ガード条件を「レガシー判定」で絞る)
- 「業務日」と「操作日時」は別物。深夜跨ぎ終了・翌朝の遅延提出では両者の日付が異なるのが正常。日付照合は必ず「業務日フィールド」を正とし、操作時刻から日付を推測しない(ce-2026-06-01-01 の教訓の照合版)
- 既存レコード誤ヒットは「表示混入」で済まず「別日データの上書き」につながる(誤って既存扱い→提出で過去日の日報を今日の内容で上書き)。find系の照合バグは書き込み事故の前段として最優先で潰す
- 症状(画面の混入リスト)だけ見ずKV実データで「どの日のレコードに何が入っているか」を突き合わせると、複数経路の混入(集計時混入と表示時誤ヒット)を一度で特定できる
A18 — 【SCALE CRM】リスト依頼に過去依頼の中業界が混入(フォーム状態の一部だけJSグローバル保持→送信後も残留)(v11.6.15)
| No | Ver | 症状 | 真因 | 対策 |
|---|---|---|---|---|
| A18 | scale-lead v11.6.15 (2026-07-21) | チャネルワークス案件・山本さんのリスト依頼①②のSlack通知に、過去に依頼した業種(飲食・外食: 焼肉・ステーキ)が混入。②では①のIT: 受託開発まで累積。大串FB「過去依頼した業種が新しいものの中に入っちゃってるバグかも」→ 仮説どおり実バグ。※2通来たこと自体は別々の2依頼(セグメント山本①/②)で正常 | フォーム選択のうち中業界だけDOMチェックボックスでなく window._lreqSubSelected(JSグローバル)に保持。大業界・従業員等はDOM保持なので再描画で毎回まっさら(v9.184方針)になるが、グローバルだけ送信後・タブ切替後もページリロードまで残留。リセットは「全解除ボタン」とリロードのみ。しかも画面上は業種バッジ0表示・中業界パネル非表示で本人は残留に気づけず、送信時はチェック済み大業界と無関係にグローバル全量をSlack文面・保存データに載せていた |
三重根治: ① 描画時+送信成功後に _lreqSubSelected を必ずリセット(DOMとグローバルの「まっさら」を一致) ② 送信時ガード=チェック済み大業界の配下の中業界だけ採用(残留しても混入させない) ③ 選択の完全可視化=中業界を選ぶと親の大業界を自動チェック・大業界OFFで配下の中業界も同時クリア(見えない選択を残さない) |
A18 から学ぶ普遍ルール
- フォーム状態の保持場所を混在させない。一部だけJSグローバル/別ストアに持つと「DOMはまっさらなのに見えない状態が残る」事故になる。階層UI等でグローバル保持が必要なら、フォームのライフサイクル(描画/送信/クリア)に同期してリセットするまでがセット
- 送信データは「画面に見えている選択」だけから組み立てる(見えない内部状態を信用しない)。内部ストアを使う場合はチェック済み項目でフィルタする保険ガードを入れる
- 「毎回まっさら」系のFB対応(v9.184ドラフト復元廃止)をした時は、DOM以外の状態保持も棚卸しする。今回はドラフトだけ廃止してグローバル変数が取りこぼされ、1年近く潜伏した
- ユーザーの「過去の選択が残ってたかも」という仮説は選択状態の永続箇所を全部listして送信ペイロードと突き合わせると最速で裏取りできる(DOM/グローバル/localStorage/ドラフトの4箇所)
A19 — 【SCALE CRM】リストプールが0件表示(巨大prefix一括GETの応答限界→silent failで空表示)+同根で過去に7.3万件上書き消失 (v11.6.16)
| No | Ver | 症状 | 真因 | 対策 |
|---|---|---|---|---|
| A19 | scale-lead v11.6.16 (2026-07-22) | リストプールを開くと「プールにデータがありません(全0件)」。D1直接SELECTではデータ健在(36,412件・72チャンク・36.7MB・parse成功)=表示だけが死んでいた。さらに調査で、7/11にプール7.3万件がCSV取込分だけに上書き縮小していた実事故も発見(現プールは7月の4バッチのみ) | 遅延ロードが /api/data?prefix=list_pool_companies の一括GET依存。該当117行の合計56MB(現行36.7MB+旧世代残骸チャンク22MB)をサーバー(Workers/D1)が1応答で作れず失敗→クライアントはcatchで _cache=[] にして0件と無言表示(silent fail)。7/11はこの失敗状態(当時7.3万件≈75MB超で確実に失敗)のままCSV取込→_lpSavePoolが「見えている全量=取込分だけ」を保存し旧データを上書き。旧チャンクの余剰DELETE(fire-and-forget)も部分失敗し残骸43個が応答をさらに肥大させる悪循環 |
①ロードをチャンク個別GETに作り直し(_lpLoadPoolChunked: メタ→chunk_0..N-1を1本512KB・並列6・各3回リトライ→結合parse)=応答が常に小さく件数無制限に強い ②失敗時は[]にせず「⚠読み込み失敗+🔄再試行」画面(0件と偽らない+「この状態でCSV取込禁止」明記)。残課題=取込時の縮小ガード/残骸チャンクD1掃除 |
A19 その後 — 消失データの全量復旧に成功(2026-07-22・scale-lead v11.6.17)
- D1 Time Travel のブックマーク往復で旧プール73,300件を100%復旧: ①現在のbookmark取得 → ②
wrangler d1 time-travel restore --timestamp=<truncation直前>→ ③旧データを吸い出し(151チャンク/77MB・39秒) → ④restore --bookmark=<①>で現在へ復帰(露出窓49秒・書き込み消失ゼロ・事前/事後カウント完全一致を検証)→ ⑤per-rowテーブルへINSERT OR IGNOREマージ(重複はmatch_keyで物理排除) - 最終: リストプール105,815件(5月18,715/6月50,688/7月36,412・全件ユニーク・分配済みマーク18,921社バックフィル済み)。恒久バックアップ2本=
~/scale-lead-backups/list_pool_dump_2026-06-26_73300.json.gz/list_pool_dump_2026-07-22_36412.json.gz - 学び①: 巻き戻し先の選定は「メタの更新時刻」で必ず確認。7/11直前を狙ったら7/8時点で既に世代混在破損していた(1回目は空振り)。「最後に正常保存された時刻の直後」(今回=6/26 02:05の直後)を狙うのが正解
- 学び②: 吸い出しは「parse前に生バイトを保存」。1回目はparse失敗でデータを持ち帰れなかった。2回目は生チャンク保存→復帰→オフラインparseの順に変更
- 学び③: 復帰処理はfinallyでリトライ付きにする(1回目のbookmark復帰はwrangler一過性エラーで1度失敗→リトライで成功)。復帰用bookmarkは実行前に必ず控える
- 学び④: 旧データに同一ID・別会社が1組存在(同ミリ秒生成の衝突)→ OR IGNOREで1件silent skip。件数照合(期待値と1件でもズレたら追跡)で検出し新IDで追加。
Date.now()+乱数4桁のID生成は大量生成時に衝突しうる
A19 その後② — 大串FB「7月36,000件だけ?もっと入れた気が」→ 直感が正しかった(2026-07-22 追加復旧)
- 7月頭(7/1濵田・7/3喜多9,858・7/8左近20,617)の取込が7/11消失の前段でさらに失われていたことを特定。真の消失開始は7/8(32,489件への縮小保存・並行flush競合で世代混在破損)
- 7/10時点へ3回目のTime Travel往復(露出48秒・カウント一致検証)→ 破損blobから53,114オブジェクトをサルベージ → 重複排除で12,257件を追加復旧
- 最終: 118,072件(5月18,715 / 6月50,766 / 7月48,591・全件ユニーク)。バックアップ
list_pool_salvage_0710_53114.json.gz追加 - 復元不能と確定した唯一の領域: 7/9〜7/10のある取込の「保存が最後まで完了しなかった新規行」(D1に一度も書かれていない=ブラウザ内にしか存在しなかった分・サルベージで部分回収済み)
- 学び⑤: 「消えた件数の全貌」はユーザーの体感が最良のトレーサ。集計が合っていても「もっと入れた気がする」と言われたら、取込イベントの時系列を独立ソース(架電リストの取込メタ・保存痕跡のupdated_by)から再構成して突き合わせる
A19 から学ぶ普遍ルール
- 「全部まとめて1応答」は成長するデータでいつか必ず死ぬ。チャンク分割「保存」してもロードが一括GETなら意味がない。保存とロードは同じ粒度(チャンク単位)で設計する
- ロード失敗を空データと同じ見た目にしない。
catch → []は「0件」と「読めなかった」を混同させ、ユーザーに「データが消えた」と誤認させるだけでなく、空状態を起点にした保存=真のデータ消失事故(7/11の7.3万件)に直結する。読めなかったらエラーと再試行を出し、書き込み系操作を止める - fire-and-forget の cleanup DELETE は部分失敗して残骸が溜まる。残骸は今回のように別経路(応答サイズ)へ跳ね返る。cleanup も成否を確認するか、定期棚卸しを設ける
- 「データ消えた?」の一次調査は D1 直接SELECT が最速最強(
wrangler d1 execute --remote)。画面・API を経由せず正本を見れば「消えた」のか「見えないだけ」なのか一発で確定する(今回: chunk世代・欠番・メタ整合・結合parse・取込バッジ分布まで全部D1実測で確定)
A20 — 【SCALE CRM】「勝手に最上部に戻る」「2タブ運用が勝手にページ同期される」(ポーリングが起動専用関数showAppを呼んでいた)(v11.6.29)
| No | Ver | 症状 | 真因 | 対策 |
|---|---|---|---|---|
| A20 | scale-lead v11.6.29 (2026-07-27) | メンバー苦情2件: ①架電リスト閲覧中に勝手に最上部へ戻される(1タブでも発生) ②リストとスクリプトを2タブで開くと、片方でページを変えるともう片方も同じページへ勝手に切り替わる | 30秒ポーリングの変更検知(onRemoteChange/forceD1Sync)が members/projects キーの変化のたびに showApp() を呼んでいた(v9.130由来)。showApp は起動専用関数で、lastPage<ユーザー>(タブ間・端末間で共有される「最後に開いたページ」)から currentPage を上書きして全再描画する。→ 誰かがメンバー/案件設定を保存するたび全タブが「自分が最後に開いたページ」へ強制遷移(=2タブ同期に見える)+架電リスト滞在中は大規模リストのチャンク再描画クランプで最上部ワープ。7月中旬からメンバー追加・設定操作が頻発し顕在化 | ①ポーリングからの showApp 呼出を全廃=バッジ/セレクタ/サイドバーの部分更新+該当情報を表示中のページだけスクロール保持つき再描画(currentPage/currentProjectId不可侵) ②showApp 側も lastPage/lastProject 復元を「真の起動時だけ」に制限(_appBootedフラグ・ログアウトでリセット)の二重ガード ③スクロール復元の追い当てを1.6秒まで延長 |
A20 から学ぶ普遍ルール
- 「起動専用関数」を定期処理から呼ばない。boot関数は共有ストアからの状態復元(ページ/案件/ユーザー)を含むことが多く、セッション中に呼ぶと「ユーザーが今見ているもの」を勝手に上書きする。リフレッシュ用途には部分再描画関数を使う
- 「最後に開いたページ」のような共有状態は起動時にしか適用しない。タブ間で共有されるストア(localStorage/BC/D1)に入っている以上、セッション中に適用すると多タブ運用が壊れる。適用ガード(bootフラグ)を関数側に置くと呼出元の増減に強い
- 「勝手に◯◯される」系の苦情は、ユーザー操作外のトリガー(ポーリング/BC/タイマー/visibilitychange)を全部並べて、その先で currentPage/currentProjectId/scroll を触る経路を逆引きすると最短で当たる。今回も renderCallList 直呼び20+箇所は全て無罪で、犯人はポーリング→showApp の1本だった
E. パフォーマンス・固まり系
| No | Ver | 症状 | 真因 | 対策 |
|---|---|---|---|---|
| E1 | v11.5.78 (2026-06-11) | 架電メンバー「CRMの反応が悪く、作業中固まることが多い」(大規模案件で定期フリーズ・入力直後のもっさり) | per-row同期層の4複合(実測: 最大案件4,931行/4.7MBで顕在化): ① clPollPerRow の60秒 full reconcile(since破棄全件再取得・v11.5.57保険①)が無変化行も全行 touched → 約5,000行×全セルの innerHTML 再構築+reflow連鎖が毎分発生(主犯) ② reconcile diffPush が pend空でも毎8秒 snapMap=全行 JSON.stringify(約5MB分) ③ PS('call_list') の BC postMessage が編集毎に配列全体4.7MBを同期 structured clone ④ full reconcile のDLが毎時約280MB=弱回線メンバーの帯域圧迫 | 1リリースで4つ全部根治(core.js makePerRowLayer/PS): ① pollPerRow に無変化行スキップ2層(生JSON文字列がprevと一致→parse自体省略 / roundtrip一致→置換・touched除外。全14 per-rowキーに波及・touched空なら onTouch も呼ばない) ② reconcile は pend がある時だけ ③ _bcPostIdle 新設(requestIdleCallback でアイドル送信・timeout500ms) ④ full reconcile 60→120秒(窓落ち主対策は保険②since10秒オーバーラップなので保証十分)。観測= PerformanceObserver(longtask) 常時計測 window._perfStats(200ms超カウント・500ms超 [PERF-LT] warn)+ [CL-FULL] rows/touched/ms ログ(Slack自動送信なし=ENV-FP撤去経緯遵守) |
| E2 | v11.5.79 (2026-06-12) | メンバー「CRMの架電リストが重いのか開けない状態」(スピナー/空画面のまま・リロード契機なし) | セッション一斉失効の401サイレント失敗: サーバ認証P2 enforce化(5月末)のセッションTTL14日 → 6/11-12にメンバーのセッションが一斉失効。起動時(/me 401→ログイン画面)は対応済みだが「タブ使用中の失効」はデータAPI全経路(loadRows/pollPerRow/_d1PollTick)が if(!res.ok)return で完全沈黙。さらに clLoadRows 3回リトライ失敗後に renderCallList を呼び戻す実装が、ロードゲート再突入→スピナー→再失敗…の無限スピナーループを形成(常時401なので永遠に終わらない) | ① _authExpiredNotice 新設: データAPI 3経路で401/403検知→再ログインバナー(60秒スロットル) ② 失敗確定時は renderCallList を呼び戻さず失敗UI(再試行/再ログインボタン)でループ遮断 ③ SESSION_TTL_DAYS 14→30日 ④ [LOAD]計測ログ(rows/ms/status)。ユーザー側の即時復旧=リロード→再ログイン |
E2 から学ぶ普遍ルール(認証failの「沈黙」が「開けない」を作る)
if(!res.ok)returnは一見安全だが、401/403(認証失効)では「永遠に成功しない」ため、リトライ設計と組み合わさると無限ループ/永久スピナーになる。fetch失敗ハンドリングは必ず「一時障害(5xx/ネット断=リトライ有効)」と「認証失効(401/403=リトライ無意味・ユーザー操作が必要)」を区別する- 認証は「起動時チェック」だけでは不十分。SPAでタブを数日開きっぱなしにする運用では「使用中の失効」が必ず起きる。データAPIの401/403検知→再ログイン誘導UIをセットで実装する
- TTL系の設定を入れた日をメモせよ: 「enforce化した日+TTL日数」=一斉失効日。その日に「開けない」系の問い合わせが来る。TTL変更時は次の一斉失効日を予測しておく
- 失敗時のUIは「スピナー継続」ではなく「失敗した事実+原因候補+次のアクション(ボタン)」を出す。スピナー放置=ユーザーには「重い/壊れた/開けない」と映る
- 後日談(v11.5.81・根本解決①): 「TTL=退職者遮断の安全弁」という構造自体を解消 — セッション検証に members 名簿照合(sessionMemberExists)を追加し「メンバー削除=全データAPIで即401 MEMBER_REVOKED」を実現。TTLの役割はcookie盗難対策のみになり180日に延長。ステートレストークンを使う時は「名簿/ユーザーテーブル照合」をセットで設計すれば、TTLを短くする理由がなくなる
- 後日談(v11.5.82・根本解決②=これが本丸): 6/17に同じ「開けない」が再発。本番で人工署名トークン3経路を実測し正体を 401 DATA_AUTH_DENIED(期限切れ) と確定(名簿照合バグでも回線でもない)。真因=cookieが「発行から固定TTLで絶対失効」する設計で、毎日使っても発行日からの経過で必ず切れる。v81で180日に延ばしても「既存の短命cookieを持つ人」は順次失効し続けるため、延長だけでは止まらなかった。真の根治=スライディングセッション(
slideSession/_middleware.ts + me.ts): 有効+名簿実在のデータAPIアクセス時、残り寿命<TTL/2なら新cookieを自動再発行 → アクティブな人は使い続ける限り永久に切れない。「TTLを延ばす」は対症療法、「アクセスのたびに延びる」が根治。判別ルール: 固定TTLのセッションは"いつか必ず全員が踏む時限爆弾"。スライディング(sliding expiration)+失効時の名簿/DB照合 をセットにするのが定石
# 認証失効で無限ループする箇所を洗う grep
grep -rn "if(!res.ok)return" --include="*.js" | grep -v "401\|403\|_authExpired"
# リトライ実装が401/403を区別しているか
grep -rn "attempt<3\|retry" --include="*.js"
E1 から学ぶ普遍ルール(定期同期 × 全行処理 × DOM反映 の三重積は行数に比例して固まる)
「定期 pull 同期」を実装する時は「無変化なら no-op」が大前提:
// ✗ NG: 取得行を無条件に取り込み+DOM反映 → 行数に比例して毎回固まる
for(var r=0;r<j.rows.length;r++){
var o=JSON.parse(j.rows[r].data); // 全行 parse
arr[idx[o.id]]=o; prev[o.id]=JSON.stringify(o); // 全行 stringify
touched.push(o); // 全行 DOM patch 行き
}
// ✓ OK: 変化していない行は parse すらしない(2層スキップ)
if(prev[rid]===j.rows[r].data && idx.hasOwnProperty(rid))continue; // ①生文字列一致
var o=JSON.parse(j.rows[r].data);
var s=JSON.stringify(o);
if(prev[o.id]===s && idx.hasOwnProperty(o.id))continue; // ②roundtrip一致
判別ルール:
- 「全件再取得の保険」(full reconcile 等)は必ず「無変化スキップ」とセットで設計する。保険の周期 × 全行コストがそのまま定期フリーズになる
- postMessage / structuredClone は大きいデータで同期ブロッキングする → requestIdleCallback でアイドル時にデファー(別タブ同期は poll でも収束するので実害なし)
- 毎tickの「保険的全量処理」(reconcile の snapMap 等)は「必要条件(pend有り等)がある時だけ」に絞る
- longtask observer(200ms超)を常設して「固まり」を定量で語れる状態にしておく。Slack自動送信はしない(v11.5.56 ENV-FP撤去の経緯 — 観測は console + window 変数まで)
- 「反応が悪い」系FBは CPU だけでなく帯域も疑う(定期大容量DLが保存PUTと帯域を取り合う)
同種ボトルネックを発見する grep パターン
# 定期実行(poll/interval)から全行ループ+DOM更新へ到達する経路を洗う
grep -rn "setInterval\|PollTick\|pollPerRow" --include="*.js" | grep -v "^\s*//"
# postMessage に大配列を渡している箇所(同期cloneコスト)
grep -rn "postMessage({key" --include="*.js"
# 全件再取得(since破棄)系の保険ロジック
grep -rn "since=''\|since = ''" --include="*.js"
パターン: 本番サイトが「403拒否」に見える→実はブラウザローカルの事象(2026-07-17 TERASU HP)
- 症状: Chromeで本番URL(terasu.scale-group.co.jp)を開くと「アクセスが拒否されました HTTP ERROR 403」。サイト障害に見える。
- 切り分け(この順で機械的に・5分で完了):
1.curl -sI https://本番URL/→ 200ならサーバーはシロ(この時点でデプロイ/Cloudflare/コードの調査は不要)
2. pages.dev直・preview・ChromeのUA付きcurl・DNS(dig)も確認(全部200/正常IPなら完全にシロ)
3. 本人にシークレットウィンドウで開いてもらう → 開けたら犯人はキャッシュか拡張機能
4.⌘+Shift+Delete→「キャッシュされた画像とファイル」削除(過去24時間)で解決 - 真因: 過去に一瞬403だったレスポンス(デプロイ切替瞬間など)がChromeのディスクキャッシュに残り表示され続けた。CFブロックページでなくChromeのローカルエラーページなのが見分けポイント。
- 教訓: 「本番が落ちた」報告でも、いきなりデプロイ履歴やCF設定を疑わず、まずcurlで実応答を確認(ブラウザ画面はサーバー状態の証拠にならない)。
パターン1: 書き込みトレーサ(データ消失追跡用)
function _logXxxWrite(source,k,oldVal,newVal){
if(!/_yyy$/.test(k))return; // 対象キーのみ
try{
var oldCount=Array.isArray(oldVal)?oldVal.length:(oldVal==null?'null':'non-array');
var newCount=Array.isArray(newVal)?newVal.length:(newVal==null?'null':'non-array');
var msg='[XXX-WRITE] '+source+' | '+k+' | '+oldCount+' → '+newCount+' | t='+new Date().toISOString().slice(11,23);
// 縮小なら error + スタックトレース
if(typeof oldCount==='number' && typeof newCount==='number' && newCount<oldCount){
console.error(msg);
console.error(new Error('shrink stack').stack);
}else{
console.log(msg);
}
}catch(_e){}
}
仕込む経路(漏れなく):
- 直接書き込み: S() / localStorage.setItem / _cache[k]=v
- 同期取得: _initSupabase forEach / _d1PollTick forEach
- 通信: BroadcastChannel onmessage / forceFetch の各経路
- 復元: restoreSnapshot / undo / redo
パターン2: safe setItem ラッパー(容量超過対策)
function _safeSetItem(k,valStr){
try{
localStorage.setItem(k,valStr);
return true;
}catch(e){
if(e&&e.name==='QuotaExceededError'){
// 古い不要キーを優先順位で削除
var prefixes=['sb_shadow_','sb__snap_data_'];
for(var i=localStorage.length-1;i>=0;i--){
var lk=localStorage.key(i);
if(prefixes.some(p=>lk.indexOf(p)===0)){
try{localStorage.removeItem(lk)}catch(_){}
}
}
try{localStorage.setItem(k,valStr);return true}catch(_){}
// 失敗時はメモリのみで継続
return false;
}
}
}
パターン3: API失敗時のリトライ機構
async function _putWithRetry(k,v,attempt){
try{
var res=await fetch(API+'/data/'+k,{method:'PUT',body:JSON.stringify(v)});
if(res.ok)return;
if(res.status===403)return; // 防御発動はリトライ不要
throw new Error('status='+res.status);
}catch(e){
var delays=[500,1500,4000];
if(attempt<delays.length){
setTimeout(()=>_putWithRetry(k,v,attempt+1),delays[attempt]);
}else{
console.error('FINAL FAILURE',k);
toast('⚠ サーバー同期失敗。リロード推奨');
}
}
}
パターン4: 配列フィールドの型ガード
const _ARRAY_FIELDS=['benefitList','advantages',/* ... */];
function _normalize(obj){
_ARRAY_FIELDS.forEach(k=>{
if(obj[k]!==undefined && !Array.isArray(obj[k])){
obj[k] = (typeof obj[k]==='string' && obj[k].trim()) ? [obj[k]] : [];
}
});
return obj;
}
U1〜U2 UIシェル系(2026-07-21 SCALE FORM で実発生)
U1. 「器(index.html)」と「描画(app.js)」の不整合 — 必須関数チェックをすり抜ける
症状: デプロイは全部成功し、必須ファイル・必須関数チェックも通り、本番も HTTP 200。
なのにログイン後の画面が動かない(真っ白/ボタンが効かない)。
真因: JS 側だけを新UIに書き換えてデプロイし、index.html の器が旧構造のままだった。
JS が getElementById('sidebarNav') 等で取るDOM IDが1つも存在しない。
deploy.sh の退化検知は「必須ファイルがあるか」「必須関数が app.js にあるか」しか見ないため素通りする。
対策(機械チェック): デプロイ前に「JSが参照するID が index.html に実在するか」を必ず突き合わせる。
# app.js が getElementById で参照する全IDを抽出し、index.html に存在するか検査
grep -oE "getElementById\('[a-zA-Z0-9_-]+'\)" app.js | sed "s/getElementById('//;s/')//" | sort -u | \
while read id; do grep -q "id=\"$id\"" index.html || echo "NG: #$id が index.html に無い"; done
恒久ルール: UI改修は index.html と app.js を必ずセットで更新・セットで検証する。
U2. モーダル/オーバーレイが「開いているのに見えない」
症状: openModal() を呼ぶと display:flex になっているのに画面に何も出ない。クリックも効かない。
真因: SCALE CRM の style.css では
.modal-overlay{display:none;opacity:0;pointer-events:none} + .modal-overlay.show{display:flex;opacity:1;pointer-events:auto}
という.show クラス方式。JS で style.display='flex' だけ変えても opacity:0 / pointer-events:none のままなので、
「開いている扱いなのに透明・クリック不可」という気づきにくい壊れ方をする。.mobile-overlay も同じ。
対策: 開閉は必ず classList.add('show') / classList.remove('show')(CRM の toggleSidebar() と同じ実装に揃える)。
関連: CRMのUIを他システムに写経するときの原則
- style.css だけコピーしても揃わない。CRM の CSS は子孫セレクタ(
.sidebar-footer .avatar、
.sidebar-header .logo-row等)が多く、親のHTML構造が違うとスタイルが一切当たらない。
骨格ごと写経すること。 - 新しいクラスを勝手に作らない(
.field.toolbar.input.status-badge等を新造すると無スタイルになる)。
CRM の正式クラス=.form-row.form-grid.filter-row.search-box.page-actions
.badge+.badge-{色}.stats-grid>.stat-card.content.page-header>.page-title-area。
D1〜D2 デプロイ系(2026-07-22 SCALE FORM で実発生・全システム共通)
D1. cmd | tail でデプロイ失敗が握りつぶされる(CIが緑なのに本番未反映)
症状: GitHub Actions が 成功(緑) で終わっているのに、本番が古いまま。ログにもエラーが見えない。
真因: npx wrangler pages deploy . | tail -3 のようにパイプすると、パイプ全体の終了コードは最後のコマンド(tail)のものになる。
wrangler が失敗しても tail は成功するので、set -e があっても止まらず、後続の「デプロイ完了」echo まで走る。
さらに tail -3 でエラー本文が切り捨てられ、原因すら追えない。
対策:
set -e
set -o pipefail # ← これが無いとパイプ内の失敗を検知できない
npx wrangler pages deploy . --project-name=xxx --branch=main # 出力は切り詰めない
恒久ルール: デプロイ・ビルド系のコマンド出力を | tail | head で切り詰めない。
(_SCALE_全システム実装ルール の「silent fail させない」はフロントだけでなくデプロイスクリプトにも適用する)
D2. 本番検証が「バージョンマーカーの有無」だけだと退化を見逃す
症状: デプロイ後チェックは通っているのに、実は前の版が配信され続けている。
真因: index.html に XXX-VER: があるかどうかしか見ていないと、古い版にもマーカーはあるので必ず通ってしまう。
対策: 「今回生成したキャッシュバスター ?v= が本番に出ているか」まで照合する。
さらに Cloudflare Pages の反映は十数秒かかることがあるので、固定 sleep ではなくポーリングする。
for i in $(seq 1 18); do
sleep 5
curl -sS "${PROD_URL}/?nocache=$(date +%s)" -o "$TMP"
grep -q "?v=${CACHE_VER}" "$TMP" && { REFLECTED=1; break; }
done
[ "$REFLECTED" = "1" ] || { echo "FATAL: 本番未反映"; exit 1; }
関連: CI で Pages Functions のビルドが落ちる
Functions が npm パッケージ(例 @cloudflare/puppeteer)を import している場合、
CI に npm ci が無いと bundle のビルドで落ちる。ローカルは node_modules があるので気づけない。
U3. UI刷新で「画面に映らない機能」が静かに消える(2026-07-22 SCALE FORM)
症状: 新UIは綺麗に動いているが、仮想スクロール・差分同期・定期ポーリングが消えていた。
見た目は正常なので誰も気づかない。数千件を入れた瞬間に固まる/他端末やCron の更新が画面に出ない、として後で顕在化する。
真因: UIを作り替える時、見えている要素は移植するが、非機能要件(性能・同期)は忘れられる。
対策(機械的に検出する):
git show <刷新前commit>:app.js > /tmp/old.js
for k in digest since setInterval 仮想スクロール _pollTimer; do
echo "$k 旧:$(grep -c "$k" /tmp/old.js) 新:$(grep -c "$k" app.js)"
done
旧>0・新=0 のものが消えた機能。UI刷新のたびにこの差分を取る。
U4. str.includes('') は常に true
「値が空ならスキップ判定に使わない」つもりの !body.includes(x || '') が、
x が空のとき 常に「含まれている」判定になり、意図と逆の分岐に落ちる。
→ const has = !!x && body.includes(x) の形にする。
SCALE FORM では、これで送信元情報とオプトアウト文が本文に一切挿入されない状態になっていた(法務要件違反)。
U5. grep が空振りしたら NULバイト混入を疑う
コードが確かに存在するのに grep が何も返さない場合、ファイルに NUL(\0) が混入していると
grep がバイナリ扱いして出力を抑制する。file が "data" と判定したら黒。
file -b path/to/file # "data" なら怪しい
tr -dc '\000' < path/to/file | wc -c # NULバイト数
SCALE FORM では sender.company || '\0' という壊れたリテラルが1個混入し、
コード調査が丸ごと空振りしていた(実害としても分岐が意図と変わっていた)。
T1 日付・時刻系(2026-08-18 SCALE CRM で実発生・全システム共通)
T1: 通知の時刻が12時間ずれる/記録日付が前日になる(端末のタイムゾーンが日本以外)
- 症状: 10:50 に届いた Slack「作業中断」の見出しが「22:50」。同じ端末の架電5行が前日の日付で保存・稼働時間も前日の日報に入っていた
- 切り分け: D1 の timer_state の sessions(端末
Date.now()=epoch)とupdated_at(サーバー時刻)を比較 → 一致=時計は正しい。dateだけ前日=タイムゾーンのズレ(実測 UTC−3) - 真因:
today()=_ymdLocal(new Date())が端末ローカル日付、Slack の時刻もgetHours()で端末ローカル。業務TZ(JST)固定になっていなかった - 根治(v11.6.101):
_ymdLocal/today()/_hmJstを JST 固定(getTime()+9hを UTC 分解=TZ非依存)。再架電クイックボタン・一括ステータスの日付・Slack 文面時刻も同経路へ。端末TZ≠JST なら起動時に警告バナー+error_log - データ修復: 該当行を JSON バックアップ後、
json_setで日付付け替え(~/scale-lead-backups/mizuno_tz_fix_2026-08-18.json) - 横展開: 「今日」を端末ローカルで決めている画面/通知/Worker は同じ事故が起きる。TERASU CRM・SCALE Base 等でも
new Date().toISOString().slice(0,10)(UTC日付)やgetDate()(端末ローカル)で日付を保存している箇所は JST 固定に
開発時の必読チェックリスト
コード変更前
- [ ] CLAUDE.md (グローバル) を Read
- [ ]
_SCALE_全システム実装ルール.mdを Read - [ ] 該当システムの
_SCALE_XXX_最新基準点.mdを Read - [ ] このトラブルシューティング集を Read
コード変更中
- [ ] Read → Edit(Write全体書き換え禁止)
- [ ] 既存関数の削除前に grep で参照確認
- [ ] データ型を変える時は
_normalize系の防御も同時に入れる - [ ] localStorage.setItem は
_safeSetItem経由 - [ ] PUT は
_putWithRetry経由
デプロイ前
- [ ]
bash scripts/check_deployment.sh実行 - [ ] 構文チェック (
node -c) - [ ] tar.gz バックアップ
- [ ] バージョン番号を UI に表示できているか
- [ ] UI改修時: JSが参照するDOM IDが index.html に実在するか機械チェック(U1)
- [ ] UI改修時: モーダル/オーバーレイの開閉が
.showクラス方式になっているか(U2) - [ ] deployスクリプトに
set -o pipefailがあるか・出力を| tailで切っていないか(D1) - [ ] 本番検証が「今回の
?v=が本番に出ているか」まで見ているか(D2) - [ ] UI刷新時: 旧版と新版で digest/since/setInterval/仮想スクロール の出現数を機械比較したか(U3)
- [ ]
includes('')が常にtrueになる分岐を書いていないか(U4)
問題発生時
- [ ] このノートを先に Read(過去パターンに該当しないか確認)
- [ ] 該当しなければ トレーサを仕込んでログ収集
- [ ] ユーザーからコンソールログをもらう
- [ ] 真因特定後、このノートに 新パターンとして追記
更新ルール
このノートは 生きるドキュメント。新しいエラー要因が発生したら必ず追記:
- 「過去エラー要因リスト」の該当カテゴリに 1行追加(No / Ver / 症状 / 真因 / 対策)
- 「真因特定パターン」に新しい再利用可能パターンがあれば追加
- 「Step 1〜6」のチェックリストに不足があれば追加