メインコンテンツへスキップ
友田 陽大

複数人同時編集のリアルタイム試合記録アプリ

アマチュア野球向け|Expo + Next.js + Supabase モノレポ|認可をDBに寄せたゼロトラスト設計(69テーブル全RLS・280ポリシー)とオフライン耐性のある冪等同時入力を単独構築

クライアント

アマチュア野球向けのリアルタイム試合記録プラットフォーム(iOS / Android モバイルアプリ + 運営用Web管理画面)|利用者: 選手・チーム管理者・スコアラー・スカウト・運営という複数ステークホルダー|開発体制: ドメイン設計からモバイル・管理画面・DB/認可・CI/CD・可観測性・リリース運用まで単独(フルスタック)

私の役割

テクニカルアーキテクト 兼 フルスタック開発者。ドメインモデリング、DB / RLS設計、モバイルアプリ(React Native / Expo)、運営Web管理画面(Next.js)、認証・認可(MFA / RLS / 監査ログ)、CI/CD、可観測性、リリース運用(EAS / OTA・Supabase)まで、設計から本番運用まで単独で担当。

課題(Situation & Task)

同じ1試合を複数のスコアラーが同時に記録しても、重複・競合・順序の乱れで破綻しない「協調スコアリング」を、電波の悪い球場(オフライン頻発)でも成立させる必要がありました。同時に、選手・チーム管理者・スカウト・運営という立場ごとに、見える情報と操作できる機能を厳密に分離する多層的な認可が求められました。モバイルアプリは改ざんされ得る前提に立ち、信頼境界をサーバー / DB側に置くことが大前提でした。

本アプリには、共同編集・モバイル・多層認可という難所が凝縮されていました。

  1. 同時編集 × オフライン: 1球ごとの高頻度入力を複数人が同時に行い、しかも球場では電波が不安定でオフラインが頻発します。送信のリトライ・二重送信・端末再起動を跨いでもデータ整合性を保つ必要がありました。

  2. ゼロトラストな多層認可: 選手・チーム管理者・スコアラー・スカウト・運営で権限が異なり、さらに「チーム単位の閲覧」「期限付きのスコアラー権限」「スカウトへの項目別開示(個人情報保護法への配慮)」など認可が多層的でした。クライアント側の出し分けは突破され得るため、DB側で強制する必要がありました。

  3. ドメインの正確性: 野球のスコアリングは状態遷移が複雑(イニング・表裏・アウト数・走者・打順・配球)で、不正な状態を型とDB制約で構造的に排除する必要がありました。

  4. 単独運用の持続可能性: 一人で開発・運用するため、退行を機械的に防ぐ高度な自動化(型・テスト・スキーマ整合・a11y・デプロイ)が不可欠でした。

技術選定の理由(Rationale)

  • pnpm + Turborepo のモノレポ: モバイル・管理画面・DBが同じドメイン語彙を共有する必要があったため、Zodスキーマを packages/domain に集約して全スタックの単一真実源に。pnpm catalog で主要依存(Zod / React など)のバージョンを一元ピン留めし、サイレントなドリフトを排除

  • Supabase (PostgreSQL + RLS): 認可ロジックをアプリ各所に散らさず、PostgreSQLの行レベルセキュリティ(RLS)でDB層に一元強制するため。クライアントを信頼しないゼロトラスト設計を、追加のバックエンドサーバーなしで低コストに実現

  • 冪等性キー駆動のオフラインファースト同期: 高頻度入力をWebSocketで撒くのではなく、決定的な冪等性キーで安全に書き込み、TanStack Query のキャッシュ無効化+短間隔の再取得で各クライアントへ反映。電波が悪くても破綻しないことを最優先(端末ローカルは Zustand + 永続キュー)

  • Next.js 16(App Router / RSC)の運営管理画面: 管理画面はサーバーコンポーネント + @supabase/ssr でRLSを効かせたサーバー取得にし、過剰なクライアント状態を持たない構成に

  • TypeScript strict + 型カバレッジのCI強制: noUncheckedIndexedAccessexactOptionalPropertyTypes まで有効化し、パッケージ単位で型カバレッジ閾値(96.7%〜100%)をCIで強制

実施したこと(Action)

  • 【オフライン耐性のある冪等同時入力】打席 game:{id}:inn:{回}:{表裏}:seq:{n}、投球 ab:{id}:pitch:{n} という決定的な冪等性キーをクライアントで生成。DBの (recording_team_id, idempotency_key) 一意制約 + upsert(ignoreDuplicates) + 競合時の権限スコープ付きRPC resolve_own_at_bat_id により、リトライ・二重送信・複数人同時でも必ず同一論理行へ収束

  • 【耐障害性のある書き込みキュー】投球タップはまず Zustand に楽観反映し、AsyncStorage永続キューへ追加。単一の「ドレインワーカー」だけが pitches テーブルへ書き込み、single-flight制御と指数バックオフで、オフライン・アプリ再起動・操作位置のズレを跨いでも安全に再送

  • 【ゼロトラスト認可をDBに集約】69の全公開テーブルでRLSを有効化し、280のポリシーで立場別アクセスを表現。スカウトへの開示は「選手の承認 ∩ チーム管理者の承認」の項目別グラント + 追記専用の監査ログ(個人情報保護法に配慮)で制御。機微操作はメールOTPのMFAでゲートし、custom_access_token_hook がJWTに mfa_verified を注入、require_mfa() がRPC入口で強制

  • 【野球ドメインの構造的正当性】打席結果・球種・出塁理由などを Zod enum で表現し、出塁理由を打席結果と直交させてフィールダーズチョイス等を正しくモデル化。アウト数0–3やイニング1–99等を TypeScript と DBのCHECK制約で二重に保証し、刺殺成立条件などのルールは TS / SQL 共通の純粋関数に集約(DRY)

  • 【単独運用を支える自動化】11本のGitHub Actionsで、型・Lint(Biome)・型カバレッジ・デッドコード検出(knip)・i18n整合・RNのa11yポリシー検査・マイグレーション安全性検査(squawk)・RLSカバレッジ・pgTAP・スキーマdrift検出・EAS / OTA配信を自動化

  • 【可観測性とプライバシー】Sentry(モバイル / 管理画面)に7階層のPIIスクラバを噛ませ、トークン・メール・電話・IP・ユーザーIDをマスキングしてから送信。packages/observability の構造化Transportでログ文脈も自動的にスクラブ

本プロダクトの設計原則は一貫して「クライアントを信じない(信頼境界をDBに置く)」でした。

同時編集の整合性 — 楽観的だが破綻しない: 高頻度な入力をWebSocketでブロードキャストすると、配信コスト・障害時の脆さ・順序の問題を抱えます。そこで採用したのが、決定的な冪等性キーです。打席・投球の「スロット」から一意なキーを算出し、DBでは (recording_team_id, idempotency_key) の一意制約で重複を吸収。書き込みは upsert(ignoreDuplicates) とし、競合時のみ権限スコープ付きRPCで自チームの行を解決します。端末側はオフラインファーストで、投球はまず楽観的にローカル反映 + 永続キューへ積み、単一のドレインワーカーが指数バックオフで安全に送信。アプリを再起動しても、スコアラーが操作位置を進めても、再送はキーで衝突せず収束します。クライアント間の反映はミューテーション駆動のキャッシュ無効化と短間隔の再取得(準リアルタイム)で行い、game_states.version 列で楽観ロックも可能にしています。

認可 — アプリではなくPostgreSQLで強制: 認可はアプリ側の出し分けに頼らず、行レベルセキュリティ(RLS)でDB側に強制しました。69の全公開テーブルでRLSを有効化し、280のポリシーで立場・チーム・期限付き権限・項目別開示までを表現。RPCは SECURITY DEFINERsearch_path 固定の定石で書き、関数の入口で auth.uid() とロール、require_mfa() を検証します。スカウトへの選手情報開示は「リクエスト → グラント → 監査」の三層で、選手とチーム管理者の承認の積集合だけを開示し、監査は追記専用(個人情報保護法に配慮)。RLSの変更には必ずpgTAPテストを伴うルールにし、set local request.jwt.claims で立場を切り替えながら許可・拒否の双方を検証して退行を防いでいます。

単独で本番運用するための自動化: 11本のGitHub Actionsが、型・Lint・型カバレッジ・pgTAP・RLSカバレッジ・マイグレーション安全性(squawk)・a11yポリシー・スキーマdrift検出・OTA配信までを担います。中でもスキーマdrift監視は「マージ済みなのに未デプロイ」という実際の障害を機に追加した安全網で、毎日マイグレーションとEdge Functionの同期を検査します。

技術選定の理由

  • Supabase RLS:69テーブル全てで認可をDB行レベルに強制(ゼロトラスト)

  • 決定的な冪等性キー+一意制約+解決RPC:オフライン・同時入力でも同一論理行へ収束

  • Zodドメイン型の単一真実源:モバイル・管理画面・DB型・Edge Functionで共有

  • pgTAP+型カバレッジ+スキーマdrift検出:退行をCIで機械的に阻止

担当領域

  • ドメイン設計・DB / RLS設計
  • モバイルアプリ開発(React Native / Expo)
  • 運営Web管理画面開発(Next.js 16 / RSC)
  • 認証・認可設計(MFA / RLS / 監査ログ)
  • CI/CD・可観測性・リリース運用(EAS / OTA・Sentry)

使用技術

React Native
Expo
Next.js
TypeScript
Supabase
PostgreSQL
Row Level Security
Zod
TanStack Query
Zustand
React Server Components
NativeWind
react-hook-form
PL/pgSQL
pgTAP
Vitest
Jest
fast-check
Sentry
Turborepo
pnpm
Biome
GitHub Actions
EAS / OTA
Edge Functions (Deno)

数字で見る成果

RLSポリシー
280+69の全公開テーブルでRLSを有効化(カバレッジ100%・ゼロトラスト)
テスト総数
1,200本+アプリ層(Jest / Vitest)+DB層(pgTAP 243本)のテストファイル
型カバレッジ
96.7%+全パッケージでCI強制(最高100%・TypeScript strict)
CHECK制約
349+アウト数0–3など、不正な状態を構造的に排除
CI/CDワークフロー
11本型・Lint・スキーマdrift検出・a11y・OTA配信まで自動化
DBマイグレーション
238本squawkで破壊的変更を静的検査しながら進化

成果

  • 同じ試合を複数のスコアラーが同時に記録できる協調スコアリングを、電波の悪い球場(オフライン頻発)でも破綻させずに実現。
  • 認可をPostgreSQLのRLSへ一元化し、69の全公開テーブルでRLSを有効化(カバレッジ100%)。280のポリシーで立場別アクセスを厳密に分離するゼロトラスト基盤を構築。
  • スカウトへの選手情報開示を「選手の承認 ∩ チーム管理者の承認」の項目別グラント+追記専用の監査ログで制御し、個人情報保護法に配慮した設計を実装。
  • 243本のpgTAP DBテストでRLS・整合性・状態遷移を常時検証。アプリ層と合わせ約1,200本のテストファイルで品質を担保し、型カバレッジは全パッケージでCI強制(96.7%〜100%)。
  • スキーマdrift検出(「マージ済みなのに未デプロイ」の検知)やマイグレーション安全性検査(squawk)、RNのa11yポリシー検査まで11本のCIで自動化し、単独でも安全に継続デリバリ。
  • Sentry+7階層のPIIスクラバで、トークン・メール・電話・IP・ユーザーIDをマスキングしつつ本番異常を検知する可観測性を確立。
  • モバイルアプリ(iOS / Android)と運営Web管理画面を、Zodドメイン型の単一真実源のもと単独で設計・開発・運用。

同様の課題、抱えていませんか?

あなたのビジネス課題も、最新の技術で解決できます。 まずは30分の無料技術相談から、状況をお聞かせください。

自社の課題もSaaS化できるか相談する

プロジェクト単位(請負)・技術顧問、どちらにも対応可能です

全ケーススタディを見る