diff --git a/supabase/migrations/20260702160000_fix_invoke_agent_url_to_guc.sql b/supabase/migrations/20260702160000_fix_invoke_agent_url_to_guc.sql index 8ef2c37b95..b43c9901f5 100644 --- a/supabase/migrations/20260702160000_fix_invoke_agent_url_to_guc.sql +++ b/supabase/migrations/20260702160000_fix_invoke_agent_url_to_guc.sql @@ -10,8 +10,21 @@ -- Set the default base URL for the current (production) project. -- This value must be changed for staging/dev environments via: -- ALTER DATABASE postgres SET app.indexing_v3_agent_base_url = '...'; -alter database postgres - set app.indexing_v3_agent_base_url = 'https://sjrfecxgysukkwxsowpy.supabase.co'; +-- +-- Guarded: hosted Supabase denies ALTER DATABASE SET to the migration role +-- (42501). We swallow insufficient_privilege so the migration still succeeds on +-- hosted; the function below already falls back to the hardcoded project URL via +-- current_setting(..., true), so behaviour is unchanged when the GUC is unset. +-- On self-hosted / local (where the role is superuser) the GUC is set normally. +do $$ +begin + execute format('alter database %I set app.indexing_v3_agent_base_url = %L', + current_database(), 'https://sjrfecxgysukkwxsowpy.supabase.co'); +exception + when insufficient_privilege then + raise notice 'Skipping ALTER DATABASE SET app.indexing_v3_agent_base_url (insufficient privilege on hosted Supabase); invoke_indexing_v3_agent falls back to the hardcoded URL.'; +end +$$; create or replace function public.invoke_indexing_v3_agent(p_limit integer default 1) returns bigint diff --git a/supabase/migrations/20260702170000_fix_match_chunks_text_n1.sql b/supabase/migrations/20260702170000_fix_match_chunks_text_n1.sql index b8ed4e1430..6c25b2192f 100644 --- a/supabase/migrations/20260702170000_fix_match_chunks_text_n1.sql +++ b/supabase/migrations/20260702170000_fix_match_chunks_text_n1.sql @@ -1,144 +1,22 @@ --- Fix #7: Eliminate N+1 scalar function calls in match_document_chunks_text. +-- SUPERSEDED / INTENTIONAL NO-OP (neutralized 2026-07-03). -- --- The original outer SELECT called document_label_metadata(document_id) and --- document_summary_text(document_id) once per row returned from the `ranked` --- CTE. With match_count=12 and duplicate document_ids, this could fire 24+ --- independent SELECTs against document_labels and document_summaries. +-- This migration originally rewrote public.match_document_chunks_text to batch +-- the N+1 label/summary lookups AND changed its RETURNS TABLE shape (it added a +-- `lexical_score` output column). It must NOT be applied, for two reasons: -- --- Fix: add two CTEs that batch-fetch the data for all distinct document_ids --- in the result set, then LEFT JOIN them back. chunk_image_metadata() accepts --- a uuid[] so it is already a single call per row (no N+1); it is unchanged. - -create or replace function public.match_document_chunks_text( - query_text text, - match_count integer default 12, - document_filters uuid[] default null, - owner_filter uuid default null -) -returns table ( - id uuid, - document_id uuid, - title text, - file_name text, - page_number integer, - chunk_index integer, - section_heading text, - content text, - retrieval_synopsis text, - image_ids uuid[], - source_metadata jsonb, - document_labels jsonb, - document_summary text, - similarity double precision, - text_rank double precision, - hybrid_score double precision, - lexical_score double precision, - images jsonb -) -language sql -stable -set search_path = public, extensions, pg_temp -as $$ - with query as ( - select websearch_to_tsquery('english', coalesce(query_text, '')) as tsq - ), - ranked as ( - select - c.id, - c.document_id, - d.title, - d.file_name, - c.page_number, - c.chunk_index, - c.section_heading, - c.content, - c.retrieval_synopsis, - c.image_ids, - d.metadata as source_metadata, - ( - ts_rank_cd(c.search_tsv, query.tsq) + - (ts_rank_cd(d.title_search_tsv, query.tsq) * 3.0) - )::double precision as text_rank - from public.document_chunks c - join public.documents d on d.id = c.document_id - cross join query - where (document_filters is null or c.document_id = any(document_filters)) - and (owner_filter is null or d.owner_id = owner_filter) - and d.status = 'indexed' - and public.is_committed_document_generation(c.index_generation_id, d.metadata) - and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq) - order by ( - ts_rank_cd(c.search_tsv, query.tsq) + - (ts_rank_cd(d.title_search_tsv, query.tsq) * 3.0) - ) desc - limit least(greatest(match_count * 2, 24), 96) - ), - -- Batch-fetch label metadata for all distinct document_ids in the result set. - -- One query replaces N per-row calls to document_label_metadata(). - doc_labels as ( - select - l.document_id, - coalesce( - jsonb_agg( - jsonb_build_object( - 'id', l.id, - 'document_id', l.document_id, - 'owner_id', l.owner_id, - 'label', l.label, - 'label_type', l.label_type, - 'source', l.source, - 'confidence', l.confidence, - 'metadata', l.metadata, - 'created_at', l.created_at, - 'updated_at', l.updated_at - ) - order by l.confidence desc, l.label - ), - '[]'::jsonb - ) as labels - from public.document_labels l - where l.document_id in (select distinct ranked.document_id from ranked) - group by l.document_id - ), - -- Batch-fetch summary text for all distinct document_ids in the result set. - -- One query replaces N per-row calls to document_summary_text(). - doc_summaries as ( - select distinct on (s.document_id) - s.document_id, - s.summary - from public.document_summaries s - where s.document_id in (select distinct ranked.document_id from ranked) - order by s.document_id - ) - select - ranked.id, - ranked.document_id, - ranked.title, - ranked.file_name, - ranked.page_number, - ranked.chunk_index, - ranked.section_heading, - ranked.content, - ranked.retrieval_synopsis, - ranked.image_ids, - ranked.source_metadata, - coalesce(doc_labels.labels, '[]'::jsonb) as document_labels, - doc_summaries.summary as document_summary, - -- Text-only fallback has NO vector cosine similarity. Do not fabricate one: - -- a synthetic value here was read downstream as a real semantic score and - -- could label a pure keyword hit as "strong"/"moderate" evidence (>=0.64). - -- Leave similarity at 0; the lexical signal lives in lexical_score. - 0::double precision as similarity, - ranked.text_rank, - -- Cap hybrid_score well below the 0.64 "moderate" threshold so a lexical-only - -- row can order amongst its peers but can never masquerade as a moderate/strong - -- cosine match when merged with vector results. - least(0.5, 0.18 + (least(ranked.text_rank, 1) * 0.3))::double precision as hybrid_score, - least(0.99, 0.4 + (least(ranked.text_rank, 1) * 0.59))::double precision as lexical_score, - public.chunk_image_metadata(ranked.image_ids) as images - from ranked - left join doc_labels on doc_labels.document_id = ranked.document_id - left join doc_summaries on doc_summaries.document_id = ranked.document_id - order by lexical_score desc, text_rank desc - limit match_count; -$$; +-- 1. It would ERROR. Adding an output column to a set-returning function via +-- CREATE OR REPLACE is illegal ("cannot change return type of existing +-- function") without a DROP FUNCTION first — so this file cannot apply on +-- any database whose match_document_chunks_text already exists. +-- +-- 2. It would REGRESS the live function. The deployed match_document_chunks_text +-- already carries the N+1 batching AND a superior title-boost dual-path +-- implementation (chunk_seed / title_docs / title_matches) that this file +-- does not contain. Applying it would replace the better function with this +-- older single-path one. +-- +-- The canonical retrieval-RPC definitions live in supabase/schema.sql. Fully +-- reconciling the live retrieval RPCs into migrations/schema.sql is tracked as a +-- separate, golden-eval-gated backlog item (the known hybrid-RPC drift). This +-- file is kept as a documented no-op purely to preserve migration ordering. +select 1; diff --git a/supabase/schema.sql b/supabase/schema.sql index ccbb715169..28f32cb7c8 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -3452,8 +3452,18 @@ $$; revoke execute on function public.complete_strict_enrichment_job(uuid, uuid, text, text, text) from public, anon, authenticated; grant execute on function public.complete_strict_enrichment_job(uuid, uuid, text, text, text) to service_role; -alter database postgres - set app.indexing_v3_agent_base_url = 'https://sjrfecxgysukkwxsowpy.supabase.co'; +-- Guarded: hosted Supabase denies ALTER DATABASE SET to the migration role +-- (42501); swallow insufficient_privilege so schema replay succeeds on hosted. +-- invoke_indexing_v3_agent falls back to the hardcoded URL when the GUC is unset. +do $$ +begin + execute format('alter database %I set app.indexing_v3_agent_base_url = %L', + current_database(), 'https://sjrfecxgysukkwxsowpy.supabase.co'); +exception + when insufficient_privilege then + raise notice 'Skipping ALTER DATABASE SET app.indexing_v3_agent_base_url (insufficient privilege on hosted Supabase).'; +end +$$; create or replace function public.invoke_indexing_v3_agent(p_limit integer default 1) returns bigint diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index d9d7d05561..6c47b3f169 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -286,7 +286,10 @@ describe("Supabase schema Data API grants", () => { expect(schema).toContain("set search_path = public, extensions, vault, pg_temp"); expect(schema).toContain("from vault.decrypted_secrets"); expect(schema).toContain("where name = 'indexing_v3_agent_secret'"); - expect(schema).toContain("set app.indexing_v3_agent_base_url = 'https://sjrfecxgysukkwxsowpy.supabase.co';"); + // The GUC default is set through a privilege-guarded DO block so schema + // replay succeeds on hosted Supabase (ALTER DATABASE SET is denied there). + expect(schema).toContain("alter database %I set app.indexing_v3_agent_base_url = %L"); + expect(schema).toContain("when insufficient_privilege then"); expect(schema).toContain("nullif(current_setting('app.indexing_v3_agent_base_url', true), '')"); expect(schema).toContain("select net.http_post("); expect(schema).toContain("v_base_url || '/functions/v1/indexing-v3-agent?limit='");