マイグレーション
スキーマ変更は Flyway で管理していて、本番に一度適用したファイルは元に戻せません。不明点があれば書く前にユーザーに確認する、というのが backend/src/main/resources/db/migration/CLAUDE.md(このディレクトリの正本)の一貫した姿勢です。
Exposed-first ワークフロー
Astar は Exposed ORM を使っていて(JPA ではありません、規約 参照)、Exposed table object がカラム定義の SSoT です。構造変更(新規テーブル・カラム追加/変更)は次の順で進めます。
- Exposed table object(
core/{module}/infrastructure/persistence/table/{Entity}sTable.kt)を作成・変更する ./gradlew generateMigration -Pdescription="..."を実行する — 新テーブルには RLS と標準制約が自動付与される- 生成された SQL をレビューし、複合 FK の TODO コメントを解決する
一方、SECURITY DEFINER 関数・CHECK 制約・インデックス最適化・seed データのような非構造的な変更は、Exposed table object を経由せず手動で SQL を書きます。この場合も db/migration/CLAUDE.md のチェックリストに従います。
V 番号運用
次の番号は filesystem が SSoT です。着手直前に必ず確認します。
ls backend/src/main/resources/db/migration/V*.sql | tail -1
ハードコードした番号を使い回さないでください。実際、直近の履歴には欠番があります。
V412 V413 (V414 は存在しない) V415 V416 ...
これは事故ではなく、後述する /wt ship の自動 renumber ゲートが生んだ意図的な欠番です。番号は「連番」であることより「重複しない」ことの方が重要です。
並行 worktree での採番衝突は構造的に起きる
Astar は複数の worktree(並行 AI エージェントセッション)で同時に開発が進みます。あるブランチを作った時点で ls V*.sql | tail -1 が V420 を返しても、別の worktree が同時に V420__foo.sql を作っていれば、後から merge しようとした側は衝突します。これは 3 回連続で実際に起きたことがある、珍しくない事故パターンです。
- ブランチ作成時点で採番した番号は仮番だと理解してください。
/wt shipが merge 直前に、そのブランチで新規追加されたマイグレーションをmainのmax+1..へ機械的に renumber します。手動で番号を付け替える必要はありません。- 「空いている番号」を判断するときは、自分の worktree だけでなく全ブランチ横断で確認するのが正確です。最終的に正しいのは「DB に適用済みの番号」であって、ローカルのファイル一覧だけではありません。
- renumber の結果生まれる欠番(上記の V414 のようなもの)は許容されます。
ファイル命名とヘッダー
V{NNN}__{snake_case_description}.sql ← ダブルアンダースコア必須
-- ============================================================================
-- V{NNN}: {タイトル}
-- ============================================================================
-- Purpose: {なぜこのマイグレーションが必要か}
-- Tables affected: {カンマ区切り}
-- ============================================================================
実例(V420__storage_device_roots.sql):
-- ============================================================================
-- V420: storage_device_roots — multi-root virtual mount namespace
-- ============================================================================
-- Purpose: Storage Device Multi-Root design
-- (docs/designs/2026-07-10-device-multi-root.md §3). ...
新テーブルの必須要素
新しいテーブルには次のカラムと制約が必須です。
CREATE TABLE public.{table_name} (
id BIGINT GENERATED ALWAYS AS IDENTITY,
public_id UUID NOT NULL DEFAULT gen_random_uuid(),
tenant_id BIGINT NOT NULL,
-- ドメインカラム --
created_by_user_id BIGINT, -- V042 命名標準
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
version BIGINT NOT NULL DEFAULT 0,
CONSTRAINT {table}_pkey PRIMARY KEY (id),
CONSTRAINT {table}_public_id_key UNIQUE (public_id),
CONSTRAINT {table}_tenant_id_id_unique UNIQUE (tenant_id, id) -- 複合FK参照先
);
id(内部 BIGINT)と public_id(API に露出する UUID)の dual-ID は全テーブル共通のパターンです。詳しくは 規約 の ID 命名を参照してください。
RLS ポリシー — 4 種類必須
Astar のマルチテナント分離は DB 側の Row Level Security で強制されています(全体像は マルチテナンシーと RLS を先に読んでください)。新しいテナントスコープテーブルには ENABLE + FORCE + 4 ポリシー全てが必須です。
ALTER TABLE public.{table} ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.{table} FORCE ROW LEVEL SECURITY;
CREATE POLICY {table}_tenant_select ON public.{table}
FOR SELECT USING (tenant_id = public.current_tenant_id());
CREATE POLICY {table}_tenant_insert ON public.{table}
FOR INSERT WITH CHECK (tenant_id = public.current_tenant_id());
CREATE POLICY {table}_tenant_update ON public.{table}
FOR UPDATE
USING (tenant_id = public.current_tenant_id())
WITH CHECK (tenant_id = public.current_tenant_id()); -- ← 両方必須
CREATE POLICY {table}_tenant_delete ON public.{table}
FOR DELETE USING (tenant_id = public.current_tenant_id());
- 絶対禁止:
current_setting('app.current_tenant_id')の直接使用。必ずpublic.current_tenant_id()関数を経由します。 - UPDATE ポリシーの
WITH CHECKを忘れると、行を別テナントに書き換えられてしまいます(実際に過去バージョンで一括修正が入った実例があります)。 - 参照先には
UNIQUE(tenant_id, id)が必要で、子テーブルからの FK はシングル FK ではなく複合 FK(tenant_id, parent_id)にします — シングル FK はクロステナント参照を防げません。
すべての新規テーブルに RLS が付くとは限りません。除外リストの SSoT はコード側の architecture/RlsCoverageExemptions.kt です(AUTH_METADATA = 認証フロー自体が使う users/tenants/roles 等、GLOBAL_NO_TENANT = tenant_id 列自体を持たない全テナント共有テーブルなど)。この一覧に新規テーブルを追加してよいかは必ずコード側を見て判断してください — ドキュメントの記述は古くなることがあります。
SECURITY DEFINER 関数
CREATE OR REPLACE FUNCTION public.{fn_name}({params})
RETURNS {type}
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public' -- 必須
AS $$
BEGIN
...
END;
$$;
SET search_path TO 'public' は必須です。Cloud SQL の cloudsqlsuperuser は BYPASSRLS を持たない(真の PostgreSQL superuser ではない)ため、SECURITY DEFINER 関数が INSERT/UPDATE するテーブルには対応する RLS ポリシーが必ず必要です。ここが欠けると dev では気付かず、Cloud SQL 本番だけで失敗します(過去に実際に発生したパターンです)。
CHECK 制約
ALTER TABLE public.{t} DROP CONSTRAINT IF EXISTS ck_{t}_{col}_valid;
ALTER TABLE public.{t} ADD CONSTRAINT ck_{t}_{col}_valid
CHECK ({col}::text = ANY(ARRAY['VAL1','VAL2']));
既存の CHECK 制約を変更するときは、先に DROP CONSTRAINT IF EXISTS してから ADD CONSTRAINT するのが標準パターンです(直近の実例: V422__add_astar_cli_to_usage_provider_check.sql)。
大きいテーブルへの変更は expand-contract
推定 100K 行を超えるテーブルへの UPDATE 全行バックフィル・SET NOT NULL・CREATE INDEX(非 CONCURRENTLY)などを 1 マイグレーションの単一トランザクションで行うと、ロックを長時間保持して本番の書き込みをブロックします。実際にこれが原因で 30 分の本番ダウンが発生した事故があり、以後 100K 行超のテーブルへのこの種の変更は expand(即時 DDL)→ backfill(別 migration・バッチ)→ contract(VALIDATE CONSTRAINT 等の online 操作)の 3 段階に分けることが必須になっています。SET LOCAL statement_timeout = 0 のようにタイムアウトを外すのは対症療法であって許可されていません。詳細な手順は db/migration/CLAUDE.md の該当節を参照してください。
Fixture / demo データは migration に書かない
Migration は application layer をバイパスするため、PropertyDefinition.validate() のようなバリデーションが一切走りません。デモテナントの content は shared/mock-data/*.json を SSoT とし、Service 層経由の idempotent upsert で投入されます。Migration で許可されるデータ操作は、アプリケーション起動前に存在が必要な core entity(SYSTEM user・DEMO tenant 等)と、テナント/ユーザーに依存しない reference data(billing_plans 等)だけです。
提出前セルフチェック(抜粋)
- バージョン番号が最新 +1(または
/wt shipが renumber する前提でギャップを許容) - ファイル名がダブルアンダースコア
V{NNN}__ - 全 DROP 文に
IF EXISTS - 新テーブルなら
id/public_id/tenant_id/versionすべてある - ENABLE + FORCE + 4 ポリシー全部あり、UPDATE ポリシーに
USINGとWITH CHECK両方 - 100K 行超のテーブルなら expand-contract を検討したか
全項目は db/migration/CLAUDE.md の「提出前セルフチェック」節にあります。書き始める前に一度目を通してください。