💻 システム開発

SCALE_TERASU_CRM_データ安全性監査と改修プラン

最終更新 2026年06月25日 / 31_システム開発部/_SCALE_TERASU_CRM_データ安全性監査と改修プラン.md

TERASU CRM データ安全性 監査 + 改修プラン

背景: 大串FB「チームで本格運用する前に、同時編集でデータが消えないか確認・しっかり作っておきたい。SCALE CRMの時は後から問題が起きて有料Supabaseに移行した」。
補足FB: 「同じリストを一気に2人が触ることはなさそう。でも同じシートの別箇所を同時に触ることはあるかも」。←この懸念がまさに本丸(下記参照)。

結論(先出し)

  1. 「データが丸ごと消える」リスクは低い。 SCALE CRMの教訓が入った保護が多重にある(D1永続DB・書込中ガード・入力中ガード・件数激減スナップショット・論理削除+30日ゴミ箱)。
  2. ただし「同じシートを複数人が同時編集(別の行でも)」した場合、後の保存が前を丸ごと上書きして片方の更新が消える構造的リスクが残る。 ← 大串の懸念「同じシートの別箇所を同時に」はまさにここに該当。今の作り(リスト丸ごと1かたまり保存)では、別の行を触っても衝突する。
  3. 有料Supabase移行は不要。 TERASU CRMは既に Cloudflare D1(ちゃんとしたSQL DB) を使っている。データの持ち方を「リスト丸ごと1blob」→「項目ごとにマージ保存」へ変えれば、別行の同時編集が安全になる(=SupabaseでやったことをD1で無料実現)。

実装状況(2026-06-16 完了・本番稼働中)

大串FB「短期視点抜きで、消える可能性を限りなくゼロにする最高品質の方法を時間かけてでも実装」を受け、楽観的並行制御(rev版数のcompare-and-swap)+ クライアント側 3-way item-merge をデータ層に汎用実装し本番反映済(TERASU CRM v=20260616q)。

当初プランの「Phase 1: 項目ごとマージ用エンドポイント追加(per-list改修)」より上位の方式を採用:データ層(S/_syncToD1/poll)に1箇所だけ実装=全キーを自動保護・per-list改修ゼロ・退化リスク最小

仕組み

  1. サーバー: app_datarev 列追加(ALTER実行済)。PUT は X-Base-Rev ヘッダで「読み込んだrevと一致時のみ更新(rev+1)」。不一致=他者が先に更新 → 409 + 現値/rev を返す。X-Base-Rev 無し(旧クライアント)は従来upsert=後方互換。
  2. クライアント: _rev[k](サーバー確定rev)/ _base[k](マージ基準=サーバー確定値)を load/poll で同期。保存は CAS。409時はサーバー最新へ「自分が触った差分(base比)だけ」を3-wayマージして再送(最大6回・上限時はレガシーPUTで確実永続化)。
  3. 書込中キーは poll が base を凍結 → 編集中の差分が守られる。

効果(lost-update を構造的に解消)

  • 別の行を同時編集 → 両方残る/他者の追加を消さない/削除と編集の競合も解決/同一itemを同時編集した時だけ後勝ち(=実用上十分)。
  • 既存の安全網(件数激減スナップショット・論理削除30日ゴミ箱・入力中/書込中ガード)は維持。

検証

  • 3-wayマージ: node 9シナリオ全pass(別item編集の両立/他者追加保護/削除競合/オブジェクトのフィールドマージ)。
  • サーバーCAS: prod D1 直接検証(正しいrev→changes:1成功/古いrev→changes:0で競合検知)。
  • Worker compiled+本番200+配信core.jsにマージ関数含有を確認。

追加: データ消失の根本対策4点(2026-06-16・大串FB「他この問題も根本から全部対処」)

同時編集(lost-update)以外の消失経路も耐久層で根治(core.js)。node 13シナリオ検証済。

# 消失経路(根本原因) 根本対策
編集直後にタブを閉じる/遷移 → 300msデバウンス保存が未発火でD1未達 離脱時フラッシュ: pagehide/可視性変化(hidden)で保留書込を keepalive 即送信
ネット瞬断/オフライン/セッション切れ/一時的5xx → 書込がD1に届かず黙って消える 永続アウトボックス+自動リトライ: 確定まで localStorage の te__outbox に保持し、online復帰/起動時/8秒毎に自動再送(確定で除去)
起動ロードの「件数が多い方を採用」が正当な削除を巻き戻す rev基準の権威判定: localStorageが多くてもD1 revが新しく(別端末確定)&未同期ローカル変更が無ければD1採用(+snapshot)。revで断定不能時のみ従来の安全側(消さない)。初回ロードは従来通り
チャンク対象5キー以外が1MB超 → 413でD1だけ未同期 → 端末切替で消える サイズ超過の汎用自動chunk化: 名前に関わらず800KB超の配列を自動分割(+413受信時のバックストップ)
  • _rev を localStorage に永続化(te_<key>__rev)し、③の権威判定に使用。
  • 既存の安全網(件数激減スナップショット・論理削除30日ゴミ箱・入力中/書込中ガード)は全維持。安全側(データを消さない方向)にバイアス。

残課題

  • Phase 2(行レベルテーブル化)は当面不要。楽観ロックで lost-update は構造的に解消されたため、実運用で問題が顕在化した場合のみ検討。
  • client portal(社外クライアント用 /api/client/data)は単一案件スコープ・低競合のためレガシーPUT継続(必要ならCAS化)。
  • ライブ環境での複数人同時編集の実地テスト(推奨:2ブラウザで同シートの別行を同時編集→両方残ることを確認)。

現状アーキテクチャ(改修前)

  • 保存先: Cloudflare D1(永続SQL DB)の app_data テーブル(列 = key, value)。
  • データの持ち方 = キー単位の丸ごとJSON blob。例: 商談リスト全体が app_data['deals'] に1つのJSONとして入っている。
  • 保存フロー: ブラウザ localStorage 先行書込 → 300msデバウンス → PUT /api/data/<key>value全体を上書き = last-write-wins)。
  • 取込: 8秒ごとに増分ポーリング(?since=)→ 変化したキーだけ localStorage 更新 → 再描画。
  • 既存の保護(良くできている):
  • 書込中ガード(_pendingKeys): 自分が保存中のキーはポーリングで上書きしない
  • 入力中ガード: カーソルが input/textarea/select にある間は再描画を見送る(入力が消えない)
  • 件数激減スナップショット: 配列が半減検知で自動退避(SCALE CRMの「CSV7000件→リロード→消失」バグの根本対策)
  • 論理削除 + 30日ゴミ箱(softDelete): 削除は復元可能
  • クライアントPUTもサーバーも GitHub にコード履歴あり(コードのロールバックは可能)

なぜ「同じシートの別箇所」でも危ないか(大串の懸念への直接回答)

データが「リスト丸ごと1かたまり」なので、別の行を触っても同じ blob を奪い合う:

A が商談#5 を編集 ─→ deals リスト"全体"を保存(PUT)
B が商談#50 を編集 ─→ deals リスト"全体"を保存(PUT)
   ↑ B の手元の deals に(まだ8秒ポーリングで取り込んでなければ)A の#5編集が無い
   → B が全体を上書き → A の#5編集が消える(lost update)
  • 競合の窓 ≒ 8秒(ポーリング間隔)+ 保存デバウンス。狭いが実在する。
  • 緩和されているのは: 8秒以内に相手の変更を取り込めれば、自分の保存にも相手の編集が含まれる(消えない)/入力中・保存中は守られる。
  • つまり「常に消える」ではなく「8秒の窓に、同じシートを2人が保存し合った時だけ」片方が負ける。頻度は運用次第だが、FS複数・マーケ複数が同じリストを日常的に触る運用では現実的に起こりうる。

データ別 リスク評価

データキー 用途 編集者 規模 同時編集リスク
deals 商談管理 FS複数
job_applications 案件応募リスト マーケ複数
linkedin_approved / linkedin_list_pool / linkedin_requests LinkedIn運用 マーケ複数
tk_tasks タスク一覧 全員
projects 案件一覧/受注案件シート PM/制作/自動生成 中〜高
apo_manual / appointments(apt_meta) アポ獲得リスト マーケ/FS
proj_<id>_*(sheet / kickoff / hearing 等) 案件ごとシート PM/制作 中(同じ案件を2人で)
revisions / reviews / questions / assets 案件付随 少人数
billing 契約・請求 経理
mail_templates / manual_* / demo_catalog / members / alert_settings 設定・テンプレ
tk_wl_state / tk_wl_sessions / today_todos / worker_analytics 個人/派生 個人

改修プラン(段階)

Phase 0(即やる・低コスト): 安全網の強化

  • 主要ホットキー(deals/job_applications/linkedin系/projects/tk_tasks)のスナップショット世代を増やす + 明示的な「履歴から復元」UI(事故っても戻せる安心)
  • 軽い楽観ロック: 保存時に「読み込み後にサーバー値が変わっていたら、保存前に最新を取り込んでマージ(or 警告)」。検知だけでも事故が激減。
  • 目安: 〜半日〜1日。

Phase 1(推奨・効果大): 「項目ごとマージ保存」

ホットなリストの保存を「リスト全体を PUT」→「変更した1項目だけ送り、サーバー側で blob にマージ(read-modify-write)」へ変更。
- 効果: 別の行を同時編集しても消えなくなる(同じ1項目を2人が同時編集した時だけ後勝ち=現実的に十分)。← 大串の懸念を構造的に解消。
- 変更点: (1) サーバーに item-merge エンドポイント追加(PATCH /api/data/<key>/item 等で D1上で読み→該当項目だけ差し替え→書き戻し、可能なら単一トランザクション) (2) 各ホットリストのインライン編集の保存呼び出しを「全体S()」→「item-merge」に差し替え。
- データの持ち方(blob)はそのまま = 移行リスク小・既存の描画コードほぼ無改修。
- 目安: 数日(対象リスト数による)。チーム本格運用前にやる推奨

Phase 2(必要なら): 行レベルテーブル化

最も大きい/ホットなリスト(deals 等)を D1 の「1レコード1行」テーブルへ。
- 効果: 完全な行レベル並行性(Supabaseでやったことと同等)を無料のD1で。
- コスト: 大(スキーマ・API・読み書き・描画の改修)。Phase 1で足りなければ着手。

コスト/優先度/金額

Phase 効果 工数 金額
Phase 0(安全網強化) 事故っても戻せる 〜1日 ¥0
Phase 1(項目マージ保存) 別行の同時編集が安全に 数日 ¥0
Phase 2(行レベル化) 完全な並行性 週単位 ¥0

金銭コストは全Phase ¥0(D1のまま。Supabaseのような有料移行は不要)。

推奨 → 実施済

  • ~~Phase 0 + Phase 1 を、チーム本格運用の前にやる~~ → 2026-06-16 実装完了(上記「実装状況」)。楽観ロック+3-wayマージで「同じシートの別箇所の同時編集」が構造的に安全になり、SCALE CRMの二の舞(後から問題→有料移行)を防いだ。
  • Phase 2(行レベル化)は当面不要。実運用で衝突が顕在化したら検討。
  • 【2026-06-25 検証・確定 (残タスクD4)】 project_rows は schema 定義のみで読み書きコードが存在しない完全なデッドスキーマ、member_rowsmembers_perrow_enabled フラグ時のみ使用。楽観ロック(rev版数CAS)+3-wayマージで lost-update は構造的に解消済みのため Phase 2 は当面不要を確定。着手基準=(1) client portal の CAS化(2026-06-25 B1 で対応済み)と (2) 本番での複数人同時編集の実地テスト(B3・未完)を経てなお「同一item同時編集の後勝ち」が業務上痛いと判明した deals 等のキーに限り段階移行する。それまで実装着手しない。
  • 次アクション: ライブ環境で複数人同時編集の実地テスト(2ブラウザで同シートの別行を同時編集→両方残るか確認)。※2026-06-25 B1(client CAS化)/B4(マージ改善)/B5(取りこぼし対策) 実装済み。この実地テスト(B3)が残タスク。

関連

  • データ層実装: core.js(D/S/_syncToD1/_initD1/_pollD1/softDelete・件数激減スナップショット)
  • サーバー: functions/api/data/[key].ts(現状: value全体のPUT)
  • 教訓: SCALE CRM(whole-blob + 後から問題 → 有料Supabase移行)。TERASU CRMは同じD1基盤のまま Phase 1 で構造的に堅牢化できる。