コンテンツにスキップ

migration 安全規約

対象読者: migration ファイルを作成するすべての開発者・エージェント。
PR を出す前にこのチェックリストを確認すること。


1. 加法のみ・冪等

migration は既存データを一切破壊しない加法的な操作のみを行う。再実行しても安全な冪等 SQL を使う。

操作 正しい書き方
テーブル作成 CREATE TABLE IF NOT EXISTS
列追加 ALTER TABLE … ADD COLUMN IF NOT EXISTS(nullable、デフォルト値なし)
インデックス作成 CREATE INDEX IF NOT EXISTS
ポリシー変更 DROP POLICY IF EXISTS … → CREATE POLICY
トリガ変更 DROP TRIGGER IF EXISTS … → CREATE TRIGGER
関数作成・変更 CREATE OR REPLACE FUNCTION

新規列は必ず nullable(既存行が NOT NULL に違反しないため)。
既存行のデータは削除・上書き禁止。DELETEUPDATEDROP TABLEDROP COLUMN は原則不可(§3 参照)。

実例: 00000000000063_legal_retention_audit_log.sqlCREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS + DROP POLICY IF EXISTS → CREATE POLICY)、00000000000064_legal_retention_soft_delete_columns.sqlADD COLUMN IF NOT EXISTS nullable、7 表全テーブルで同一パターン)。


2. RLS 同一テナント参照ガード

クロステナント FK(他テナントの行を自テナント行に紐付ける攻撃)を防ぐため、外部キーが示す参照先が同一テナントに属することを WITH CHECKEXISTS で検証する。

with check (
  tenant_id = public.current_tenant_id()
  and exists (
    select 1 from public.<親テーブル> p
    where p.id = <外部キー列>               -- 外側テーブル列を完全修飾
      and p.tenant_id = public.current_tenant_id()
  )
)

未修飾 tenant_id シャドーイング(必ず回避)

相関サブクエリ内で tenant_id を未修飾で書くと、内側テーブル(親テーブル)の tenant_id 列に解決され p.tenant_id = p.tenant_id(恒真)となり検証が無効化される。
→ 外側テーブル列は <外側テーブル>.<列名> で完全修飾するか、テナント一致は public.current_tenant_id() と直接比較する。

実例: - 00000000000055_dispatch_stops_write_same_tenant_refs.sqldriver_id / collection_site_id が自テナント所属か EXISTS で検証。コメントにシャドーイング罠の解説あり。 - 00000000000019_weighing_integrity.sqlweighings / partner_item_contractspartner_id が自テナント所属か検証。 - 00000000000056_manifests_job_id_same_tenant.sql — マニフェストの job_id 同一テナント検証。


3. 法定保存記録は物理削除禁止(soft-delete のみ)

廃掃法・会計法令に基づき、基本 7 表 + 以後追加された 7 表(external_partners / external_partner_settlements / driver_roll_calls / driver_daily_notes / vehicle_daily_logs / billing_invoices / billing_invoice_lines)=計 14 表の法定レコードは物理削除禁止

テーブル 法的位置づけ
manifests 産廃管理票(廃掃法12条の3・5年保存)
weighings 計量=処分・取引実績
weighing_items 計量内訳(按分結果)
jobs 委託=取引の正本
permits 許可証(コンプラ照合根拠)
partner_item_contracts 委託契約・単価契約(5年保存)
partners 上記法定子の CASCADE 親

実装構成(フェーズ1)

役割 migration 内容
soft-delete 列 00000000000064_legal_retention_soft_delete_columns.sql deleted_at timestamptzdeleted_by uuid を 7 表に追加(nullable)
物理削除ブロック 00000000000065_legal_retention_block_physical_delete.sql 各表に BEFORE DELETE トリガ。app.bypass_legal_delete = 'on' セッション変数がない限り RAISE EXCEPTION で拒否(service_role・RPC・CASCADE 経由も阻止)
SELECT 除外 00000000000066_legal_retention_select_filter.sql 基底 SELECT を deleted_at IS NULL でフィルタ。for all を SELECT/INSERT/UPDATE に分割し DELETE ポリシーは剥奪(weighing_items のみ DELETE 残置・下記)
削除済みの一律非表示 00000000000068_legal_retention_drop_admin_reads_deleted.sql mig66 の permissive な admin reads deleted * ポリシーを撤去。admin を含む全員から削除済みを非表示にする(fail-safe)。参照/復元は admin 限定 DEFINER RPC で(下記)
soft-delete / restore RPC 00000000000067_legal_retention_rpcs.sql soft_delete_legal_record / restore_legal_record(SECURITY DEFINER)。削除は必ず RPC 経由
監査 00000000000063_legal_retention_audit_log.sql audit_log テーブル(append-only)。soft_delete / restore イベントを記録

削除済みの参照・復元(permissive RLS は使わない)

soft-delete 済みレコードは admin を含む全員の通常 SELECT から非表示(mig68 で permissive な admin reads deleted * ポリシーを撤去)。「admin だけ削除済みを多く見る」を permissive RLS で表現しない — RLS はクエリの意図を区別できず、admin の全運用一覧へ削除済みが再露出して整合性事故(例: 取消済み計量の再完了)の温床になるため。削除済みの参照/復元は admin 限定の SECURITY DEFINER RPC で行う:

  • 復元: restore_legal_record(p_table, p_id)record_id 指定で実行する(DEFINER で RLS を迂回するため SELECT 不可視でも復元可)。admin は復元対象の record_idaudit_log(admin-only SELECT)から取得する。
  • 削除済み一覧(ゴミ箱 UI): Later。実装時も permissive RLS ではなく admin 限定 DEFINER RPC で提供する。

weighing_items の例外

weighing_itemscomplete_weighing RPC(SECURITY INVOKER)が完了前の按分を再計算するため DELETE ポリシーを残している。ただし物理削除トリガは完了済み親を持つ items の削除を阻止する(complete_weighingapp.bypass_legal_delete GUC でトリガをバイパスして書込む)。

法定テーブルを新設するとき(§ 4 チェックリストも参照)

  1. deleted_at timestamptz / deleted_by uuid 列を nullable で追加(§1 加法ルール)
  2. BEFORE DELETE トリガを付与して物理削除を封じる
  3. deleted_at IS NULL フィルタを SELECT RLS に加える(admin 含む全員に適用。削除済み閲覧用の permissive ポリシーは作らない — 参照/復元は admin 限定 DEFINER RPC で)
  4. 表専用の soft_delete_<table> DEFINER RPC を新設する(mig67 の allowlist は凍結・拡張しない)
  5. audit_log への記録が動作することをテストで確認

4. 版番プレフィックス一意・空ファイル禁止・dry-run 確認

  • ファイル名は 14桁ゼロ埋め連番(例: 00000000000068_…)。同じ番号を持つファイルが 2 つ以上あってはならない。
  • 空ファイル禁止(中身のない .sql はゲートで弾かれる)。
  • PR を作成すると .github/workflows/supabase-migrations-check.yml が自動起動し、以下を検証する:
  • 版番プレフィックスの重複チェック
  • 空ファイル検出
  • dev プロジェクトへの supabase db push --dry-run(DB は変更しない)

ゲートを通過するまで PR はマージ不可。ローカルでも事前確認を推奨する。


5. 本番へ migration 番号の飛びを出さない

本番の migration 適用は厳格な番号順です。develop の検証環境は並行 PR の出順ずれを吸収するため include-all を許容しますが、develop が緑でも本番の番号順を保証しません。

  • リリース前に develop と未マージの子ワークツリーを確認し、含まれる migration 番号を昇順で列挙する。
  • 大きい番号だけを先に main へ出さない。小さい番号を同じリリースへ含めるか、未マージ側の番号を採番し直す。
  • main へ出した後に小さい番号を追加すると prod の db push が before the last migration で停止するため、リリース順を逆転させない。
  • 新規番号は自ワークツリーの最大値ではなく、他の未マージ作業の番号も含む最大値 + 1 から採る。
  • 連番変更時は proof ファイル、sentinel 関数、期待値、AGENTS の参照を同じ変更で更新する。

6. デプロイ経路(develop → dev / main → 本番)

.github/workflows/supabase-deploy.yml が migration の適用を担う。

イベント 適用先
develop ブランチへの push dev プロジェクト(SUPABASE_DEV_DB_URL
main ブランチへの push 本番プロジェクト(SUPABASE_PROD_DB_URL

supabase db pushschema_migrations を参照し未適用分のみを順に適用する(重複適用なし)。
main へのマージは必ずユーザー(PdM)の承認を経ること(CLAUDE.md 規定)。
手動適用(supabase db push 直接実行など)は禁止。Supabase MCP からの apply_migration も本プロジェクトでは禁止(接続先が Greco 本番でないため)。


まとめ: 新しい migration を書く前のチェックリスト

  • [ ] 加法のみか(IF NOT EXISTS・nullable 列・CREATE OR REPLACE・冪等な DROP→CREATE)
  • [ ] WITH CHECKEXISTS 同一テナント検証を書いたか、tenant_id を完全修飾したか
  • [ ] 対象テーブルが法定 7 表に含まれる場合、soft-delete パターンに従っているか(物理 DELETE ではないか)
  • [ ] 版番プレフィックスは一意で 14桁ゼロ埋めか、ファイルは空でないか
  • [ ] PR ゲート(supabase-migrations-check.yml)が緑か