-- AwoooP Phase 1 Batch 1: 現有四表加 project_id + RLS -- 2026-05-04 ogt + Claude Sonnet 4.6(ADR-118 Batch 1,C-3/C-4 db-expert 修正版) -- 2026-05-04 critic 修正版:ADD CONSTRAINT IF NOT EXISTS 不存在於 PG → 改用 DO 塊檢查 pg_constraint -- -- 對象:incidents / knowledge_entries / playbooks / audit_logs -- 這四張表是高頻寫入表,採「三步式 migration」避免長時間鎖表: -- -- Step A: ADD COLUMN nullable(metadata-only,瞬間) -- Step B: 分批回填(每批 5000 筆,外部腳本呼叫) -- Step C: NOT VALID CHECK → VALIDATE(SHARE UPDATE EXCLUSIVE,不擋讀寫) -- → SET NOT NULL(PG 12+ 利用已驗證 check,不掃表) -- → SET DEFAULT 'awoooi' -- -- ⚠️ 執行前必確認: -- 1. awooop_phase1_control_plane_2026-05-04.sql 已執行(awooop_projects 表存在) -- 2. apps/api 已 deploy 「SET LOCAL app.project_id」版本,rollout 100% -- 3. 31 個 background loop 改用 awooop_platform_admin role(PR-10) -- 4. 量測各表體量(見下方 pre-migration check query) -- -- Pre-migration check: -- SELECT relname, n_live_tup, pg_size_pretty(pg_total_relation_size(oid)) -- FROM pg_class -- WHERE relname IN ('incidents','knowledge_entries','playbooks','audit_logs'); -- -- 分批回填腳本: -- apps/api/scripts/awooop_phase1_batch1_backfill.py(另行提供) -- -- ⚠️ RLS 是 fail-closed: -- SET LOCAL app.project_id 未設 → 讀不到任何資料(C-4 修正) -- WITH CHECK 防止 INSERT 寫入錯誤 tenant -- -- 回滾路徑: -- ALTER TABLE incidents DISABLE ROW LEVEL SECURITY; -- DROP POLICY IF EXISTS incidents_tenant_isolation ON incidents; -- DROP POLICY IF EXISTS knowledge_entries_tenant_isolation ON knowledge_entries; -- DROP POLICY IF EXISTS playbooks_tenant_isolation ON playbooks; -- DROP POLICY IF EXISTS audit_logs_tenant_isolation ON audit_logs; -- ALTER TABLE incidents DISABLE ROW LEVEL SECURITY; -- ALTER TABLE knowledge_entries DISABLE ROW LEVEL SECURITY; -- ALTER TABLE playbooks DISABLE ROW LEVEL SECURITY; -- ALTER TABLE audit_logs DISABLE ROW LEVEL SECURITY; -- ALTER TABLE incidents DROP COLUMN IF EXISTS project_id; -- ALTER TABLE knowledge_entries DROP COLUMN IF EXISTS project_id; -- ALTER TABLE playbooks DROP COLUMN IF EXISTS project_id; -- ALTER TABLE audit_logs DROP COLUMN IF EXISTS project_id; -- --------------------------------------------------------------------------- -- =========================== -- STEP A: ADD COLUMN(nullable,瞬間取鎖,不重寫表) -- =========================== -- 一次只做 ADD COLUMN,讓 AccessExclusiveLock 最短 DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'incidents' AND column_name = 'project_id' ) THEN ALTER TABLE incidents ADD COLUMN project_id VARCHAR(64); END IF; END $$; DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'knowledge_entries' AND column_name = 'project_id' ) THEN ALTER TABLE knowledge_entries ADD COLUMN project_id VARCHAR(64); END IF; END $$; DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'playbooks' AND column_name = 'project_id' ) THEN ALTER TABLE playbooks ADD COLUMN project_id VARCHAR(64); END IF; END $$; DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'audit_logs' AND column_name = 'project_id' ) THEN ALTER TABLE audit_logs ADD COLUMN project_id VARCHAR(64); END IF; END $$; -- =========================== -- STEP B: 分批回填(外部腳本) -- =========================== -- 此步驟由 apps/api/scripts/awooop_phase1_batch1_backfill.py 執行 -- 每批 UPDATE ... WHERE project_id IS NULL LIMIT 5000 -- 完成條件:SELECT count(*) FROM incidents WHERE project_id IS NULL; → 0 -- -- 快速驗證(執行此 SQL 前必須確認回填完成): -- SELECT -- 'incidents' as tbl, count(*) as null_count FROM incidents WHERE project_id IS NULL -- UNION ALL SELECT 'knowledge_entries', count(*) FROM knowledge_entries WHERE project_id IS NULL -- UNION ALL SELECT 'playbooks', count(*) FROM playbooks WHERE project_id IS NULL -- UNION ALL SELECT 'audit_logs', count(*) FROM audit_logs WHERE project_id IS NULL; -- 所有 null_count 必須為 0,否則停止。 -- -- ⚠️ 回填完成確認後才可繼續執行 Step C -- =========================== -- STEP C: NOT NULL 強制 + DEFAULT + Index + RLS -- =========================== -- PostgreSQL 12+:NOT VALID CHECK → VALIDATE → SET NOT NULL -- VALIDATE 只取 SHARE UPDATE EXCLUSIVE,不擋讀寫 -- SET NOT NULL 在 VALIDATE 後不再掃表(利用 check constraint 証明) -- --- incidents --- -- PostgreSQL 無 ADD CONSTRAINT IF NOT EXISTS,改用 DO 塊檢查 pg_constraint DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'chk_incidents_project_id_not_null' AND conrelid = 'incidents'::regclass ) THEN ALTER TABLE incidents ADD CONSTRAINT chk_incidents_project_id_not_null CHECK (project_id IS NOT NULL) NOT VALID; END IF; END $$; ALTER TABLE incidents VALIDATE CONSTRAINT chk_incidents_project_id_not_null; ALTER TABLE incidents ALTER COLUMN project_id SET NOT NULL; ALTER TABLE incidents ALTER COLUMN project_id SET DEFAULT 'awoooi'; ALTER TABLE incidents DROP CONSTRAINT IF EXISTS chk_incidents_project_id_not_null; CREATE INDEX IF NOT EXISTS idx_incidents_project_id ON incidents (project_id); ALTER TABLE incidents ENABLE ROW LEVEL SECURITY; ALTER TABLE incidents FORCE ROW LEVEL SECURITY; DROP POLICY IF EXISTS incidents_tenant_isolation ON incidents; CREATE POLICY incidents_tenant_isolation ON incidents FOR ALL TO awooop_app USING (project_id = current_setting('app.project_id', TRUE)) WITH CHECK (project_id = current_setting('app.project_id', TRUE)); -- --- knowledge_entries --- DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'chk_km_project_id_not_null' AND conrelid = 'knowledge_entries'::regclass ) THEN ALTER TABLE knowledge_entries ADD CONSTRAINT chk_km_project_id_not_null CHECK (project_id IS NOT NULL) NOT VALID; END IF; END $$; ALTER TABLE knowledge_entries VALIDATE CONSTRAINT chk_km_project_id_not_null; ALTER TABLE knowledge_entries ALTER COLUMN project_id SET NOT NULL; ALTER TABLE knowledge_entries ALTER COLUMN project_id SET DEFAULT 'awoooi'; ALTER TABLE knowledge_entries DROP CONSTRAINT IF EXISTS chk_km_project_id_not_null; CREATE INDEX IF NOT EXISTS idx_knowledge_entries_project_id ON knowledge_entries (project_id); ALTER TABLE knowledge_entries ENABLE ROW LEVEL SECURITY; ALTER TABLE knowledge_entries FORCE ROW LEVEL SECURITY; DROP POLICY IF EXISTS knowledge_entries_tenant_isolation ON knowledge_entries; CREATE POLICY knowledge_entries_tenant_isolation ON knowledge_entries FOR ALL TO awooop_app USING (project_id = current_setting('app.project_id', TRUE)) WITH CHECK (project_id = current_setting('app.project_id', TRUE)); -- --- playbooks --- DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'chk_playbooks_project_id_not_null' AND conrelid = 'playbooks'::regclass ) THEN ALTER TABLE playbooks ADD CONSTRAINT chk_playbooks_project_id_not_null CHECK (project_id IS NOT NULL) NOT VALID; END IF; END $$; ALTER TABLE playbooks VALIDATE CONSTRAINT chk_playbooks_project_id_not_null; ALTER TABLE playbooks ALTER COLUMN project_id SET NOT NULL; ALTER TABLE playbooks ALTER COLUMN project_id SET DEFAULT 'awoooi'; ALTER TABLE playbooks DROP CONSTRAINT IF EXISTS chk_playbooks_project_id_not_null; CREATE INDEX IF NOT EXISTS idx_playbooks_project_id ON playbooks (project_id); ALTER TABLE playbooks ENABLE ROW LEVEL SECURITY; ALTER TABLE playbooks FORCE ROW LEVEL SECURITY; DROP POLICY IF EXISTS playbooks_tenant_isolation ON playbooks; CREATE POLICY playbooks_tenant_isolation ON playbooks FOR ALL TO awooop_app USING (project_id = current_setting('app.project_id', TRUE)) WITH CHECK (project_id = current_setting('app.project_id', TRUE)); -- --- audit_logs --- DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'chk_audit_project_id_not_null' AND conrelid = 'audit_logs'::regclass ) THEN ALTER TABLE audit_logs ADD CONSTRAINT chk_audit_project_id_not_null CHECK (project_id IS NOT NULL) NOT VALID; END IF; END $$; ALTER TABLE audit_logs VALIDATE CONSTRAINT chk_audit_project_id_not_null; ALTER TABLE audit_logs ALTER COLUMN project_id SET NOT NULL; ALTER TABLE audit_logs ALTER COLUMN project_id SET DEFAULT 'awoooi'; ALTER TABLE audit_logs DROP CONSTRAINT IF EXISTS chk_audit_project_id_not_null; CREATE INDEX IF NOT EXISTS idx_audit_logs_project_id ON audit_logs (project_id); ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY; ALTER TABLE audit_logs FORCE ROW LEVEL SECURITY; DROP POLICY IF EXISTS audit_logs_tenant_isolation ON audit_logs; CREATE POLICY audit_logs_tenant_isolation ON audit_logs FOR ALL TO awooop_app USING (project_id = current_setting('app.project_id', TRUE)) WITH CHECK (project_id = current_setting('app.project_id', TRUE)); -- =========================== -- 驗收查詢 -- =========================== -- SELECT tablename, rowsecurity, forcerowsecurity FROM pg_tables -- WHERE tablename IN ('incidents','knowledge_entries','playbooks','audit_logs'); -- -- -- RLS fail-closed 測試(需 awooop_app role 執行): -- SET ROLE awooop_app; -- SET LOCAL app.project_id = 'ewoooc'; -- SELECT count(*) FROM incidents; -- 應 = 0(無 ewoooc 資料) -- SET LOCAL app.project_id = 'awoooi'; -- SELECT count(*) FROM incidents; -- 應 = 全部既有資料筆數 -- RESET ROLE; -- -- -- 確認無 NULL project_id: -- SELECT count(*) FROM incidents WHERE project_id IS NULL; -- = 0 -- SELECT count(*) FROM knowledge_entries WHERE project_id IS NULL; -- = 0 -- SELECT count(*) FROM playbooks WHERE project_id IS NULL; -- = 0 -- SELECT count(*) FROM audit_logs WHERE project_id IS NULL; -- = 0