From 9429982d4ed0d23825a03e858cda93b52e06c83b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 11:25:52 +0000 Subject: [PATCH] fix(db): backfill 4 orphan migration versions to repair prod integration These 4 versions were applied to production via MCP apply_migration but their files were never committed, so the Supabase->prod integration on every main commit failed with "Remote migration versions not found in local migrations directory" (history mismatch): 20260525005350 colapso_fase5_smoke_tests_mensal_history_v2 20260525005426 colapso_fase5_smoke_tests_fix_emoji_parser 20260525005954 colapso_fase5_kill_switch_rollout_gradual 20260525021830 restore_set_quote_number_trigger Back-filled verbatim from production's schema_migrations.statements (same version+name), so the repo ledger matches prod. Content is idempotent (CREATE ... IF NOT EXISTS / OR REPLACE / DROP ... IF EXISTS) for clean replay. https://claude.ai/code/session_01MBTzmQYmrgwLnwfxRS3PNU --- ...so_fase5_smoke_tests_mensal_history_v2.sql | 151 ++++++++++++++++++ ...pso_fase5_smoke_tests_fix_emoji_parser.sql | 84 ++++++++++ ...apso_fase5_kill_switch_rollout_gradual.sql | 79 +++++++++ ...21830_restore_set_quote_number_trigger.sql | 11 ++ 4 files changed, 325 insertions(+) create mode 100644 supabase/migrations/20260525005350_colapso_fase5_smoke_tests_mensal_history_v2.sql create mode 100644 supabase/migrations/20260525005426_colapso_fase5_smoke_tests_fix_emoji_parser.sql create mode 100644 supabase/migrations/20260525005954_colapso_fase5_kill_switch_rollout_gradual.sql create mode 100644 supabase/migrations/20260525021830_restore_set_quote_number_trigger.sql diff --git a/supabase/migrations/20260525005350_colapso_fase5_smoke_tests_mensal_history_v2.sql b/supabase/migrations/20260525005350_colapso_fase5_smoke_tests_mensal_history_v2.sql new file mode 100644 index 000000000..dd2da1d56 --- /dev/null +++ b/supabase/migrations/20260525005350_colapso_fase5_smoke_tests_mensal_history_v2.sql @@ -0,0 +1,151 @@ +-- ================================================================ +-- SMOKE TESTS MENSAIS — v2 (SECURITY INVOKER porque fn_run_smoke_tests +-- interna usa RESET role que não funciona em SECURITY DEFINER) +-- ================================================================ + +CREATE TABLE IF NOT EXISTS public.smoke_tests_runs ( + id bigserial PRIMARY KEY, + ran_at timestamptz NOT NULL DEFAULT now(), + test_name text NOT NULL, + test_category text, + result text NOT NULL, + details text, + duration_ms numeric +); + +CREATE INDEX IF NOT EXISTS idx_smoke_tests_runs_ran_at + ON public.smoke_tests_runs (ran_at DESC); + +CREATE INDEX IF NOT EXISTS idx_smoke_tests_runs_test_result + ON public.smoke_tests_runs (test_name, result, ran_at DESC); + +ALTER TABLE public.smoke_tests_runs ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS smoke_tests_runs_read_admin ON public.smoke_tests_runs; +CREATE POLICY smoke_tests_runs_read_admin + ON public.smoke_tests_runs FOR SELECT + TO authenticated + USING (is_admin_or_above((SELECT auth.uid()))); + +GRANT SELECT ON public.smoke_tests_runs TO authenticated; +REVOKE ALL ON public.smoke_tests_runs FROM anon; +GRANT USAGE, SELECT ON SEQUENCE public.smoke_tests_runs_id_seq TO authenticated; + +-- Wrapper SECURITY INVOKER (default) +CREATE OR REPLACE FUNCTION public.fn_run_and_persist_smoke_tests() +RETURNS TABLE( + ran_at timestamptz, + total_tests int, + passed int, + failed int, + warned int, + failed_tests text[] +) +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = public, pg_catalog +AS $$ +DECLARE + v_ran_at timestamptz := now(); + v_total int := 0; + v_passed int := 0; + v_failed int := 0; + v_warned int := 0; + v_failed_tests text[] := ARRAY[]::text[]; + r RECORD; +BEGIN + FOR r IN SELECT * FROM public.fn_run_smoke_tests() LOOP + v_total := v_total + 1; + + INSERT INTO public.smoke_tests_runs ( + ran_at, test_name, test_category, result, details, duration_ms + ) VALUES ( + v_ran_at, r.test_name, r.test_category, r.result, r.details, r.duration_ms + ); + + IF upper(r.result) = 'PASS' THEN v_passed := v_passed + 1; + ELSIF upper(r.result) = 'FAIL' THEN + v_failed := v_failed + 1; + v_failed_tests := array_append(v_failed_tests, r.test_name); + ELSIF upper(r.result) IN ('WARN','WARNING') THEN v_warned := v_warned + 1; + END IF; + END LOOP; + + RETURN QUERY SELECT v_ran_at, v_total, v_passed, v_failed, v_warned, v_failed_tests; +END; +$$; + +COMMENT ON FUNCTION public.fn_run_and_persist_smoke_tests() IS +'Wrapper SECURITY INVOKER. Roda fn_run_smoke_tests(), persiste em smoke_tests_runs e retorna sumário. +Cron mensal + execução manual via dashboard admin.'; + +-- Grant para admin/postgres executar +GRANT EXECUTE ON FUNCTION public.fn_run_and_persist_smoke_tests() TO postgres; +GRANT EXECUTE ON FUNCTION public.fn_run_and_persist_smoke_tests() TO authenticated; + +-- Views +CREATE OR REPLACE VIEW public.v_smoke_tests_latest_run +WITH (security_invoker = on) AS +WITH latest AS ( + SELECT max(ran_at) AS ran_at FROM public.smoke_tests_runs +) +SELECT + r.ran_at, r.test_name, r.test_category, r.result, r.details, r.duration_ms +FROM public.smoke_tests_runs r +JOIN latest l ON r.ran_at = l.ran_at +ORDER BY + CASE upper(r.result) + WHEN 'FAIL' THEN 1 WHEN 'WARN' THEN 2 WHEN 'PASS' THEN 3 ELSE 4 + END, r.test_name; + +GRANT SELECT ON public.v_smoke_tests_latest_run TO authenticated; +REVOKE SELECT ON public.v_smoke_tests_latest_run FROM anon; + +CREATE OR REPLACE VIEW public.v_smoke_tests_trend +WITH (security_invoker = on) AS +SELECT + ran_at, + count(*) AS total, + count(*) FILTER (WHERE upper(result) = 'PASS') AS passed, + count(*) FILTER (WHERE upper(result) = 'FAIL') AS failed, + count(*) FILTER (WHERE upper(result) IN ('WARN','WARNING')) AS warned, + round(avg(duration_ms)::numeric, 1) AS avg_duration_ms +FROM public.smoke_tests_runs +GROUP BY ran_at +ORDER BY ran_at DESC +LIMIT 12; + +GRANT SELECT ON public.v_smoke_tests_trend TO authenticated; +REVOKE SELECT ON public.v_smoke_tests_trend FROM anon; + +-- Cron mensal (dia 1, 03h UTC — domingo madrugada se cair, sem impacto) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'smoke_tests_monthly') THEN + PERFORM cron.schedule( + 'smoke_tests_monthly', + '0 3 1 * *', + $cron$ SELECT public.fn_run_and_persist_smoke_tests(); $cron$ + ); + END IF; +END $$; + +-- Rotação: manter últimas 24 execuções (~2 anos) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'smoke_tests_runs_purge') THEN + PERFORM cron.schedule( + 'smoke_tests_runs_purge', + '0 4 1 * *', + $cron$ + DELETE FROM public.smoke_tests_runs + WHERE ran_at < ( + SELECT min(ran_at) FROM ( + SELECT DISTINCT ran_at FROM public.smoke_tests_runs + ORDER BY ran_at DESC LIMIT 24 + ) keep + ); + $cron$ + ); + END IF; +END $$; diff --git a/supabase/migrations/20260525005426_colapso_fase5_smoke_tests_fix_emoji_parser.sql b/supabase/migrations/20260525005426_colapso_fase5_smoke_tests_fix_emoji_parser.sql new file mode 100644 index 000000000..dde19939b --- /dev/null +++ b/supabase/migrations/20260525005426_colapso_fase5_smoke_tests_fix_emoji_parser.sql @@ -0,0 +1,84 @@ +-- ================================================================ +-- FIX: fn_run_smoke_tests retorna result com emoji prefix +-- ("✅ PASS", "❌ FAIL", "⚠️ WARN"). Ajustar parser para usar LIKE. +-- ================================================================ + +CREATE OR REPLACE FUNCTION public.fn_run_and_persist_smoke_tests() +RETURNS TABLE( + ran_at timestamptz, + total_tests int, + passed int, + failed int, + warned int, + failed_tests text[] +) +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = public, pg_catalog +AS $$ +DECLARE + v_ran_at timestamptz := now(); + v_total int := 0; + v_passed int := 0; + v_failed int := 0; + v_warned int := 0; + v_failed_tests text[] := ARRAY[]::text[]; + r RECORD; +BEGIN + FOR r IN SELECT * FROM public.fn_run_smoke_tests() LOOP + v_total := v_total + 1; + + INSERT INTO public.smoke_tests_runs ( + ran_at, test_name, test_category, result, details, duration_ms + ) VALUES ( + v_ran_at, r.test_name, r.test_category, r.result, r.details, r.duration_ms + ); + + -- Match com emoji prefix (✅ PASS, ❌ FAIL, ⚠️ WARN) + IF r.result ILIKE '%PASS%' THEN + v_passed := v_passed + 1; + ELSIF r.result ILIKE '%FAIL%' THEN + v_failed := v_failed + 1; + v_failed_tests := array_append(v_failed_tests, r.test_name); + ELSIF r.result ILIKE '%WARN%' THEN + v_warned := v_warned + 1; + END IF; + END LOOP; + + RETURN QUERY SELECT v_ran_at, v_total, v_passed, v_failed, v_warned, v_failed_tests; +END; +$$; + +-- View latest_run ajustar ordenação para emoji prefix +CREATE OR REPLACE VIEW public.v_smoke_tests_latest_run +WITH (security_invoker = on) AS +WITH latest AS ( + SELECT max(ran_at) AS ran_at FROM public.smoke_tests_runs +) +SELECT + r.ran_at, r.test_name, r.test_category, r.result, r.details, r.duration_ms +FROM public.smoke_tests_runs r +JOIN latest l ON r.ran_at = l.ran_at +ORDER BY + CASE + WHEN r.result ILIKE '%FAIL%' THEN 1 + WHEN r.result ILIKE '%WARN%' THEN 2 + WHEN r.result ILIKE '%PASS%' THEN 3 + ELSE 4 + END, + r.test_name; + +-- View trend ajustar +CREATE OR REPLACE VIEW public.v_smoke_tests_trend +WITH (security_invoker = on) AS +SELECT + ran_at, + count(*) AS total, + count(*) FILTER (WHERE result ILIKE '%PASS%') AS passed, + count(*) FILTER (WHERE result ILIKE '%FAIL%') AS failed, + count(*) FILTER (WHERE result ILIKE '%WARN%') AS warned, + round(avg(duration_ms)::numeric, 1) AS avg_duration_ms +FROM public.smoke_tests_runs +GROUP BY ran_at +ORDER BY ran_at DESC +LIMIT 12; diff --git a/supabase/migrations/20260525005954_colapso_fase5_kill_switch_rollout_gradual.sql b/supabase/migrations/20260525005954_colapso_fase5_kill_switch_rollout_gradual.sql new file mode 100644 index 000000000..675b55bcc --- /dev/null +++ b/supabase/migrations/20260525005954_colapso_fase5_kill_switch_rollout_gradual.sql @@ -0,0 +1,79 @@ +-- ================================================================ +-- ROLLOUT GRADUAL DO KILL-SWITCH (A/B controlado) +-- +-- Permite desligar o switch progressivamente: 5% → 25% → 50% → 100% +-- usando hash determinístico do user_id/session_id como bucket. +-- +-- Estratégia: +-- 1. Coluna rollout_percentage em system_kill_switches (0-100) +-- 2. Função fn_should_apply_kill_switch(switch_name, bucket_key) → boolean +-- Retorna TRUE se a chave cai dentro do bucket de rollout (kill aplicado) +-- 3. View v_kill_switch_with_rollout para o front consultar +-- =============================================================== + +-- 1) Adicionar coluna de rollout +ALTER TABLE public.system_kill_switches + ADD COLUMN IF NOT EXISTS rollout_percentage smallint NOT NULL DEFAULT 100 + CHECK (rollout_percentage BETWEEN 0 AND 100); + +COMMENT ON COLUMN public.system_kill_switches.rollout_percentage IS +'Porcentagem de tráfego (0-100) que recebe o kill-switch quando enabled=false. +100 = todos os clientes (default). 5 = apenas 5% (canary). +Útil para A/B testing antes de desligar definitivamente.'; + +-- 2) Função pura de roteamento — determinística por hash do bucket_key +CREATE OR REPLACE FUNCTION public.fn_should_apply_kill_switch( + p_switch_name text, + p_bucket_key text +) RETURNS boolean +LANGUAGE plpgsql +STABLE +SECURITY INVOKER +SET search_path = public, pg_catalog +AS $$ +DECLARE + v_enabled boolean; + v_rollout smallint; + v_bucket int; +BEGIN + -- Lê estado do switch (RLS aplicável: anon tem SELECT) + SELECT enabled, rollout_percentage + INTO v_enabled, v_rollout + FROM public.system_kill_switches + WHERE switch_name = p_switch_name; + + -- Switch não cadastrado = não aplicar (fail-open) + IF NOT FOUND THEN RETURN false; END IF; + + -- Switch ATIVO (enabled=true) = nunca aplicar kill, independente de rollout + IF v_enabled THEN RETURN false; END IF; + + -- Switch OFF (enabled=false): aplicar kill conforme rollout % + IF v_rollout >= 100 THEN RETURN true; END IF; + IF v_rollout <= 0 THEN RETURN false; END IF; + + -- Hash determinístico → bucket 0-99 + -- Mesmo bucket_key sempre cai no mesmo bucket (estabilidade entre reloads) + v_bucket := abs(hashtext(coalesce(p_bucket_key, 'anonymous')) % 100); + RETURN v_bucket < v_rollout; +END; +$$; + +COMMENT ON FUNCTION public.fn_should_apply_kill_switch IS +'Decide se o kill-switch deve ser APLICADO (= bloquear chamada) para um bucket. +Determinístico por bucket_key (mesma chave sempre cai no mesmo grupo). +Lógica: + enabled=true → false (nunca bloqueia) + enabled=false, rollout=100 → true (sempre bloqueia) + enabled=false, rollout=0 → false (nunca bloqueia — pré-rollout) + enabled=false, rollout=X% → true se hashtext(bucket_key) % 100 < X +Bucket_key sugerido: auth.uid() para logged-in; localStorage uuid para anon.'; + +GRANT EXECUTE ON FUNCTION public.fn_should_apply_kill_switch TO anon; +GRANT EXECUTE ON FUNCTION public.fn_should_apply_kill_switch TO authenticated; + +-- 3) Validar: switch atual está enabled=true, então sempre retorna false +SELECT + public.fn_should_apply_kill_switch('edge_external_db_bridge', 'test-user-1') AS rollout_test_1, + public.fn_should_apply_kill_switch('edge_external_db_bridge', 'test-user-2') AS rollout_test_2, + public.fn_should_apply_kill_switch('non_existent_switch', 'anyone') AS unknown_switch; diff --git a/supabase/migrations/20260525021830_restore_set_quote_number_trigger.sql b/supabase/migrations/20260525021830_restore_set_quote_number_trigger.sql new file mode 100644 index 000000000..f58fbd4b4 --- /dev/null +++ b/supabase/migrations/20260525021830_restore_set_quote_number_trigger.sql @@ -0,0 +1,11 @@ +-- Restore the BEFORE INSERT trigger that auto-generates public.quotes.quote_number. +-- The trigger was lost during the migration-replay drift cleanup while +-- public.generate_quote_number() survived. Without it, quote_number (NOT NULL, +-- no default) is never populated and every quote INSERT from the app fails. +DROP TRIGGER IF EXISTS set_quote_number ON public.quotes; + +CREATE TRIGGER set_quote_number + BEFORE INSERT ON public.quotes + FOR EACH ROW + WHEN (NEW.quote_number IS NULL OR NEW.quote_number = '') + EXECUTE FUNCTION public.generate_quote_number();