アマチュア野球向けの「リアルタイム試合記録アプリ」を、ドメイン設計からモバイル・運営管理画面・DB・CI/CD・運用まで一人で作り、運用しています。モバイルアプリ(iOS / Android)と運営用Web管理画面、そして両者を支えるPostgreSQLからなる、Expo + Next.js + Supabase のモノレポです。
このプロダクトには、フロントエンドの華やかさとは別の「地味だが致命的」な難所が凝縮されていました。複数人が同じ1試合を同時に記録しても破綻しないこと。しかも電波の悪い球場でオフラインになってもです。さらに、選手・チーム管理者・スコアラー・スカウト・運営という立場ごとに、見える情報と操作できる機能を厳密に分ける多層的な認可が要求されました。
本記事は、この2つの難所——同時編集の整合性とゼロトラストな認可——を、私がどう解いたかの設計記録です。結論を一言でいえば、設計の背骨は一貫して「クライアントを信じない」でした。
設計の背骨:「クライアントを信じない」
モバイルアプリは、ユーザーの手元で動き、改ざんもされ得ます。ネットワークは切れ、リクエストは重複し、順序は乱れます。だから私は、信頼境界をアプリの外——サーバーとデータベースの側——に置くことを最初の原則にしました。
この原則は、2つの具体的な設計判断に落ちます。
- 整合性は「楽観的なクライアント」ד厳格なDB制約」で守る。 クライアントは速く動いてよい。ただし真実はDBの一意制約とCHECK制約が決める。
- 認可はアプリのif文ではなく、PostgreSQLの行レベルセキュリティ(RLS)で強制する。 UIが権限チェックを忘れても、DBが拒否する。
以下、それぞれを実コードで見ていきます。
1. 同時編集の整合性 — WebSocketに頼らず、冪等性キーで守る
なぜブロードキャストにしなかったのか
「リアルタイム共同編集」と聞くと、まずWebSocketで全イベントを撒く設計を思い浮かべます。実際、Supabase RealtimeでもPostgresの変更を購読できます。しかし、1球ごとに高頻度で発生する入力をそのままブロードキャストする設計は、このプロダクトには合いませんでした。
- コスト: 全クライアントへ全イベントを配信する負荷とコストが、利用規模に対して見合わない。
- 障害時の脆さ: 接続が切れた瞬間のイベントは欠落する。再接続後の整合をどう取るかが別問題として残る。
- 順序の問題: 複数の書き手から届くイベントの順序保証は、配信層では本質的に難しい。
そこで私は、リアルタイム配信そのものを「主役」から降ろしました。書き込みの正しさは冪等性キーとDB制約で保証し、他クライアントへの反映は短間隔の再取得(準リアルタイム)に委ねる——この割り切りが、オフライン頻発という現実に最も強い構成でした。
決定的な冪等性キー(スロットキー)
鍵になるのが、決定的(deterministic)な冪等性キーです。打席や投球は「試合のどのスロット(位置)か」が決まれば一意に定まります。ランダムなUUIDではなく、スロットから機械的に算出するキーを採用しました。
// apps/mobile … scoringIdempotencyKeys.ts
export const atBatIdempotencyKey = (input: AtBatSlotKeyInput): string =>
`game:${input.gameId}:inn:${input.inning}:${input.half}:seq:${input.sequenceInHalf}`;
export const pitchIdempotencyKey = (input: PitchKeyInput): string =>
`ab:${input.atBatId}:pitch:${input.sequence}`;
ポイントは2つ。
- ランダム性ゼロ。同じスロットからは、何度計算しても同じキーが出る。だからリトライしても、別端末から計算しても、衝突せず同じ論理行を指す。
- チーム非依存。キー自体はチームを含まない。記録チームのスコープは、DB側の一意制約で表現する。
DB側の一意制約は「記録チーム × 冪等性キー」です。これにより、同一チームの複数スコアラーが同じスロットを同時に入力しても、行は1つに収束します。
-- 記録チームごとに idempotency_key を一意化(チーム内の重複を吸収)
create unique index at_bats_idem_team_key
on public.at_bats (recording_team_id, idempotency_key);
upsert と「自チームの行を返す」RPCフォールバック
書き込みは「INSERTか、すでにあるならその行を取る」というセマンティクスにしたい。素朴な「SELECTしてなければINSERT」は競合に弱いので、upsert + ignoreDuplicates を使い、衝突時だけ自チームの行を解決するRPCにフォールバックします。
// apps/mobile … useStartAtBat.ts(抜粋・簡略化)
const { data: inserted } = await supabase
.from('at_bats')
.upsert(
{
game_id: input.gameId,
inning: input.inning,
half: input.half,
batter_id: input.batterId,
pitcher_id: input.pitcherId,
batting_order: input.battingOrder,
sequence_in_half: input.sequenceInHalf,
idempotency_key: idempotencyKey,
},
{ onConflict: 'recording_team_id,idempotency_key', ignoreDuplicates: true },
)
.select('id')
.maybeSingle();
if (inserted) {
return { id: inserted.id };
}
// 競合パス: すでに同じスロットの行がある → 自チームの行をRPCで解決
const { data: existingId } = await supabase.rpc('resolve_own_at_bat_id', {
p_game_id: input.gameId,
p_idempotency_key: idempotencyKey,
});
なぜ「ただSELECTする」ではなくRPCなのか。resolve_own_at_bat_id は権限スコープ付きで、呼び出し元が属するチームの行だけを返すよう設計されています。クライアントに任意の行を引かせないことで、競合解決すらも信頼境界の内側(DB)に置けます。
オフラインファーストな永続キューとドレインワーカー
球場ではオフラインが日常茶飯事です。だから書き込みは端末ローカルに永続化してから、バックグラウンドで安全に送信します。
設計はこうです。投球タップはまずZustandのストアに楽観反映し、同時にAsyncStorageに永続化したキューへ操作を積む。そして、pitchesテーブルへ書き込むのは「単一のドレインワーカー」だけにします。
/**
* Durable-pitch drain worker — the SOLE writer of the `pitches` table.
*
* - Single-flight gating(同じopを二重発火させない。サーバーの
* idempotency_key が第二の防衛線)
* - Self-healing at-bat open(スコアラーがスロットを進めても、
* アプリ再起動後でも、保留中の投球を独立してドレインできる)
* - Exponential backoff(成功で即ドレイン、失敗で次回をスケジュール)
*/
この3点が効きます。
- single-flight制御: 連打や再レンダーでローカルに二重発火させない。万一抜けても、サーバーの冪等性キーが第二の防衛線になる。
- 自己修復: スコアラーが操作位置を先へ進めても、アプリを再起動しても、保留中の投球を「今のライブ状態とは独立に」流し込める。
- 指数バックオフ: 失敗は責めず、間隔を空けて再送する。決定的キーのおかげで、再送は何度繰り返しても安全。
「楽観的に速いUI」と「壊れない永続化」を両立させる、オフラインファーストの定石を、冪等性キーが下支えしている構図です。
クライアント間の反映:ミューテーション駆動のキャッシュ無効化
では他のスコアラーの画面にはどう伝わるのか。ここはTanStack Queryのキャッシュ無効化 + 短間隔の再取得で実現しています。書き込みが成功したら、依存するキャッシュだけを的確に無効化します。
// useRecordPitchEvent.ts(抜粋)
onSuccess: (_data, variables) => {
// pitch_events に依存する2つのキャッシュだけを無効化する
queryClient.invalidateQueries({ queryKey: gameStateQueryKey(variables.gameId) });
queryClient.invalidateQueries({ queryKey: gameEarnedRunsQueryKey(variables.gameId) });
queryClient.invalidateQueries({ queryKey: gamePitchEventCountsQueryKey(variables.gameId) });
},
クエリキーは階層的なファクトリ(['games', gameId, サブリソース])に一元化し、無効化の取りこぼし・撒きすぎを防いでいます。読み取りには短いstaleTimeを設定し、変更後は速やかに再取得が走る。さらにDBのgame_statesには**version列**を持たせ、必要に応じて楽観ロック(compare-and-swap)も掛けられるようにしています。
create table public.game_states (
...
current_inning smallint not null default 1 check (current_inning between 1 and 99),
outs smallint not null default 0 check (outs between 0 and 3),
version integer not null default 0, -- 楽観ロック用
...
);
これが「準リアルタイム」と正直に呼んでいる理由です。ミリ秒単位の双方向ストリームではなく、**「速く書ける」「壊れない」「数秒で揃う」**を選んだ。オフライン頻発という制約下では、これが最も堅牢でした。
2. 認可をPostgreSQLに寄せる — ゼロトラストRLS
なぜアプリ側の出し分けに頼らないのか
認可をUIやAPI層のif文で実装すると、画面が増え、ロールが増えるほど、チェック漏れの確率が上がります。漏れた1か所が情報漏洩になります。
そこで私は、認可を**PostgreSQLの行レベルセキュリティ(RLS)**でDB層に強制しました。アプリがうっかりチェックを忘れても、DBが行を返さない・書かせない。公開スキーマの全69テーブルでRLSを有効化し、約280のポリシーで立場別アクセスを表現しています(カバレッジ100%)。
RLSとヘルパー関数
ポリシーは「この行を、この役割の、この条件のユーザーだけが見られる/書ける」を宣言的に書きます。チームスコープや期限の判定は、再利用可能なヘルパー関数(is_team_admin など)に切り出しています。
-- 自チームの game_requests だけを SELECT 可能にする
create policy game_requests_select_requester on public.game_requests
for select to authenticated
using (
exists (
select 1 from public.team_members tm
where tm.team_id = requester_team_id
and tm.user_id = auth.uid()
and tm.left_at is null
and (tm.expires_at is null or tm.expires_at > now()) -- 期限切れは除外
)
);
監査ログのような機微テーブルは、authenticatedにはSELECTすらrestrictiveポリシーで全面拒否し、service_roleだけが触れるようにしています。「見えてはいけないものは、そもそも返さない」を徹底します。
期限付き権限(time-bound)
このドメインには「一時的に与える権限」が多くあります。スコアラー権限、MFAセッション、対戦申請、招待——いずれも有効期限を持ちます。
create table public.team_members (
...
role public.team_role not null,
left_at timestamptz,
expires_at timestamptz,
-- expires_at を持てるのは scorer ロールだけ、という不変条件
constraint team_members_expires_at_scorer_only
check (expires_at is null or role = 'scorer')
);
「アクティブなメンバー」の判定は、left_at is null and (expires_at is null or expires_at > now()) という述語で各ポリシーに一貫して織り込まれます。期限はビジネスルールではなく、データの不変条件として扱う——これがゼロトラストの肝です。
スカウト開示カーネル:request → grant → audit
最も神経を使ったのが、スカウトへの選手情報開示です。個人情報を扱うため、「誰が・どの項目を・誰の承認で見られるのか」を厳密に管理し、かつ監査に耐える必要がありました。
設計は3つのテーブルの協調です。
| テーブル | 役割 |
|---|---|
scout_disclosure_requests | スカウトが「この項目を見たい」と要求する(状態機械: pending → accepted / declined / withdrawn) |
scout_disclosure_grants | 実際に開示される項目を保持。選手の承認 ∩ チーム管理者の承認の積集合だけが有効 |
scout_disclosure_audit | 追記専用の監査ログ(個人情報保護法に配慮)。authenticatedには全面非公開 |
開示対象は name / position / physical / handedness / grade の5項目。重要なのは、開示は「選手が承認した項目」と「チーム管理者が承認した項目」の積集合でしか成立しないこと。片方が承認しても、もう片方が承認しなければ開示されません。すべてのDMLはSECURITY DEFINERなRPC経由に限定し、authenticatedの直接INSERT/UPDATEは拒否しています。選手がチームを移籍すると、トリガが開示ポリシーを自動的にリセットし、その事実も監査ログに残ります。
SECURITY DEFINER と search_path 固定
RLSは「行が見えるか」を守りますが、状態遷移を伴う操作(承認・受諾・冪等な作成)はRPCで表現し、関数の入口で認可を検証します。Supabase/PostgreSQLでの定石は、SECURITY DEFINER + search_path 固定です。
create or replace function public.accept_game_request(p_request_id uuid)
returns public.games
language plpgsql security definer
set search_path = public -- search_path インジェクション対策
as $$
declare
v_caller uuid := auth.uid();
v_request public.game_requests;
begin
if v_caller is null then
raise exception '認証が必要です' using errcode = '28000';
end if;
select * into v_request from public.game_requests
where id = p_request_id
for update; -- 行ロックで競合を直列化
-- 認可: 相手チームの admin のみが承認できる
if not public.is_team_admin(v_request.requested_team_id) then
raise exception '相手チームの admin のみ承認できます' using errcode = '42501';
end if;
-- 冪等: すでに accepted なら同じ game を返す(再実行に強い)
if v_request.status = 'accepted' and v_request.accepted_game_id is not null then
return (select g from public.games g
where g.id = v_request.accepted_game_id and g.deleted_at is null);
end if;
-- 期限切れ・不正な状態遷移を拒否し、ここから atomically に games を作る …
end
$$;
for update の行ロックで競合を直列化し、状態機械の検証と冪等な再実行(accepted なら同じ結果を返す)を関数内に閉じ込めています。RLSが「データの可視性」を、RPCが「操作の正しさ」を担う、役割分担です。
MFAをDBで強制する
機微な操作はMFA(メールOTP)でゲートします。ここで重要なのは、MFAの検証結果をJWTのクレームに焼き込み、DB側で強制すること。クライアントの「MFA済みフラグ」を信じないためです。
カスタムアクセストークンフックが、セッションの検証状態を見て app_metadata.mfa_verified をJWTに注入します。
-- custom_access_token_hook(抜粋): セッションが MFA 検証済みかを JWT に入れる
v_session_id := nullif(v_claims->>'session_id', '')::uuid;
if v_session_id is not null then
select exists (
select 1 from public.mfa_verified_sessions s
where s.session_id = v_session_id
and s.expires_at > now() -- 検証には有効期限がある
) into v_mfa;
end if;
v_app_metadata := jsonb_set(v_app_metadata, '{mfa_verified}', to_jsonb(v_mfa));
そして、機微なRPCは入口で require_mfa() を呼びます。
create or replace function public.require_mfa()
returns void language plpgsql stable as $$
begin
if not public.session_is_mfa_verified() then
raise exception 'MFA verification required'
using errcode = '42501', hint = 'MFA_REQUIRED';
end if;
end
$$;
クライアント側は、OTP検証に成功した直後に supabase.auth.refreshSession() を呼び、mfa_verified=true を載せたJWTを再発行させます。「MFAしたつもり」をアプリが主張するのではなく、DBが事実として確認する——これがゼロトラストの徹底です。
3. ドメインの正当性を型とDBで二重化する
整合性と認可を固めても、ドメインのモデルが間違っていれば意味がありません。野球のスコアリングは状態遷移が複雑です。不正な状態は、型とDB制約で「そもそも作れない」ようにします。
Zod enum と「出塁理由」の直交
打席結果・球種・出塁理由などは、packages/domainのZodスキーマで一元定義し、モバイル・管理画面・Edge Functionで共有します。設計上の工夫は、「打席結果」と「出塁理由」を直交させたことです。
// packages/domain … scoring.ts / enums.ts(抜粋)
export const AtBatResult = z.enum([
'single', 'double', 'triple', 'home_run',
'strikeout_swinging', 'strikeout_called',
'groundout', 'flyout', 'lineout', 'popout',
'walk', 'intentional_walk', 'hit_by_pitch',
'sacrifice_bunt', 'sacrifice_fly',
'fielders_choice', 'double_play', 'triple_play',
'dropped_third_strike', 'catcher_interference',
// …
]);
export const OnBaseReason = z.enum([
'out', 'hit', 'walk', 'intentional_walk', 'hit_by_pitch',
'fielding_error', 'dropped_third_strike', 'sacrifice',
'fielders_choice', 'catcher_interference', 'interference',
]);
たとえば「ゴロだが、フィールダーズチョイスで打者は生きた」のような状況は、result: 'groundout' × on_base_reason: 'fielders_choice' の組み合わせで自然に表現できます。1つのenumに無理やり詰め込むと、ありえない値や曖昧な値が生まれます。直交させることで、表現力と正しさを同時に得るのです。
CHECK制約と生成列
型はコンパイル時の防壁ですが、DBは最後の砦です。同じ不変条件をDBのCHECK制約でも二重化します。アウト数は0〜3、イニングは1〜99、打順は1〜9、投球コースは正規化座標で-1.0〜1.0——いずれもDBが拒否します。
create table public.at_bats (
...
batting_order smallint not null check (batting_order between 1 and 9),
idempotency_key text not null,
-- 派生値はアプリで計算させず、DBの生成列に閉じ込める(単一真実源)
is_hit boolean generated always as (public.at_bat_is_hit(result)) stored,
is_official_at_bat boolean generated always as (public.at_bat_is_official(result)) stored,
...
);
create table public.pitches (
...
location_x real not null check (location_x between -1.0 and 1.0),
location_y real not null check (location_y between -1.0 and 1.0),
...
);
「ヒットか」「正式な打数か」といった派生値は、アプリで計算させず生成列にしました。集計とUIで計算式がズレる事故を、構造的に防ぎます。プロジェクト全体ではこうしたCHECK制約が約349個あり、不正な状態を「データとして存在できない」ものにしています。
TS と SQL で共有する純粋関数(DRY)
ドメインのルールは、TSとSQLの両方に現れます。たとえば「この守備プレーが刺殺(putout)を生むか」。これをアプリと集計でそれぞれ実装すると、必ずズレます。そこでルールを純粋関数に集約し、TS側とSQL側で同じ定義を共有しました。
// 刺殺成立条件: 捕球者が居て、かつ捕球者本人が捕球失策をしていない
export function playEarnsPutout(play: DefensivePlay): boolean {
return play.catcherPosition !== null && !catcherCommittedFieldingError(play);
}
この関数は、守備記録エディタの「アウト行 / エラー行」分類、ウィザードの「行数 = アウト数」整合、そしてDB側の集計が共有する単一真実源です。DRYは「コードを短くする技」ではなく、**「真実をひとつにする規律」**だと考えています。
4. 退行を機械的に防ぐ — pgTAP・型カバレッジ・スキーマdrift
一人で開発・運用するからこそ、退行を人間の注意力に頼らないことが死活問題でした。CIに「壊れたら止まる」仕組みを大量に仕込んでいます。
pgTAPでRLSをテストする
RLSは強力ですが、書いただけでは正しさを保証しません。だから認可ポリシーには必ずpgTAPテストを伴わせるルールにしました。set local request.jwt.claims で立場を切り替え、許可されるケースと拒否されるケースの両方を検証します。
select plan(4);
-- 1. requester admin は自分の game_requests を SELECT できる
select is(
(select count(*)::int from public.game_requests
where requester_team_id = '…' and requested_team_id = '…'),
1,
'requester admin can SELECT their own game_requests'
);
-- 2. 無関係なチームの admin は見えない
set local request.jwt.claims = '{"sub":"…","role":"authenticated"}';
select is(
(select count(*)::int from public.game_requests where …),
0,
'unrelated team admin cannot see game_requests'
);
-- 3. authenticated の直接 INSERT は拒否される(42501 / GRANT なし)
select throws_ok(
$$ insert into public.game_requests (…) values (…) $$,
'42501', null,
'authenticated cannot directly INSERT game_requests'
);
select finish();
DB層だけで243本のpgTAPテストファイルが、RLS・整合性・状態遷移・移籍時のリセットといった「壊れたら気づきにくい」挙動を常時検証します。アプリ層(Jest / Vitest / fast-checkによるプロパティテスト)と合わせると、テストファイルは約1,200本規模になります。
型カバレッジをCIで強制する
TypeScriptはstrictに加え、noUncheckedIndexedAccess や exactOptionalPropertyTypes まで有効化したベース設定を全パッケージで共有しています(Next.js都合で一部だけ緩めるなど、緩和は明示的に・最小限に)。さらに、anyの侵食を型カバレッジ閾値でCIに守らせています。
// 例: apps/admin/package.json
"typeCoverage": { "atLeast": 99.5, "strict": true, "detail": false }
閾値はパッケージごとに96.7%〜100%。共有ドメインやUIは100%、アプリ側もほぼ全域が型付けされている状態を、機械的に維持しています。
「マージ済みなのに未デプロイ」を捕まえる
運用で一度ヒヤリとしたのが、**「mainにマージされたのに、本番DBに反映されていない」**という状態です。マイグレーションが古いまま、アプリだけ進む——気づきにくく、危険です。
そこで、CIにスキーマdrift監視を追加しました。毎日、ローカルのマイグレーション/Edge Functionと、デプロイ済みリモートの同期を検査します。
# supabase-drift-watch.yml — 「マージ済みなのに未デプロイ」を毎日検知する安全網
# (デプロイワークフロー自体が "実行されない" 故障モードを捕まえる)
- run: node scripts/check-supabase-drift.mjs --strict
--project-ref ${{ secrets.SUPABASE_PROJECT_REF }}
このスクリプトは、マイグレーションの並びとEdge Functionのデプロイ状態を突き合わせ、ローカルのみ/リモートのみの差分を検出して終了コードで知らせます(0=同期 / 1=drift / 2=環境不全)。マイグレーションの破壊的変更は別途squawkで静的検査し、全テーブルがRLS有効かつ少なくとも1ポリシーを持つことも検証します。CIワークフローは全部で11本——型・Lint・型カバレッジ・pgTAP・RLSカバレッジ・a11yポリシー・drift検出・OTA配信までを担います。
5. 可観測性とプライバシー — PIIスクラバ
本番の異常は検知したい。でも、個人情報をログに流したくない。この2つは両立できます。Sentry(モバイル / 管理画面)へ送る前に、7階層のPIIスクラバを必ず通します。
// packages/observability … scrubPii.ts(抜粋)
// メールはローカル部を完全マスク、ドメインだけ残す(運用時の切り分け用)
export function obfuscateEmail(value: string): string {
const at = value.indexOf('@');
if (at <= 0 || at === value.length - 1) return value;
const domain = value.slice(at + 1);
return `[REDACTED]@${domain}`;
}
スクラバは、トークン(Authorization / *_token / Bearer・JWTらしき値)、IP、電話番号、メール、ユーザーID(先頭8文字 + …)を、キー名と値の両方から検出してマスクします。SentryのbeforeSendで全イベントに適用するので、付け忘れが起きません。
// 管理画面側 beforeSend: 全イベントの extra / tags / user.id をスクラブ
export function buildBeforeSend(): (event: SentryEvent) => SentryEvent {
return (event) => {
if (event.extra) event.extra = scrubPii(event.extra);
if (event.tags) event.tags = scrubPii(event.tags);
if (event.user && typeof event.user.id === 'string') {
event.user = { ...event.user, id: obfuscateUserId(event.user.id) };
}
return event;
};
}
「メールのローカル部は残さない(ドメインだけ残す)」のような判断は、過去の監査で**“短いローカル部 + 企業ドメイン”だと個人が特定され得る**と気づいたことに基づいています。可観測性とプライバシーのトレードオフは、設計の意図としてコードに残してあります。
まとめ:この設計が効いた理由とトレードオフ
このプロダクトで私が一貫して守ったのは、**「クライアントを信じない」**という一点でした。それが、整合性と認可という2つの難所に、それぞれ具体的な形で効きました。
- 整合性: WebSocketブロードキャストではなく、決定的な冪等性キー + DBの一意制約 + オフライン永続キュー。速いUIと壊れない永続化を、再送に強い形で両立した。
- 認可: アプリのif文ではなく、PostgreSQLのRLS + SECURITY DEFINERなRPC + DBで強制するMFA。69テーブル・280ポリシーで、立場・チーム・期限・項目別開示までを宣言的に表現した。
- 正当性: 型とDB制約の二重化、生成列、TS/SQL共有の純粋関数で、不正な状態を「作れない」ものにした。
- 持続性: pgTAP・型カバレッジ・スキーマdrift検出で、一人でも退行を機械的に止める。
もちろんトレードオフはあります。RLSへ認可を寄せると、ポリシーとRPCの設計・テストに前払いの設計コストがかかります。準リアルタイムは、ミリ秒単位の双方向同期が要る用途には向きません。しかし、**「オフライン頻発・多層認可・単独運用」**という制約の下では、この設計が最も堅牢で、最も安く、最も壊れにくい——そう確信しています。
「リアルタイム共同編集」の本当の難しさは、UIではなく、信頼境界をどこに引くかにあります。クライアントを信じない、と決めるだけで、設計は驚くほど素直になります。
同種の「複数人同時編集」「ゼロトラストな認可」「オフラインファースト」「モバイル + 管理画面の一気通貫」といった要件の設計・実装・運用について相談したい方は、お気軽にご連絡ください。要件定義からインフラ・運用まで、一人でワンストップに対応します。