diff --git a/scripts/ops/reclaim_supabase_swap.sql b/scripts/ops/reclaim_supabase_swap.sql new file mode 100644 index 0000000000..9717a8478b --- /dev/null +++ b/scripts/ops/reclaim_supabase_swap.sql @@ -0,0 +1,102 @@ +-- Capgo-EU Phase A reclaim (run manually in a maintenance window). +-- REQUIRED: psql (uses \gexec; VACUUM cannot run inside a transaction). +-- Prefer ~/.pgpass / PGPASSFILE instead of putting the password on the CLI. +-- Example: +-- psql "postgresql://postgres@HOST:5432/postgres?sslmode=require" -v ON_ERROR_STOP=1 -f scripts/ops/reclaim_supabase_swap.sql +-- Safe order: index -> truncate -> archives -> null manifests -> audit trim. +-- Re-run the FULL script until cleanup notices report deleted/updated = 0 +-- (functions always emit a notice, including zero totals). + +SET lock_timeout = '5s'; + +-- --------------------------------------------------------------------------- +-- 0) Baseline sizes +-- --------------------------------------------------------------------------- +SELECT pg_size_pretty(pg_database_size(current_database())::bigint) AS db_size; + +SELECT + relname, + n_live_tup, + pg_size_pretty(pg_total_relation_size(format('%I.%I', schemaname, relname)::regclass)::bigint) AS total +FROM pg_stat_user_tables +WHERE (schemaname, relname) IN ( + ('net', '_http_response'), + ('public', 'audit_logs'), + ('public', 'app_versions'), + ('public', 'manifest'), + ('pgmq', 'a_on_version_update'), + ('pgmq', 'a_on_manifest_create'), + ('pgmq', 'a_webhook_dispatcher'), + ('pgmq', 'a_on_channel_update') +) +ORDER BY pg_total_relation_size(format('%I.%I', schemaname, relname)::regclass) DESC; + +-- --------------------------------------------------------------------------- +-- 0b) Candidate index for hourly nulling (non-blocking; must be outside a tx) +-- --------------------------------------------------------------------------- +CREATE INDEX CONCURRENTLY IF NOT EXISTS app_versions_manifest_present_idx + ON public.app_versions USING btree (id) + WHERE manifest IS NOT NULL; + +-- --------------------------------------------------------------------------- +-- 1) Truncate pg_net response bloat +-- --------------------------------------------------------------------------- +TRUNCATE TABLE net._http_response; + +-- --------------------------------------------------------------------------- +-- 2) Purge pgmq archives/stuck messages. +-- Re-run the FULL script until archived_deleted=0 and stuck_deleted=0. +-- --------------------------------------------------------------------------- +SELECT public.cleanup_queue_messages(); + +-- Vacuum Capgo-EU evidenced bloated queues only. +VACUUM (VERBOSE) pgmq.a_on_version_update; +VACUUM (VERBOSE) pgmq.a_on_manifest_create; +VACUUM (VERBOSE) pgmq.a_webhook_dispatcher; +VACUUM (VERBOSE) pgmq.a_on_channel_update; +VACUUM (VERBOSE) pgmq.q_on_version_update; +VACUUM (VERBOSE) pgmq.q_on_manifest_create; +VACUUM (VERBOSE) pgmq.q_webhook_dispatcher; +VACUUM (VERBOSE) pgmq.q_on_channel_update; + +-- --------------------------------------------------------------------------- +-- 3) Null fully migrated app_versions.manifest arrays (s3_path + file_hash). +-- Re-run the FULL script until updated=0. +-- --------------------------------------------------------------------------- +SELECT public.null_migrated_app_version_manifests(); + +VACUUM (ANALYZE, VERBOSE) public.app_versions; +-- Optional TOAST compaction after updated=0: +-- VACUUM (FULL, VERBOSE) public.app_versions; + +-- --------------------------------------------------------------------------- +-- 4) Trim audit_logs older than 30 days. +-- Re-run the FULL script until deleted=0. +-- --------------------------------------------------------------------------- +SELECT public.cleanup_old_audit_logs(); + +VACUUM (ANALYZE, VERBOSE) public.audit_logs; +-- Optional TOAST compaction after deleted=0: +-- VACUUM (FULL, VERBOSE) public.audit_logs; + +-- --------------------------------------------------------------------------- +-- 5) Final sizes +-- --------------------------------------------------------------------------- +SELECT pg_size_pretty(pg_database_size(current_database())::bigint) AS db_size_after; + +SELECT + relname, + n_live_tup, + pg_size_pretty(pg_total_relation_size(format('%I.%I', schemaname, relname)::regclass)::bigint) AS total +FROM pg_stat_user_tables +WHERE (schemaname, relname) IN ( + ('net', '_http_response'), + ('public', 'audit_logs'), + ('public', 'app_versions'), + ('public', 'manifest'), + ('pgmq', 'a_on_version_update'), + ('pgmq', 'a_on_manifest_create'), + ('pgmq', 'a_webhook_dispatcher'), + ('pgmq', 'a_on_channel_update') +) +ORDER BY pg_total_relation_size(format('%I.%I', schemaname, relname)::regclass) DESC; diff --git a/scripts/ops/verify_supabase_swap.sql b/scripts/ops/verify_supabase_swap.sql new file mode 100644 index 0000000000..732908b759 --- /dev/null +++ b/scripts/ops/verify_supabase_swap.sql @@ -0,0 +1,121 @@ +-- Post-deploy / post-reclaim verification for Capgo-EU swap pressure. +-- REQUIRED: psql (uses \gexec). Example: +-- psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f scripts/ops/verify_supabase_swap.sql + +SELECT pg_size_pretty(pg_database_size(current_database())::bigint) AS db_size; + +SELECT + name, + setting, + unit +FROM pg_settings +WHERE name IN ('shared_buffers', 'work_mem', 'max_connections'); + +SELECT + relname, + n_live_tup, + pg_size_pretty(pg_total_relation_size(format('%I.%I', schemaname, relname)::regclass)::bigint) AS total +FROM pg_stat_user_tables +WHERE (schemaname, relname) IN ( + ('net', '_http_response'), + ('public', 'audit_logs'), + ('public', 'app_versions'), + ('public', 'manifest'), + ('pgmq', 'a_on_version_update'), + ('pgmq', 'a_on_manifest_create'), + ('pgmq', 'a_webhook_dispatcher'), + ('pgmq', 'a_on_channel_update') +) +ORDER BY pg_total_relation_size(format('%I.%I', schemaname, relname)::regclass) DESC; + +-- Sample first 1000 non-null manifests; a zero does not prove global completion. +SELECT count(*) AS eligible_dual_storage_sample +FROM ( + SELECT sample.id + FROM ( + SELECT av.id, av.manifest + FROM public.app_versions AS av + WHERE av.manifest IS NOT NULL + ORDER BY av.id + LIMIT 1000 + ) AS sample + WHERE NOT EXISTS ( + SELECT 1 + FROM unnest(sample.manifest) AS entry(file_name, s3_path, file_hash) + WHERE NOT EXISTS ( + SELECT 1 + FROM public.manifest AS m + WHERE m.app_version_id = sample.id + AND m.s3_path = entry.s3_path + AND m.file_hash = entry.file_hash + ) + ) +) AS eligible; + +SELECT + name, + enabled, + hour_interval, + run_at_hour, + run_at_minute, + target, + description, + updated_at +FROM public.cron_tasks +WHERE name IN ( + 'cleanup_queue_messages', + 'cleanup_net_http_response', + 'cleanup_old_audit_logs', + 'null_migrated_app_version_manifests' +) +ORDER BY name; + +SELECT indexname +FROM pg_indexes +WHERE schemaname = 'public' + AND indexname = 'app_versions_manifest_present_idx'; + +SELECT EXISTS ( + SELECT 1 + FROM public.audit_logs + WHERE created_at < now() - interval '30 days' + LIMIT 1 +) AS has_audit_logs_older_than_30d; + +SELECT format( + $fmt$SELECT %L AS queue_name, + EXISTS ( + SELECT 1 + FROM pgmq.%I + WHERE archived_at < now() - interval '2 days' + LIMIT 1 + ) AS has_rows_older_than_2d;$fmt$, + queue_name, + 'a_' || pg_catalog.lower(queue_name) +) +FROM pgmq.list_queues() +\gexec + +SELECT format( + $fmt$SELECT %L AS queue_name, + EXISTS ( + SELECT 1 + FROM pgmq.%I + WHERE read_ct > 5 + LIMIT 1 + ) AS has_stuck_read_ct_gt_5;$fmt$, + queue_name, + 'q_' || pg_catalog.lower(queue_name) +) +FROM pgmq.list_queues() +\gexec + +SELECT + 'index hit rate' AS name, + ROUND((sum(idx_blks_hit)::numeric / nullif(sum(idx_blks_hit + idx_blks_read), 0) * 100), 2) AS ratio +FROM pg_statio_user_indexes +UNION ALL +SELECT + 'table hit rate', + ROUND((sum(heap_blks_hit)::numeric / nullif(sum(heap_blks_hit) + sum(heap_blks_read), 0) * 100), 2) +FROM pg_statio_user_tables; diff --git a/src/services/versions.ts b/src/services/versions.ts index a04188e85f..3bed6f3b60 100644 --- a/src/services/versions.ts +++ b/src/services/versions.ts @@ -26,6 +26,7 @@ export function createBuiltinChannelVersion(channel: { cli_version: null, comment: null, created_at: channel.created_at, + created_by_apikey_rbac_id: null, deleted: false, deleted_at: null, external_url: null, diff --git a/supabase/migrations/20260722082019_fix_supabase_swap_memory.sql b/supabase/migrations/20260722082019_fix_supabase_swap_memory.sql new file mode 100644 index 0000000000..8b88de00e2 --- /dev/null +++ b/supabase/migrations/20260722082019_fix_supabase_swap_memory.sql @@ -0,0 +1,778 @@ +-- Reduce Capgo-EU swap pressure without upgrading compute: +-- 1) batched pgmq archive cleanup (2-day retention, hourly) +-- 2) reclaim net._http_response (truncate hourly) +-- 3) slim audit payloads + batched 30-day audit cleanup +-- 4) null leftover app_versions.manifest arrays after table migration +-- 5) also omit native_packages from app_versions queue payloads + +-- --------------------------------------------------------------------------- +-- Batched pgmq archive / stuck-message cleanup +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION "public"."cleanup_queue_messages"() RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + queue_name text; + cutoff timestamptz := pg_catalog.now() - INTERVAL '2 days'; + batch_size integer := 10000; + -- Hard cap of productive batches across ALL queues/phases for one invocation. + max_batches_total integer := 40; + batches_used integer := 0; + deleted_batch integer; + deleted_archived_total bigint := 0; + deleted_stuck_total bigint := 0; + did_work boolean; +BEGIN + -- Round-robin: at most one archive batch and one stuck batch per queue per pass, + -- so a busy first queue cannot starve later queues within the global budget. + LOOP + EXIT WHEN batches_used >= max_batches_total; + did_work := false; + + FOR queue_name IN ( + SELECT q.queue_name FROM pgmq.list_queues() q + ) LOOP + EXIT WHEN batches_used >= max_batches_total; + + EXECUTE pg_catalog.format( + 'DELETE FROM pgmq.a_%I + WHERE ctid IN ( + SELECT ctid + FROM pgmq.a_%I + WHERE archived_at < $1 + LIMIT $2 + )', + queue_name, + queue_name + ) + USING cutoff, batch_size; + + GET DIAGNOSTICS deleted_batch = ROW_COUNT; + IF deleted_batch > 0 THEN + batches_used := batches_used + 1; + deleted_archived_total := deleted_archived_total + deleted_batch; + did_work := true; + END IF; + + IF batches_used >= max_batches_total THEN + EXIT; -- leave queue loop; do not start stuck deletes over budget + END IF; + + EXECUTE pg_catalog.format( + 'DELETE FROM pgmq.q_%I + WHERE ctid IN ( + SELECT ctid + FROM pgmq.q_%I + WHERE read_ct > 5 + LIMIT $1 + )', + queue_name, + queue_name + ) + USING batch_size; + + GET DIAGNOSTICS deleted_batch = ROW_COUNT; + IF deleted_batch > 0 THEN + batches_used := batches_used + 1; + deleted_stuck_total := deleted_stuck_total + deleted_batch; + did_work := true; + END IF; + END LOOP; + + EXIT WHEN NOT did_work; + END LOOP; + + RAISE NOTICE + 'cleanup_queue_messages: archived_deleted=% stuck_deleted=% batches_used=%/%', + deleted_archived_total, + deleted_stuck_total, + batches_used, + max_batches_total; +END; +$$; + +ALTER FUNCTION "public"."cleanup_queue_messages"() OWNER TO "postgres"; +REVOKE ALL ON FUNCTION "public"."cleanup_queue_messages"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."cleanup_queue_messages"() TO "service_role"; + +-- --------------------------------------------------------------------------- +-- Reclaim pg_net response bloat (DELETE alone never shrinks the table) +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION "public"."cleanup_net_http_response"() RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +BEGIN + -- Responses are only used for short-lived async HTTP debugging. + -- Truncate reclaims disk; row deletes do not. + TRUNCATE TABLE net._http_response; + RAISE NOTICE 'cleanup_net_http_response: truncated net._http_response'; +END; +$$; + +ALTER FUNCTION "public"."cleanup_net_http_response"() OWNER TO "postgres"; +REVOKE ALL ON FUNCTION "public"."cleanup_net_http_response"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."cleanup_net_http_response"() TO "service_role"; + +-- --------------------------------------------------------------------------- +-- Batched audit log cleanup (30-day retention) +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION "public"."cleanup_old_audit_logs"() RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + cutoff timestamptz := pg_catalog.now() - INTERVAL '30 days'; + batch_size integer := 5000; + max_batches integer := 40; + batch_no integer := 0; + deleted_batch integer; + deleted_total bigint := 0; +BEGIN + LOOP + batch_no := batch_no + 1; + EXIT WHEN batch_no > max_batches; + + DELETE FROM public.audit_logs + WHERE ctid IN ( + SELECT ctid + FROM public.audit_logs + WHERE created_at < cutoff + LIMIT batch_size + ); + + GET DIAGNOSTICS deleted_batch = ROW_COUNT; + deleted_total := deleted_total + deleted_batch; + EXIT WHEN deleted_batch = 0; + END LOOP; + + RAISE NOTICE 'cleanup_old_audit_logs: deleted=% max_batches=%', deleted_total, max_batches; +END; +$$; + +ALTER FUNCTION "public"."cleanup_old_audit_logs"() OWNER TO "postgres"; +REVOKE ALL ON FUNCTION "public"."cleanup_old_audit_logs"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."cleanup_old_audit_logs"() TO "service_role"; + +-- --------------------------------------------------------------------------- +-- Null leftover dual-storage app_versions.manifest arrays +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION "public"."null_migrated_app_version_manifests"() RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + batch_size integer := 200; + max_batches integer := 50; + batch_no integer := 0; + updated_batch integer; + updated_total bigint := 0; +BEGIN + LOOP + batch_no := batch_no + 1; + EXIT WHEN batch_no > max_batches; + + WITH doomed AS ( + SELECT av.id + FROM public.app_versions AS av + WHERE av.manifest IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.unnest(av.manifest) AS entry(file_name, s3_path, file_hash) + WHERE NOT EXISTS ( + SELECT 1 + FROM public.manifest AS m + WHERE m.app_version_id = av.id + -- Match stable identity; file_name may have been normalized at migrate time. + AND m.s3_path = entry.s3_path + AND m.file_hash = entry.file_hash + ) + ) + ORDER BY av.id + LIMIT batch_size + ) + UPDATE public.app_versions AS av + SET manifest = NULL + FROM doomed + WHERE av.id = doomed.id; + + GET DIAGNOSTICS updated_batch = ROW_COUNT; + updated_total := updated_total + updated_batch; + EXIT WHEN updated_batch = 0; + END LOOP; + + RAISE NOTICE + 'null_migrated_app_version_manifests: updated=% max_batches=%', + updated_total, + max_batches; +END; +$$; + +ALTER FUNCTION "public"."null_migrated_app_version_manifests"() OWNER TO "postgres"; +REVOKE ALL ON FUNCTION "public"."null_migrated_app_version_manifests"() FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."null_migrated_app_version_manifests"() TO "service_role"; + +-- --------------------------------------------------------------------------- +-- Slim audit payloads (app_versions fat columns) +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION "public"."audit_log_trigger"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_old_record jsonb; + v_new_record jsonb; + v_changed_fields text[]; + v_org_id uuid; + v_record_id text; + v_user_id uuid; + v_key text; + v_api_key_text text; + v_api_key public.apikeys%ROWTYPE; + v_actor_type text := 'system'; + v_actor_user_id uuid; + v_actor_user_email text; + v_actor_apikey_id bigint; + v_actor_apikey_name text; + v_stats_refresh_fields constant text[] := ARRAY['stats_refresh_requested_at', 'stats_updated_at', 'updated_at']; + v_background_counter_fields constant text[] := ARRAY['channel_device_count', 'manifest_bundle_count', 'updated_at']; + v_fat_app_version_fields constant text[] := ARRAY['manifest', 'native_packages']; +BEGIN + SELECT auth.uid() INTO v_actor_user_id; + + IF v_actor_user_id IS NOT NULL THEN + v_actor_type := 'user'; + ELSE + SELECT public.get_apikey_header() INTO v_api_key_text; + + IF v_api_key_text IS NOT NULL THEN + SELECT * + INTO v_api_key + FROM public.find_apikey_by_value(v_api_key_text) + LIMIT 1; + + -- Attribute only valid, write-capable API keys; a read-only key present on + -- a request must not be recorded as the actor of a mutation. + IF v_api_key.id IS NOT NULL + AND NOT public.is_apikey_expired(v_api_key.expires_at) + AND ( + public.is_allowed_capgkey(v_api_key_text, '{upload}'::text[]) + OR public.is_allowed_capgkey(v_api_key_text, '{write}'::text[]) + OR public.is_allowed_capgkey(v_api_key_text, '{all}'::text[]) + ) THEN + v_actor_type := 'apikey'; + v_actor_user_id := v_api_key.user_id; + v_actor_apikey_id := v_api_key.id; + v_actor_apikey_name := v_api_key.name; + END IF; + END IF; + END IF; + + IF v_actor_user_id IS NOT NULL THEN + SELECT users.email + INTO v_actor_user_email + FROM public.users AS users + WHERE users.id = v_actor_user_id; + END IF; + + v_user_id := v_actor_user_id; + + IF TG_OP = 'DELETE' THEN + v_old_record := pg_catalog.to_jsonb(OLD); + v_new_record := NULL; + ELSIF TG_OP = 'INSERT' THEN + v_old_record := NULL; + v_new_record := pg_catalog.to_jsonb(NEW); + ELSE + v_old_record := pg_catalog.to_jsonb(OLD); + v_new_record := pg_catalog.to_jsonb(NEW); + + FOR v_key IN SELECT pg_catalog.jsonb_object_keys(v_new_record) + LOOP + IF v_old_record->v_key IS DISTINCT FROM v_new_record->v_key THEN + v_changed_fields := pg_catalog.array_append(v_changed_fields, v_key); + END IF; + END LOOP; + + IF TG_TABLE_NAME = ANY(ARRAY['apps', 'orgs']) + AND v_changed_fields && ARRAY['stats_refresh_requested_at', 'stats_updated_at'] + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.unnest(v_changed_fields) AS changed_field(field_name) + WHERE changed_field.field_name <> ALL(v_stats_refresh_fields) + ) THEN + RETURN NEW; + END IF; + + IF v_actor_type = 'system' + AND TG_TABLE_NAME = 'apps' + AND v_changed_fields && ARRAY['channel_device_count', 'manifest_bundle_count'] + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.unnest(v_changed_fields) AS changed_field(field_name) + WHERE changed_field.field_name <> ALL(v_background_counter_fields) + ) THEN + RETURN NEW; + END IF; + END IF; + + -- Never persist multi-MB array/json columns in audit TOAST. + -- Keep fat field names in changed_fields so upload-time edits remain visible. + IF TG_TABLE_NAME = 'app_versions' THEN + IF v_old_record IS NOT NULL THEN + v_old_record := v_old_record - v_fat_app_version_fields; + END IF; + IF v_new_record IS NOT NULL THEN + v_new_record := v_new_record - v_fat_app_version_fields; + END IF; + + -- Skip audit only for dual-storage reclaim: non-null manifest -> NULL (+ updated_at). + IF TG_OP = 'UPDATE' + AND OLD.manifest IS NOT NULL + AND NEW.manifest IS NULL + AND NEW.native_packages IS NOT DISTINCT FROM OLD.native_packages + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.unnest(COALESCE(v_changed_fields, ARRAY[]::text[])) AS changed_field(field_name) + WHERE changed_field.field_name <> ALL(ARRAY['manifest', 'updated_at']::text[]) + ) THEN + RETURN NEW; + END IF; + END IF; + + CASE TG_TABLE_NAME + WHEN 'orgs' THEN + v_org_id := COALESCE(NEW.id, OLD.id); + v_record_id := COALESCE(NEW.id, OLD.id)::text; + WHEN 'apps' THEN + v_org_id := COALESCE(NEW.owner_org, OLD.owner_org); + v_record_id := COALESCE(NEW.app_id, OLD.app_id)::text; + WHEN 'channels' THEN + v_org_id := COALESCE(NEW.owner_org, OLD.owner_org); + v_record_id := COALESCE(NEW.id, OLD.id)::text; + WHEN 'app_versions' THEN + v_org_id := COALESCE(NEW.owner_org, OLD.owner_org); + v_record_id := COALESCE(NEW.id, OLD.id)::text; + WHEN 'org_users' THEN + v_org_id := COALESCE(NEW.org_id, OLD.org_id); + v_record_id := COALESCE(NEW.id, OLD.id)::text; + ELSE + v_org_id := NULL; + v_record_id := NULL; + END CASE; + + IF v_org_id IS NOT NULL THEN + INSERT INTO public.audit_logs ( + table_name, + record_id, + operation, + user_id, + org_id, + old_record, + new_record, + changed_fields, + actor_type, + actor_user_id, + actor_user_email, + actor_apikey_id, + actor_apikey_name + ) VALUES ( + TG_TABLE_NAME, + v_record_id, + TG_OP, + v_user_id, + v_org_id, + v_old_record, + v_new_record, + v_changed_fields, + v_actor_type, + v_actor_user_id, + v_actor_user_email, + v_actor_apikey_id, + v_actor_apikey_name + ); + END IF; + + RETURN COALESCE(NEW, OLD); +END; +$$; + +ALTER FUNCTION "public"."audit_log_trigger"() OWNER TO "postgres"; + +-- --------------------------------------------------------------------------- +-- Also omit native_packages from app_versions queue payloads +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION "public"."trigger_http_queue_post_to_function"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + payload jsonb; + record_payload jsonb; + old_record_payload jsonb; + function_type text; +BEGIN + function_type := CASE + WHEN NULLIF(TG_ARGV[1], '') IS NULL THEN 'cloudflare' + WHEN pg_catalog.lower(TG_ARGV[1]) = 'supabase' THEN 'cloudflare' + ELSE TG_ARGV[1] + END; + + record_payload := pg_catalog.to_jsonb(NEW); + old_record_payload := pg_catalog.to_jsonb(OLD); + + -- app_versions fat columns can be multi-MB. Never enqueue them; handlers reload when needed. + IF TG_TABLE_NAME = 'app_versions' THEN + IF record_payload IS NOT NULL THEN + record_payload := record_payload - 'manifest' - 'native_packages'; + END IF; + IF old_record_payload IS NOT NULL THEN + old_record_payload := old_record_payload - 'manifest' - 'native_packages'; + END IF; + + -- Skip queue fan-out only for dual-storage reclaim: non-null manifest -> NULL. + -- native_packages is stripped from payloads, so compare it explicitly. + IF TG_OP = 'UPDATE' + AND OLD.manifest IS NOT NULL + AND NEW.manifest IS NULL + AND NEW.native_packages IS NOT DISTINCT FROM OLD.native_packages + AND (record_payload - 'updated_at') IS NOT DISTINCT FROM (old_record_payload - 'updated_at') + THEN + RETURN NEW; + END IF; + END IF; + + payload := pg_catalog.jsonb_build_object( + 'function_name', TG_ARGV[0], + 'function_type', function_type, + 'payload', pg_catalog.jsonb_build_object( + 'old_record', old_record_payload, + 'record', record_payload, + 'type', TG_OP, + 'table', TG_TABLE_NAME, + 'schema', TG_TABLE_SCHEMA + ) + ); + + IF TG_ARGV[0] IS NOT NULL THEN + PERFORM "pgmq"."send"(TG_ARGV[0], payload); + END IF; + RETURN NEW; +END; +$$; + +ALTER FUNCTION "public"."trigger_http_queue_post_to_function"() OWNER TO "postgres"; + +-- --------------------------------------------------------------------------- +-- Cron schedules: make reclaim jobs hourly / reliable +-- --------------------------------------------------------------------------- +UPDATE public.cron_tasks +SET + second_interval = NULL, + minute_interval = NULL, + hour_interval = 1, + run_at_hour = NULL, + run_at_minute = 0, + run_at_second = NULL, + updated_at = pg_catalog.now() +WHERE name = 'cleanup_queue_messages'; + +UPDATE public.cron_tasks +SET + second_interval = NULL, + minute_interval = NULL, + hour_interval = NULL, + run_at_hour = 3, + run_at_minute = 0, + run_at_second = 0, + updated_at = pg_catalog.now() +WHERE name = 'cleanup_old_audit_logs'; + +INSERT INTO public.cron_tasks ( + name, + description, + task_type, + target, + batch_size, + payload, + second_interval, + minute_interval, + hour_interval, + run_at_hour, + run_at_minute, + run_at_second, + run_on_dow, + run_on_day, + enabled +) +VALUES + ( + 'cleanup_net_http_response', + 'Truncate net._http_response so pg_net response history cannot bloat disk/RAM', + 'function', + 'public.cleanup_net_http_response()', + NULL, + NULL, + NULL, + NULL, + 1, + NULL, + 5, + NULL, + NULL, + NULL, + true + ), + ( + 'null_migrated_app_version_manifests', + 'Null leftover app_versions.manifest arrays after rows exist in public.manifest', + 'function', + 'public.null_migrated_app_version_manifests()', + NULL, + NULL, + NULL, + NULL, + 1, + NULL, + 15, + NULL, + NULL, + NULL, + true + ) +ON CONFLICT (name) DO UPDATE +SET + description = EXCLUDED.description, + task_type = EXCLUDED.task_type, + target = EXCLUDED.target, + hour_interval = EXCLUDED.hour_interval, + run_at_minute = EXCLUDED.run_at_minute, + run_at_hour = NULL, + second_interval = NULL, + minute_interval = NULL, + enabled = true, + updated_at = pg_catalog.now(); + + + +UPDATE public.cron_tasks +SET + description = 'Delete audit_logs older than 30 days in bounded batches', + updated_at = pg_catalog.now() +WHERE name = 'cleanup_old_audit_logs'; + + +-- --------------------------------------------------------------------------- +-- Allow clearing dual-storage app_versions.manifest after upload (null only). +-- native_packages stays locked: no alternate persisted source of truth. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION "public"."check_encrypted_bundle_on_insert"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + org_id uuid; + org_enforcing boolean; + org_required_key varchar(21); + bundle_is_encrypted boolean; + bundle_key_id varchar(20); + bundle_was_ready boolean; +BEGIN + IF TG_OP = 'UPDATE' THEN + -- Never drop the only copy of legacy file metadata, ready or not. + IF NEW.manifest IS NULL + AND OLD.manifest IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM pg_catalog.unnest(OLD.manifest) AS entry(file_name, s3_path, file_hash) + WHERE NOT EXISTS ( + SELECT 1 + FROM public.manifest AS m + WHERE m.app_version_id = OLD.id + -- Match stable identity; file_name may have been normalized at migrate time. + AND m.s3_path = entry.s3_path + AND m.file_hash = entry.file_hash + ) + ) + THEN + RAISE EXCEPTION '%', + 'bundle_manifest_not_migrated: Cannot clear app_versions.manifest ' + || 'until every entry exists in public.manifest.'; + END IF; + + bundle_was_ready := OLD.storage_provider IS DISTINCT FROM 'r2-direct'; + + -- Nulling a fully migrated dual-storage manifest array is allowed after upload. + -- native_packages remains locked (compatibility metadata has no table copy). + -- Rewriting non-null manifest content stays locked. + IF bundle_was_ready + AND ( + NEW.name IS DISTINCT FROM OLD.name + OR NEW.app_id IS DISTINCT FROM OLD.app_id + OR NEW.session_key IS DISTINCT FROM OLD.session_key + OR NEW.key_id IS DISTINCT FROM OLD.key_id + OR NEW.storage_provider IS DISTINCT FROM OLD.storage_provider + OR NEW.r2_path IS DISTINCT FROM OLD.r2_path + OR NEW.external_url IS DISTINCT FROM OLD.external_url + OR NEW.checksum IS DISTINCT FROM OLD.checksum + OR (NEW.manifest IS DISTINCT FROM OLD.manifest AND NEW.manifest IS NOT NULL) + -- Nulling is allowed only when public.manifest has every expected entry. + OR ( + NEW.manifest IS NULL + AND OLD.manifest IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM pg_catalog.unnest(OLD.manifest) AS entry(file_name, s3_path, file_hash) + WHERE NOT EXISTS ( + SELECT 1 + FROM public.manifest AS m + WHERE m.app_version_id = OLD.id + -- Match stable identity; file_name may have been normalized at migrate time. + AND m.s3_path = entry.s3_path + AND m.file_hash = entry.file_hash + ) + ) + ) + OR NEW.native_packages IS DISTINCT FROM OLD.native_packages + ) + THEN + PERFORM public.pg_log('deny: BUNDLE_CONTENT_LOCKED_TRIGGER', + pg_catalog.jsonb_build_object( + 'org_id', OLD.owner_org, + 'app_id', OLD.app_id, + 'version_name', OLD.name, + 'user_id', OLD.user_id, + 'old_storage_provider', OLD.storage_provider, + 'new_storage_provider', NEW.storage_provider, + 'reason', 'bundle_ready' + )); + RAISE EXCEPTION '%', + 'bundle_already_ready: Bundle content cannot be changed ' + || 'after upload is complete. Upload a new bundle instead.'; + END IF; + END IF; + + -- Fully migrated dual-storage nulling must not re-run encryption enforcement. + -- Incomplete nulling (still missing public.manifest rows) must not bypass checks, + -- including for in-progress r2-direct uploads. + IF TG_OP = 'UPDATE' + AND NEW.session_key IS NOT DISTINCT FROM OLD.session_key + AND NEW.key_id IS NOT DISTINCT FROM OLD.key_id + AND NEW.name IS NOT DISTINCT FROM OLD.name + AND NEW.app_id IS NOT DISTINCT FROM OLD.app_id + AND NEW.storage_provider IS NOT DISTINCT FROM OLD.storage_provider + AND NEW.r2_path IS NOT DISTINCT FROM OLD.r2_path + AND NEW.external_url IS NOT DISTINCT FROM OLD.external_url + AND NEW.checksum IS NOT DISTINCT FROM OLD.checksum + AND NEW.native_packages IS NOT DISTINCT FROM OLD.native_packages + AND ( + NEW.manifest IS NOT DISTINCT FROM OLD.manifest + OR ( + NEW.manifest IS NULL + AND OLD.manifest IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.unnest(OLD.manifest) AS entry(file_name, s3_path, file_hash) + WHERE NOT EXISTS ( + SELECT 1 + FROM public.manifest AS m + WHERE m.app_version_id = OLD.id + -- Match stable identity; file_name may have been normalized at migrate time. + AND m.s3_path = entry.s3_path + AND m.file_hash = entry.file_hash + ) + ) + ) + ) + THEN + RETURN NEW; + END IF; + + -- Derive org_id from NEW.app_id first because + -- force_valid_owner_org_app_versions runs after this trigger. + SELECT apps.owner_org INTO org_id + FROM public.apps + WHERE apps.app_id = NEW.app_id; + + IF org_id IS NULL THEN + org_id := NEW.owner_org; + END IF; + + -- If org not found, allow the existing foreign-key/owner checks to fail. + IF org_id IS NULL THEN + RETURN NEW; + END IF; + + SELECT enforce_encrypted_bundles, required_encryption_key + INTO org_enforcing, org_required_key + FROM public.orgs + WHERE id = org_id; + + IF org_enforcing IS NULL OR org_enforcing = false THEN + RETURN NEW; + END IF; + + bundle_is_encrypted := public.is_bundle_encrypted(NEW.session_key); + bundle_key_id := NULLIF(pg_catalog.btrim(NEW.key_id), '')::varchar(20); + + IF NOT bundle_is_encrypted THEN + PERFORM public.pg_log('deny: ORG_REQUIRES_ENCRYPTED_BUNDLES_TRIGGER', + pg_catalog.jsonb_build_object( + 'org_id', org_id, + 'app_id', NEW.app_id, + 'version_name', NEW.name, + 'user_id', NEW.user_id, + 'reason', 'not_encrypted' + )); + RAISE EXCEPTION '%', + 'encryption_required: This organization requires all bundles to be ' + || 'encrypted. Please upload an encrypted bundle with a session_key.'; + END IF; + + IF org_required_key IS NOT NULL AND org_required_key <> '' THEN + IF bundle_key_id IS NULL THEN + PERFORM public.pg_log('deny: ORG_REQUIRES_SPECIFIC_ENCRYPTION_KEY_TRIGGER', + pg_catalog.jsonb_build_object( + 'org_id', org_id, + 'app_id', NEW.app_id, + 'version_name', NEW.name, + 'user_id', NEW.user_id, + 'required_key', org_required_key, + 'bundle_key_id', bundle_key_id, + 'reason', 'missing_key_id' + )); + RAISE EXCEPTION '%', + 'encryption_key_required: This organization requires bundles to be ' + || 'encrypted with a specific key. The uploaded bundle does not have ' + || 'a key_id.'; + END IF; + + -- key_id is 20 chars and required_encryption_key may be 20 or 21 chars. + IF NOT ( + bundle_key_id = pg_catalog.left(org_required_key, 20) + OR pg_catalog.left(bundle_key_id, pg_catalog.length(org_required_key)) = org_required_key + ) THEN + PERFORM public.pg_log('deny: ORG_REQUIRES_SPECIFIC_ENCRYPTION_KEY_TRIGGER', + pg_catalog.jsonb_build_object( + 'org_id', org_id, + 'app_id', NEW.app_id, + 'version_name', NEW.name, + 'user_id', NEW.user_id, + 'required_key', org_required_key, + 'bundle_key_id', bundle_key_id, + 'reason', 'key_mismatch' + )); + RAISE EXCEPTION '%', + 'encryption_key_mismatch: This organization requires bundles to be ' + || 'encrypted with a specific key. The uploaded bundle was encrypted ' + || 'with a different key.'; + END IF; + END IF; + + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."check_encrypted_bundle_on_insert"() OWNER TO "postgres"; diff --git a/supabase/tests/55_test_audit_log_cleanup_cron.sql b/supabase/tests/55_test_audit_log_cleanup_cron.sql index 26d785990e..8ebc57e567 100644 --- a/supabase/tests/55_test_audit_log_cleanup_cron.sql +++ b/supabase/tests/55_test_audit_log_cleanup_cron.sql @@ -68,7 +68,7 @@ INSERT INTO public.audit_logs ( ) VALUES ( - now() - interval '91 days', + now() - interval '31 days', 'audit_log_retention_test', 'audit-log-retention-old', 'INSERT', @@ -79,7 +79,7 @@ VALUES ARRAY['retention_probe']::text [] ), ( - now() - interval '89 days', + now() - interval '29 days', 'audit_log_retention_test', 'audit-log-retention-fresh', 'INSERT', @@ -101,7 +101,7 @@ SELECT is( AND table_name = 'audit_log_retention_test' ), 0, - 'cleanup_old_audit_logs deletes rows older than 90 days' + 'cleanup_old_audit_logs deletes rows older than 30 days' ); SELECT is( @@ -113,7 +113,7 @@ SELECT is( AND table_name = 'audit_log_retention_test' ), 1, - 'cleanup_old_audit_logs keeps rows newer than 90 days' + 'cleanup_old_audit_logs keeps rows newer than 30 days' ); SELECT tests.clear_authentication(); diff --git a/tests/audit-logs.test.ts b/tests/audit-logs.test.ts index 4f8fe68956..7ae360b020 100644 --- a/tests/audit-logs.test.ts +++ b/tests/audit-logs.test.ts @@ -558,6 +558,9 @@ describe('audit logs for app_versions via API key', () => { expect(versionAuditLog.new_record).toBeTruthy() if (versionAuditLog.new_record && typeof versionAuditLog.new_record === 'object') { expect((versionAuditLog.new_record as Record).name).toBe(testVersionName) + // Fat columns must stay out of audit TOAST to protect primary DB memory. + expect((versionAuditLog.new_record as Record).manifest).toBeUndefined() + expect((versionAuditLog.new_record as Record).native_packages).toBeUndefined() } } } diff --git a/tests/cleanup_swap_memory.test.ts b/tests/cleanup_swap_memory.test.ts new file mode 100644 index 0000000000..6e3d00f73a --- /dev/null +++ b/tests/cleanup_swap_memory.test.ts @@ -0,0 +1,285 @@ +import { randomUUID } from 'node:crypto' +import { afterAll, describe, expect, it } from 'vitest' +import { cleanupPostgresClient, executeSQL } from './test-utils.ts' + +describe('swap memory cleanup functions', () => { + afterAll(async () => { + await cleanupPostgresClient() + }) + + it('cleanup_queue_messages deletes archived rows older than 2 days in batches', async () => { + const marker = `swap-cleanup-${randomUUID()}` + const baseMsgId = BigInt(Date.now()) * 1000n + + await executeSQL( + `INSERT INTO pgmq.a_on_version_update (msg_id, read_ct, enqueued_at, archived_at, vt, message) + VALUES + ($1, 0, now() - interval '10 days', now() - interval '10 days', now(), $3::jsonb), + ($2, 0, now() - interval '1 hour', now() - interval '1 hour', now(), $4::jsonb)`, + [ + (baseMsgId + 1n).toString(), + (baseMsgId + 2n).toString(), + JSON.stringify({ marker, age: 'old' }), + JSON.stringify({ marker, age: 'fresh' }), + ], + ) + + await executeSQL(`SELECT public.cleanup_queue_messages()`) + + const rows = await executeSQL( + `SELECT message->>'age' AS age + FROM pgmq.a_on_version_update + WHERE message->>'marker' = $1 + ORDER BY age`, + [marker], + ) + + expect(rows).toHaveLength(1) + expect(rows[0]?.age).toBe('fresh') + + await executeSQL( + `DELETE FROM pgmq.a_on_version_update WHERE message->>'marker' = $1`, + [marker], + ) + }) + + it('cleanup_net_http_response truncates net._http_response', async () => { + const id = BigInt(Date.now()) * 1000n + 7n + await executeSQL( + `INSERT INTO net._http_response (id, status_code, content, created) + VALUES ($1, 200, 'swap-cleanup-test', now())`, + [id.toString()], + ) + + await executeSQL(`SELECT public.cleanup_net_http_response()`) + + const rows = await executeSQL( + `SELECT count(*)::int AS n FROM net._http_response`, + ) + expect(rows[0]?.n).toBe(0) + }) + + it('null_migrated_app_version_manifests clears fully migrated dual-storage arrays', async () => { + const appId = `com.swap.nullmanifest.${randomUUID().slice(0, 8)}` + const orgRows = await executeSQL( + `SELECT id FROM public.orgs ORDER BY created_at LIMIT 1`, + ) + const orgId = orgRows[0]?.id as string + expect(orgId).toBeTruthy() + + await executeSQL( + `INSERT INTO public.apps (app_id, name, icon_url, owner_org) + VALUES ($1, 'swap-null-manifest', '', $2::uuid)`, + [appId, orgId], + ) + + const versionRows = await executeSQL( + `INSERT INTO public.app_versions (app_id, name, owner_org, storage_provider, manifest, manifest_count) + VALUES ( + $1, + $2, + $3::uuid, + 'r2', + ARRAY[ROW('index.html', 'apps/test/index.html', 'abc123')::public.manifest_entry], + 1 + ) + RETURNING id`, + [appId, `1.0.0-${randomUUID().slice(0, 8)}`, orgId], + ) + const versionId = versionRows[0]?.id as number + expect(versionId).toBeTruthy() + + await executeSQL( + `INSERT INTO public.manifest (app_version_id, file_name, s3_path, file_hash) + VALUES ($1, 'index.html', 'apps/test/index.html', 'abc123')`, + [versionId], + ) + + await executeSQL(`SELECT public.null_migrated_app_version_manifests()`) + + const after = await executeSQL( + `SELECT manifest IS NULL AS is_null, manifest_count + FROM public.app_versions + WHERE id = $1`, + [versionId], + ) + expect(after[0]?.is_null).toBe(true) + expect(after[0]?.manifest_count).toBe(1) + + await executeSQL(`DELETE FROM public.manifest WHERE app_version_id = $1`, [versionId]) + await executeSQL(`DELETE FROM public.app_versions WHERE id = $1`, [versionId]) + await executeSQL(`DELETE FROM public.apps WHERE app_id = $1`, [appId]) + }) + + it('audit_log_trigger strips fat app_versions fields', async () => { + const appId = `com.swap.auditfat.${randomUUID().slice(0, 8)}` + const orgRows = await executeSQL( + `SELECT id FROM public.orgs ORDER BY created_at LIMIT 1`, + ) + const orgId = orgRows[0]?.id as string + + await executeSQL( + `INSERT INTO public.apps (app_id, name, icon_url, owner_org) + VALUES ($1, 'swap-audit', '', $2::uuid)`, + [appId, orgId], + ) + + const versionRows = await executeSQL( + `INSERT INTO public.app_versions (app_id, name, owner_org, storage_provider, comment) + VALUES ($1, $2, $3::uuid, 'r2-direct', 'before') + RETURNING id`, + [appId, `1.0.0-${randomUUID().slice(0, 8)}`, orgId], + ) + const versionId = versionRows[0]?.id as number + + await executeSQL( + `UPDATE public.app_versions + SET + comment = 'after', + manifest = ARRAY[ROW('a.js', 'apps/a.js', 'hash')::public.manifest_entry], + native_packages = ARRAY['{"name":"cordova-plugin"}'::jsonb] + WHERE id = $1`, + [versionId], + ) + + const logs = await executeSQL( + `SELECT new_record, changed_fields + FROM public.audit_logs + WHERE table_name = 'app_versions' + AND record_id = $1 + AND operation = 'UPDATE' + ORDER BY id DESC + LIMIT 1`, + [String(versionId)], + ) + + expect(logs).toHaveLength(1) + expect(logs[0]?.new_record?.manifest).toBeUndefined() + expect(logs[0]?.new_record?.native_packages).toBeUndefined() + expect(logs[0]?.new_record?.comment).toBe('after') + expect(logs[0]?.changed_fields).toContain('comment') + // Fat payloads stay stripped, but field names remain for upload-time history. + expect(logs[0]?.changed_fields).toContain('manifest') + expect(logs[0]?.changed_fields).toContain('native_packages') + + await executeSQL(`DELETE FROM public.audit_logs WHERE record_id = $1 AND table_name = 'app_versions'`, [String(versionId)]) + await executeSQL(`DELETE FROM public.app_versions WHERE id = $1`, [versionId]) + await executeSQL(`DELETE FROM public.apps WHERE app_id = $1`, [appId]) + }) + + it('null_migrated_app_version_manifests skips partially migrated arrays', async () => { + const appId = `com.swap.partialmanifest.${randomUUID().slice(0, 8)}` + const orgRows = await executeSQL( + `SELECT id FROM public.orgs ORDER BY created_at LIMIT 1`, + ) + const orgId = orgRows[0]?.id as string + + await executeSQL( + `INSERT INTO public.apps (app_id, name, icon_url, owner_org) + VALUES ($1, 'swap-partial-manifest', '', $2::uuid)`, + [appId, orgId], + ) + + const versionRows = await executeSQL( + `INSERT INTO public.app_versions (app_id, name, owner_org, storage_provider, manifest, manifest_count) + VALUES ( + $1, + $2, + $3::uuid, + 'r2', + ARRAY[ + ROW('index.html', 'apps/test/index.html', 'abc123')::public.manifest_entry, + ROW('main.js', 'apps/test/main.js', 'def456')::public.manifest_entry + ], + 2 + ) + RETURNING id`, + [appId, `1.0.0-${randomUUID().slice(0, 8)}`, orgId], + ) + const versionId = versionRows[0]?.id as number + + // Only one of two files migrated. + await executeSQL( + `INSERT INTO public.manifest (app_version_id, file_name, s3_path, file_hash) + VALUES ($1, 'index.html', 'apps/test/index.html', 'abc123')`, + [versionId], + ) + + await executeSQL(`SELECT public.null_migrated_app_version_manifests()`) + + const after = await executeSQL( + `SELECT manifest IS NULL AS is_null, cardinality(manifest) AS n + FROM public.app_versions + WHERE id = $1`, + [versionId], + ) + expect(after[0]?.is_null).toBe(false) + expect(after[0]?.n).toBe(2) + + await executeSQL(`DELETE FROM public.manifest WHERE app_version_id = $1`, [versionId]) + await executeSQL(`DELETE FROM public.app_versions WHERE id = $1`, [versionId]) + await executeSQL(`DELETE FROM public.apps WHERE app_id = $1`, [appId]) + }) + + it('null_migrated_app_version_manifests works when org requires encryption', async () => { + const appId = `com.swap.encnull.${randomUUID().slice(0, 8)}` + const orgRows = await executeSQL( + `SELECT id FROM public.orgs ORDER BY created_at LIMIT 1`, + ) + const orgId = orgRows[0]?.id as string + + await executeSQL( + `INSERT INTO public.apps (app_id, name, icon_url, owner_org) + VALUES ($1, 'swap-enc-null', '', $2::uuid)`, + [appId, orgId], + ) + + // Create an unencrypted ready bundle while enforcement is off. + const versionRows = await executeSQL( + `INSERT INTO public.app_versions (app_id, name, owner_org, storage_provider, session_key, manifest, manifest_count) + VALUES ( + $1, + $2, + $3::uuid, + 'r2', + NULL, + ARRAY[ROW('index.html', 'apps/test/index.html', 'abc123')::public.manifest_entry], + 1 + ) + RETURNING id`, + [appId, `1.0.0-${randomUUID().slice(0, 8)}`, orgId], + ) + const versionId = versionRows[0]?.id as number + + await executeSQL( + `INSERT INTO public.manifest (app_version_id, file_name, s3_path, file_hash) + VALUES ($1, 'index.html', 'apps/test/index.html', 'abc123')`, + [versionId], + ) + + try { + await executeSQL( + `UPDATE public.orgs SET enforce_encrypted_bundles = true WHERE id = $1::uuid`, + [orgId], + ) + + await executeSQL(`SELECT public.null_migrated_app_version_manifests()`) + + const after = await executeSQL( + `SELECT manifest IS NULL AS is_null FROM public.app_versions WHERE id = $1`, + [versionId], + ) + expect(after[0]?.is_null).toBe(true) + } + finally { + await executeSQL( + `UPDATE public.orgs SET enforce_encrypted_bundles = false WHERE id = $1::uuid`, + [orgId], + ) + await executeSQL(`DELETE FROM public.manifest WHERE app_version_id = $1`, [versionId]) + await executeSQL(`DELETE FROM public.app_versions WHERE id = $1`, [versionId]) + await executeSQL(`DELETE FROM public.apps WHERE app_id = $1`, [appId]) + } + }) + +})