BEGIN; CREATE TABLE IF NOT EXISTS schema_migrations ( version text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now() ); CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$; CREATE TABLE media_assets ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), kind text NOT NULL CHECK (kind IN ( 'channel_logo', 'video_thumbnail', 'interviewee_professional', 'interviewee_personal', 'origin_icon', 'other' )), source_url text, storage_path text NOT NULL, sha256 text NOT NULL, mime_type text NOT NULL, size_bytes bigint NOT NULL CHECK (size_bytes >= 0), width integer CHECK (width IS NULL OR width > 0), height integer CHECK (height IS NULL OR height > 0), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT media_assets_sha256_not_blank CHECK (length(trim(sha256)) > 0), CONSTRAINT media_assets_storage_path_not_blank CHECK (length(trim(storage_path)) > 0), CONSTRAINT media_assets_sha256_unique UNIQUE (sha256) ); CREATE TABLE podcast_channels ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), youtube_channel_id text NOT NULL, name text NOT NULL, canonical_url text NOT NULL, logo_asset_id uuid REFERENCES media_assets(id) ON DELETE SET NULL, status text NOT NULL DEFAULT 'candidate' CHECK (status IN ('candidate', 'selected', 'active', 'removed', 'failed')), metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'), discovered_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz, CONSTRAINT podcast_channels_youtube_id_not_blank CHECK (length(trim(youtube_channel_id)) > 0), CONSTRAINT podcast_channels_name_not_blank CHECK (length(trim(name)) > 0), CONSTRAINT podcast_channels_url_not_blank CHECK (length(trim(canonical_url)) > 0), CONSTRAINT podcast_channels_youtube_id_unique UNIQUE (youtube_channel_id), CONSTRAINT podcast_channels_canonical_url_unique UNIQUE (canonical_url) ); CREATE TABLE videos ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), channel_id uuid NOT NULL REFERENCES podcast_channels(id) ON DELETE CASCADE, youtube_video_id text NOT NULL, canonical_url text NOT NULL, title text NOT NULL, description text NOT NULL DEFAULT '', published_at timestamptz, duration_seconds integer CHECK (duration_seconds IS NULL OR duration_seconds >= 0), thumbnail_asset_id uuid REFERENCES media_assets(id) ON DELETE SET NULL, processing_status text NOT NULL DEFAULT 'pending' CHECK (processing_status IN ('pending', 'processing', 'processed', 'skipped', 'failed')), metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz, CONSTRAINT videos_youtube_id_not_blank CHECK (length(trim(youtube_video_id)) > 0), CONSTRAINT videos_title_not_blank CHECK (length(trim(title)) > 0), CONSTRAINT videos_youtube_id_unique UNIQUE (youtube_video_id), CONSTRAINT videos_canonical_url_unique UNIQUE (canonical_url) ); CREATE TABLE transcripts ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), video_id uuid NOT NULL REFERENCES videos(id) ON DELETE CASCADE, language text NOT NULL, source text NOT NULL CHECK (source IN ('youtube', 'translated', 'speech_to_text', 'manual')), text_content text NOT NULL DEFAULT '', content_hash text NOT NULL, status text NOT NULL DEFAULT 'ready' CHECK (status IN ('pending', 'ready', 'unavailable', 'failed')), is_generated boolean NOT NULL DEFAULT false, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT transcripts_language_not_blank CHECK (length(trim(language)) > 0), CONSTRAINT transcripts_hash_not_blank CHECK (length(trim(content_hash)) > 0), CONSTRAINT transcripts_video_language_source_unique UNIQUE (video_id, language, source) ); CREATE TABLE categories ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), display_name text NOT NULL, normalized_name text NOT NULL, description text NOT NULL DEFAULT '', created_by text NOT NULL DEFAULT 'ai' CHECK (created_by IN ('ai', 'manual', 'system')), active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT categories_display_name_not_blank CHECK (length(trim(display_name)) > 0), CONSTRAINT categories_normalized_name_not_blank CHECK (length(trim(normalized_name)) > 0), CONSTRAINT categories_normalized_name_unique UNIQUE (normalized_name) ); CREATE TABLE pipeline_runs ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), kind text NOT NULL CHECK (kind IN ( 'podcast_discovery', 'interviewee_extraction', 'contact_extraction' )), mode text NOT NULL DEFAULT 'manual' CHECK (mode IN ('manual', 'automatic')), status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'paused', 'cancelling', 'cancelled', 'completed', 'failed')), idempotency_key text, requested_by uuid, input jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(input) = 'object'), progress_current bigint NOT NULL DEFAULT 0 CHECK (progress_current >= 0), progress_total bigint CHECK (progress_total IS NULL OR progress_total >= 0), stats jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(stats) = 'object'), error_code text, error_message text, started_at timestamptz, finished_at timestamptz, cancel_requested_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT pipeline_runs_idempotency_key_unique UNIQUE (idempotency_key), CONSTRAINT pipeline_runs_progress_valid CHECK ( progress_total IS NULL OR progress_current <= progress_total ) ); CREATE TABLE interviewees ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), primary_category_id uuid REFERENCES categories(id) ON DELETE RESTRICT, display_name text NOT NULL, real_name text, brand_name text, normalized_display_name text NOT NULL, normalized_real_name text, normalized_brand_name text, professional_summary text NOT NULL DEFAULT '', public_bio text NOT NULL DEFAULT '', creator_content_type text, creator_audience text, professional_image_asset_id uuid REFERENCES media_assets(id) ON DELETE SET NULL, personal_image_asset_id uuid REFERENCES media_assets(id) ON DELETE SET NULL, status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'merged', 'archived')), dedup_review_status text NOT NULL DEFAULT 'unreviewed' CHECK (dedup_review_status IN ('unreviewed', 'confirmed', 'needs_review')), created_in_run_id uuid REFERENCES pipeline_runs(id) ON DELETE SET NULL, metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz, CONSTRAINT interviewees_display_name_not_blank CHECK (length(trim(display_name)) > 0), CONSTRAINT interviewees_normalized_name_not_blank CHECK (length(trim(normalized_display_name)) > 0) ); CREATE TABLE interviewee_aliases ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), interviewee_id uuid NOT NULL REFERENCES interviewees(id) ON DELETE CASCADE, alias text NOT NULL, normalized_alias text NOT NULL, kind text NOT NULL DEFAULT 'other' CHECK (kind IN ('real_name', 'brand_name', 'stage_name', 'social_handle', 'other')), created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT interviewee_aliases_alias_not_blank CHECK (length(trim(alias)) > 0), CONSTRAINT interviewee_aliases_normalized_not_blank CHECK (length(trim(normalized_alias)) > 0), CONSTRAINT interviewee_aliases_unique UNIQUE (interviewee_id, kind, normalized_alias) ); CREATE TABLE interviewee_redirects ( old_interviewee_id uuid PRIMARY KEY REFERENCES interviewees(id) ON DELETE CASCADE, canonical_interviewee_id uuid NOT NULL REFERENCES interviewees(id) ON DELETE RESTRICT, reason text NOT NULL DEFAULT '', merged_by text NOT NULL DEFAULT 'manual' CHECK (merged_by IN ('manual', 'system')), created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT interviewee_redirects_not_self CHECK (old_interviewee_id <> canonical_interviewee_id) ); CREATE TABLE appearances ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), interviewee_id uuid NOT NULL REFERENCES interviewees(id) ON DELETE CASCADE, video_id uuid NOT NULL REFERENCES videos(id) ON DELETE CASCADE, confidence real NOT NULL CHECK (confidence >= 0 AND confidence <= 1), evidence text NOT NULL DEFAULT '', evidence_hash text, extraction_source text NOT NULL DEFAULT 'ai' CHECK (extraction_source IN ('ai', 'manual', 'metadata')), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT appearances_interviewee_video_unique UNIQUE (interviewee_id, video_id) ); CREATE TABLE origins ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), display_name text NOT NULL, canonical_url text NOT NULL, domain text NOT NULL, source_type text NOT NULL DEFAULT 'website' CHECK (source_type IN ('youtube', 'instagram', 'linktree', 'website', 'google', 'other')), icon_asset_id uuid REFERENCES media_assets(id) ON DELETE SET NULL, first_seen_at timestamptz NOT NULL DEFAULT now(), last_seen_at timestamptz NOT NULL DEFAULT now(), metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT origins_name_not_blank CHECK (length(trim(display_name)) > 0), CONSTRAINT origins_url_not_blank CHECK (length(trim(canonical_url)) > 0), CONSTRAINT origins_domain_not_blank CHECK (length(trim(domain)) > 0), CONSTRAINT origins_canonical_url_unique UNIQUE (canonical_url) ); CREATE TABLE contacts ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), interviewee_id uuid NOT NULL REFERENCES interviewees(id) ON DELETE CASCADE, primary_origin_id uuid NOT NULL REFERENCES origins(id) ON DELETE RESTRICT, contact_type text NOT NULL CHECK (contact_type IN ( 'email', 'phone', 'whatsapp', 'instagram', 'linkedin', 'facebook', 'tiktok', 'x', 'telegram', 'youtube', 'website', 'other' )), raw_value text NOT NULL, normalized_value text NOT NULL, relationship_kind text NOT NULL CHECK (relationship_kind IN ('personal', 'commercial')), label text, confidence real NOT NULL CHECK (confidence >= 0 AND confidence <= 1), status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'stale', 'suppressed', 'deleted')), discovered_in_run_id uuid REFERENCES pipeline_runs(id) ON DELETE SET NULL, first_seen_at timestamptz NOT NULL DEFAULT now(), last_seen_at timestamptz NOT NULL DEFAULT now(), last_verified_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz, CONSTRAINT contacts_raw_value_not_blank CHECK (length(trim(raw_value)) > 0), CONSTRAINT contacts_normalized_value_not_blank CHECK (length(trim(normalized_value)) > 0) ); CREATE UNIQUE INDEX contacts_active_identity_unique ON contacts (interviewee_id, contact_type, normalized_value) WHERE deleted_at IS NULL AND status <> 'deleted'; CREATE TABLE contact_evidence ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), contact_id uuid NOT NULL REFERENCES contacts(id) ON DELETE CASCADE, origin_id uuid NOT NULL REFERENCES origins(id) ON DELETE RESTRICT, page_url text NOT NULL, evidence_text text NOT NULL DEFAULT '', evidence_hash text, confidence real NOT NULL CHECK (confidence >= 0 AND confidence <= 1), collected_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX contact_evidence_unique ON contact_evidence (contact_id, origin_id, page_url, COALESCE(evidence_hash, '')); CREATE TABLE jobs ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), run_id uuid REFERENCES pipeline_runs(id) ON DELETE CASCADE, parent_job_id uuid REFERENCES jobs(id) ON DELETE SET NULL, kind text NOT NULL, payload jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(payload) = 'object'), result jsonb CHECK (result IS NULL OR jsonb_typeof(result) = 'object'), status text NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'retry_scheduled', 'succeeded', 'failed', 'cancelled')), priority integer NOT NULL DEFAULT 0, idempotency_key text, attempt_count integer NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), max_attempts integer NOT NULL DEFAULT 5 CHECK (max_attempts > 0), available_at timestamptz NOT NULL DEFAULT now(), locked_at timestamptz, locked_by text, heartbeat_at timestamptz, started_at timestamptz, finished_at timestamptz, cancel_requested_at timestamptz, last_error_code text, last_error_message text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT jobs_kind_not_blank CHECK (length(trim(kind)) > 0), CONSTRAINT jobs_idempotency_key_unique UNIQUE (idempotency_key), CONSTRAINT jobs_attempt_bounds CHECK (attempt_count <= max_attempts) ); CREATE TABLE job_attempts ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), job_id uuid NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, attempt_no integer NOT NULL CHECK (attempt_no > 0), worker_id text NOT NULL, status text NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'succeeded', 'failed', 'cancelled')), error_code text, error_message text, started_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz, metrics jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metrics) = 'object'), CONSTRAINT job_attempts_job_attempt_unique UNIQUE (job_id, attempt_no) ); CREATE TABLE crawl_pages ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), run_id uuid NOT NULL REFERENCES pipeline_runs(id) ON DELETE CASCADE, interviewee_id uuid NOT NULL REFERENCES interviewees(id) ON DELETE CASCADE, origin_id uuid REFERENCES origins(id) ON DELETE SET NULL, canonical_url text NOT NULL, depth smallint NOT NULL CHECK (depth >= 0 AND depth <= 5), status text NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'fetching', 'fetched', 'skipped', 'failed', 'blocked')), relevance real CHECK (relevance IS NULL OR (relevance >= 0 AND relevance <= 1)), http_status integer CHECK (http_status IS NULL OR (http_status >= 100 AND http_status <= 599)), content_hash text, title text, extracted_text text, content_bytes bigint CHECK (content_bytes IS NULL OR content_bytes >= 0), error_code text, error_message text, queued_at timestamptz NOT NULL DEFAULT now(), fetched_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT crawl_pages_url_not_blank CHECK (length(trim(canonical_url)) > 0), CONSTRAINT crawl_pages_run_url_unique UNIQUE (run_id, canonical_url) ); CREATE TABLE crawl_edges ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), run_id uuid NOT NULL REFERENCES pipeline_runs(id) ON DELETE CASCADE, from_page_id uuid REFERENCES crawl_pages(id) ON DELETE CASCADE, to_page_id uuid REFERENCES crawl_pages(id) ON DELETE SET NULL, discovered_url text NOT NULL, anchor_text text, relationship text NOT NULL DEFAULT 'link' CHECK (relationship IN ('seed', 'link', 'redirect', 'canonical')), created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT crawl_edges_url_not_blank CHECK (length(trim(discovered_url)) > 0) ); CREATE UNIQUE INDEX crawl_edges_unique ON crawl_edges (run_id, COALESCE(from_page_id, '00000000-0000-0000-0000-000000000000'::uuid), discovered_url, relationship); CREATE TABLE interviewee_candidates ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), run_id uuid NOT NULL REFERENCES pipeline_runs(id) ON DELETE CASCADE, video_id uuid NOT NULL REFERENCES videos(id) ON DELETE CASCADE, proposed_name text NOT NULL, normalized_name text NOT NULL, proposed_real_name text, proposed_brand_name text, professional_summary text NOT NULL DEFAULT '', evidence text NOT NULL DEFAULT '', evidence_hash text, confidence real NOT NULL CHECK (confidence >= 0 AND confidence <= 1), status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'matched', 'created', 'rejected')), matched_interviewee_id uuid REFERENCES interviewees(id) ON DELETE SET NULL, ai_call_id uuid, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT interviewee_candidates_name_not_blank CHECK (length(trim(proposed_name)) > 0), CONSTRAINT interviewee_candidates_normalized_not_blank CHECK (length(trim(normalized_name)) > 0) ); CREATE UNIQUE INDEX interviewee_candidates_unique ON interviewee_candidates (run_id, video_id, normalized_name, COALESCE(evidence_hash, '')); CREATE TABLE contact_candidates ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), run_id uuid NOT NULL REFERENCES pipeline_runs(id) ON DELETE CASCADE, interviewee_id uuid NOT NULL REFERENCES interviewees(id) ON DELETE CASCADE, origin_id uuid NOT NULL REFERENCES origins(id) ON DELETE CASCADE, crawl_page_id uuid REFERENCES crawl_pages(id) ON DELETE SET NULL, contact_type text NOT NULL CHECK (contact_type IN ( 'email', 'phone', 'whatsapp', 'instagram', 'linkedin', 'facebook', 'tiktok', 'x', 'telegram', 'youtube', 'website', 'other' )), raw_value text NOT NULL, normalized_value text NOT NULL, proposed_relationship_kind text CHECK (proposed_relationship_kind IN ('personal', 'commercial')), proposed_label text, evidence text NOT NULL DEFAULT '', confidence real NOT NULL CHECK (confidence >= 0 AND confidence <= 1), status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected', 'needs_review')), rejection_reason text, accepted_contact_id uuid REFERENCES contacts(id) ON DELETE SET NULL, ai_call_id uuid, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT contact_candidates_raw_not_blank CHECK (length(trim(raw_value)) > 0), CONSTRAINT contact_candidates_normalized_not_blank CHECK (length(trim(normalized_value)) > 0), CONSTRAINT contact_candidates_unique UNIQUE ( run_id, interviewee_id, origin_id, contact_type, normalized_value ) ); CREATE TABLE ai_calls ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), run_id uuid REFERENCES pipeline_runs(id) ON DELETE SET NULL, job_id uuid REFERENCES jobs(id) ON DELETE SET NULL, purpose text NOT NULL CHECK (purpose IN ( 'guest_extraction', 'contact_extraction', 'categorization', 'identity_resolution', 'page_relevance', 'summary', 'other' )), model text NOT NULL, prompt_version text NOT NULL, schema_version text NOT NULL, input_hash text NOT NULL, input_payload jsonb, output_payload jsonb, status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'succeeded', 'failed', 'invalid_output')), prompt_tokens integer CHECK (prompt_tokens IS NULL OR prompt_tokens >= 0), completion_tokens integer CHECK (completion_tokens IS NULL OR completion_tokens >= 0), cost_micros bigint CHECK (cost_micros IS NULL OR cost_micros >= 0), latency_ms bigint CHECK (latency_ms IS NULL OR latency_ms >= 0), error_code text, error_message text, retain_until timestamptz, created_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz, CONSTRAINT ai_calls_model_not_blank CHECK (length(trim(model)) > 0), CONSTRAINT ai_calls_input_hash_not_blank CHECK (length(trim(input_hash)) > 0) ); ALTER TABLE interviewee_candidates ADD CONSTRAINT interviewee_candidates_ai_call_fk FOREIGN KEY (ai_call_id) REFERENCES ai_calls(id) ON DELETE SET NULL; ALTER TABLE contact_candidates ADD CONSTRAINT contact_candidates_ai_call_fk FOREIGN KEY (ai_call_id) REFERENCES ai_calls(id) ON DELETE SET NULL; CREATE TABLE audit_events ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), run_id uuid REFERENCES pipeline_runs(id) ON DELETE SET NULL, actor_type text NOT NULL DEFAULT 'system' CHECK (actor_type IN ('system', 'ai', 'user', 'worker')), actor_id uuid, action text NOT NULL, entity_type text NOT NULL, entity_id uuid, before_data jsonb, after_data jsonb, metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'), created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT audit_events_action_not_blank CHECK (length(trim(action)) > 0), CONSTRAINT audit_events_entity_type_not_blank CHECK (length(trim(entity_type)) > 0) ); CREATE TABLE suppression_entries ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), scope_kind text NOT NULL CHECK (scope_kind IN ('contact', 'interviewee', 'domain')), contact_type text, normalized_hash text NOT NULL, reason text NOT NULL DEFAULT '', created_by uuid, expires_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT suppression_entries_hash_not_blank CHECK (length(trim(normalized_hash)) > 0), CONSTRAINT suppression_entries_unique UNIQUE NULLS NOT DISTINCT (scope_kind, contact_type, normalized_hash) ); CREATE INDEX podcast_channels_name_trgm_idx ON podcast_channels USING gin (name gin_trgm_ops); CREATE INDEX podcast_channels_status_idx ON podcast_channels (status) WHERE deleted_at IS NULL; CREATE INDEX videos_channel_published_idx ON videos (channel_id, published_at DESC) WHERE deleted_at IS NULL; CREATE INDEX transcripts_video_idx ON transcripts (video_id); CREATE INDEX categories_name_trgm_idx ON categories USING gin (normalized_name gin_trgm_ops) WHERE active; CREATE INDEX interviewees_display_name_trgm_idx ON interviewees USING gin (normalized_display_name gin_trgm_ops) WHERE deleted_at IS NULL AND status = 'active'; CREATE INDEX interviewees_category_idx ON interviewees (primary_category_id) WHERE deleted_at IS NULL; CREATE INDEX interviewee_aliases_name_trgm_idx ON interviewee_aliases USING gin (normalized_alias gin_trgm_ops); CREATE INDEX appearances_video_idx ON appearances (video_id); CREATE INDEX origins_domain_idx ON origins (domain); CREATE INDEX contacts_interviewee_idx ON contacts (interviewee_id) WHERE deleted_at IS NULL; CREATE INDEX contacts_type_value_idx ON contacts (contact_type, normalized_value) WHERE deleted_at IS NULL; CREATE INDEX contact_evidence_contact_idx ON contact_evidence (contact_id, collected_at DESC); CREATE INDEX pipeline_runs_status_created_idx ON pipeline_runs (status, created_at DESC); CREATE INDEX jobs_claim_idx ON jobs (priority DESC, available_at, created_at) WHERE status IN ('queued', 'retry_scheduled') AND cancel_requested_at IS NULL; CREATE INDEX jobs_run_status_idx ON jobs (run_id, status); CREATE INDEX jobs_locked_idx ON jobs (locked_at) WHERE status = 'running'; CREATE INDEX job_attempts_job_idx ON job_attempts (job_id, attempt_no DESC); CREATE INDEX crawl_pages_frontier_idx ON crawl_pages (run_id, depth, queued_at) WHERE status = 'queued'; CREATE INDEX crawl_pages_interviewee_idx ON crawl_pages (interviewee_id, status); CREATE INDEX crawl_edges_from_idx ON crawl_edges (from_page_id); CREATE INDEX interviewee_candidates_status_idx ON interviewee_candidates (run_id, status); CREATE INDEX contact_candidates_status_idx ON contact_candidates (run_id, status); CREATE INDEX ai_calls_run_created_idx ON ai_calls (run_id, created_at DESC); CREATE INDEX ai_calls_purpose_status_idx ON ai_calls (purpose, status); CREATE INDEX audit_events_entity_idx ON audit_events (entity_type, entity_id, created_at DESC); CREATE INDEX suppression_entries_lookup_idx ON suppression_entries (normalized_hash, scope_kind); CREATE TRIGGER media_assets_touch_updated_at BEFORE UPDATE ON media_assets FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); CREATE TRIGGER podcast_channels_touch_updated_at BEFORE UPDATE ON podcast_channels FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); CREATE TRIGGER videos_touch_updated_at BEFORE UPDATE ON videos FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); CREATE TRIGGER transcripts_touch_updated_at BEFORE UPDATE ON transcripts FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); CREATE TRIGGER categories_touch_updated_at BEFORE UPDATE ON categories FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); CREATE TRIGGER pipeline_runs_touch_updated_at BEFORE UPDATE ON pipeline_runs FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); CREATE TRIGGER interviewees_touch_updated_at BEFORE UPDATE ON interviewees FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); CREATE TRIGGER appearances_touch_updated_at BEFORE UPDATE ON appearances FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); CREATE TRIGGER origins_touch_updated_at BEFORE UPDATE ON origins FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); CREATE TRIGGER contacts_touch_updated_at BEFORE UPDATE ON contacts FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); CREATE TRIGGER jobs_touch_updated_at BEFORE UPDATE ON jobs FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); CREATE TRIGGER crawl_pages_touch_updated_at BEFORE UPDATE ON crawl_pages FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); CREATE TRIGGER interviewee_candidates_touch_updated_at BEFORE UPDATE ON interviewee_candidates FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); CREATE TRIGGER contact_candidates_touch_updated_at BEFORE UPDATE ON contact_candidates FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); INSERT INTO schema_migrations(version) VALUES ('0001_initial') ON CONFLICT (version) DO NOTHING; COMMIT;