|
| 1 | +-- --------------------------------------------------------------------------- |
| 2 | +-- supabase-cached-jobs-base — the cached_jobs base table (Tier 1 schema) |
| 3 | +-- --------------------------------------------------------------------------- |
| 4 | +-- THE TRACKED, REPRODUCIBLE DEFINITION OF `cached_jobs`. Closes the PERFDB-4 |
| 5 | +-- finding: until this file existed, the base table — the generated columns, |
| 6 | +-- the GIN/trgm/partial indexes, the unique key — lived ONLY in the prod |
| 7 | +-- Supabase DB and could not be rebuilt from version control. A |
| 8 | +-- disaster-recovery rebuild, a fresh staging/preview env, or a project |
| 9 | +-- migration that recreated `cached_jobs` WITHOUT (say) the `search_tsv` GIN |
| 10 | +-- index would silently turn every `search_cached_jobs_ranked` call into a |
| 11 | +-- seq-scan + ts_rank sort over ~14k rows, reproducing the documented |
| 12 | +-- statement-timeout collapse. This file is the source of truth; it was |
| 13 | +-- captured from the live prod catalog (pg_get_indexdef + pg_attribute + |
| 14 | +-- pg_get_expr) on 2026-06-22, not hand-written. |
| 15 | +-- |
| 16 | +-- THIS IS THE COMPLETE *LEAN* (Tier 1, lexical-only) SCHEMA. It includes |
| 17 | +-- everything EXCEPT the Tier 2 semantic add-ons: |
| 18 | +-- * the `embedding vector(1536)` column -> docs/sql/supabase-cached-jobs-pgvector.sql |
| 19 | +-- * the HNSW cosine index -> same file |
| 20 | +-- Keeping the embedding column in pgvector.sql (not here) gives it ONE |
| 21 | +-- source of truth and makes this file double as the "rebuild from scratch on |
| 22 | +-- the Supabase free tier" definition — see the "Hybrid-search lean/full |
| 23 | +-- switch" runbook in docs/deployment.md. |
| 24 | +-- |
| 25 | +-- REBUILD ORDER (fresh DB): |
| 26 | +-- Lean (free tier): apply THIS file -> apply supabase-cached-jobs-search.sql |
| 27 | +-- (the lexical RPC) -> the 4-hourly refresh fills rows. |
| 28 | +-- Full (paid plan): the above, THEN supabase-cached-jobs-pgvector.sql -> |
| 29 | +-- run scripts/backfill_job_embeddings.py -> |
| 30 | +-- supabase-cached-jobs-hybrid.sql -> flip |
| 31 | +-- JOB_SEARCH_HYBRID_ENABLED=true. |
| 32 | +-- |
| 33 | +-- IDEMPOTENT: CREATE TABLE/INDEX IF NOT EXISTS + guarded constraint adds, so |
| 34 | +-- re-applying against the live DB is a safe no-op (it will NOT alter the |
| 35 | +-- existing table). Use it to rebuild, or to diff against prod. |
| 36 | +-- |
| 37 | +-- SECURITY: RLS is enabled with NO policies (verified: relrowsecurity=true, |
| 38 | +-- 0 policies). The table is reached only by the service-role client, which |
| 39 | +-- bypasses RLS; enabling RLS with no policies is defence-in-depth so a |
| 40 | +-- mis-scoped anon/authenticated client can never read it. Do NOT add policies |
| 41 | +-- — that would imply non-service-role access this table is not designed for. |
| 42 | +-- --------------------------------------------------------------------------- |
| 43 | + |
| 44 | +-- pg_trgm backs the title/company trigram GIN indexes (fuzzy company/title |
| 45 | +-- matching in lexical search). The `vector` extension is NOT enabled here — |
| 46 | +-- it belongs with the embedding column in pgvector.sql. |
| 47 | +CREATE EXTENSION IF NOT EXISTS pg_trgm; |
| 48 | + |
| 49 | +-- ---- table ----------------------------------------------------------------- |
| 50 | +-- `bigserial` reproduces the prod `id bigint DEFAULT nextval('cached_jobs_id_seq')` |
| 51 | +-- (bigserial auto-creates the same-named sequence + default). The three |
| 52 | +-- GENERATED ALWAYS AS (...) STORED columns are transcribed verbatim from the |
| 53 | +-- prod generation expressions — do NOT paraphrase the CASE arms; the partial |
| 54 | +-- indexes and the search RPCs depend on these exact normalizations. |
| 55 | +CREATE TABLE IF NOT EXISTS public.cached_jobs ( |
| 56 | + id bigserial PRIMARY KEY, |
| 57 | + source text NOT NULL, |
| 58 | + job_id text NOT NULL, |
| 59 | + title text NOT NULL DEFAULT ''::text, |
| 60 | + company text NOT NULL DEFAULT ''::text, |
| 61 | + location text NOT NULL DEFAULT ''::text, |
| 62 | + employment_type text NOT NULL DEFAULT ''::text, |
| 63 | + url text NOT NULL DEFAULT ''::text, |
| 64 | + summary text NOT NULL DEFAULT ''::text, |
| 65 | + description text NOT NULL DEFAULT ''::text, |
| 66 | + remote boolean NOT NULL DEFAULT false, |
| 67 | + posted_at timestamptz, |
| 68 | + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, |
| 69 | + first_seen_at timestamptz NOT NULL DEFAULT timezone('utc'::text, now()), |
| 70 | + last_seen_at timestamptz NOT NULL DEFAULT timezone('utc'::text, now()), |
| 71 | + removed_at timestamptz, |
| 72 | + |
| 73 | + -- Lexical search vector: title weighted A, company B, description C. |
| 74 | + search_tsv tsvector GENERATED ALWAYS AS ( |
| 75 | + ( |
| 76 | + setweight(to_tsvector('english'::regconfig, COALESCE(title, ''::text)), 'A') |
| 77 | + || setweight(to_tsvector('english'::regconfig, COALESCE(company, ''::text)), 'B') |
| 78 | + ) |
| 79 | + || setweight(to_tsvector('english'::regconfig, COALESCE(description, ''::text)), 'C') |
| 80 | + ) STORED, |
| 81 | + |
| 82 | + -- Work-mode dropdown filter, derived from remote flag + metadata + location. |
| 83 | + work_mode text GENERATED ALWAYS AS ( |
| 84 | + CASE |
| 85 | + WHEN (remote IS TRUE) THEN 'remote'::text |
| 86 | + WHEN ((metadata ->> 'is_remote'::text) = 'true'::text) THEN 'remote'::text |
| 87 | + WHEN ((metadata ->> 'workplace_type'::text) ~~* 'remote%'::text) THEN 'remote'::text |
| 88 | + WHEN ((metadata ->> 'workplace_type'::text) ~~* 'hybrid%'::text) THEN 'hybrid'::text |
| 89 | + WHEN ((metadata ->> 'workplace_type'::text) ~~* 'in%office%'::text) THEN 'onsite'::text |
| 90 | + WHEN ((metadata ->> 'workplace_type'::text) ~~* 'in%person%'::text) THEN 'onsite'::text |
| 91 | + WHEN (location ~~* '%remote%'::text) THEN 'remote'::text |
| 92 | + WHEN (location ~~* '%hybrid%'::text) THEN 'hybrid'::text |
| 93 | + WHEN (location ~~* '%anywhere%'::text) THEN 'remote'::text |
| 94 | + WHEN (location <> ''::text) THEN 'onsite'::text |
| 95 | + ELSE ''::text |
| 96 | + END |
| 97 | + ) STORED, |
| 98 | + |
| 99 | + -- Employment-type dropdown filter, normalized from title + raw employment_type. |
| 100 | + employment_type_norm text GENERATED ALWAYS AS ( |
| 101 | + CASE |
| 102 | + WHEN (title ~* '\mintern(s|ship|ships)?\M'::text) THEN 'internship'::text |
| 103 | + WHEN (employment_type ~* '\mintern(s|ship|ships)?\M'::text) THEN 'internship'::text |
| 104 | + WHEN (employment_type ~~* '%contract%'::text) THEN 'contract'::text |
| 105 | + WHEN (employment_type ~~* '%contractor%'::text) THEN 'contract'::text |
| 106 | + WHEN ((employment_type ~~* '%temporary%'::text) OR (employment_type ~~* '%temp '::text)) THEN 'temporary'::text |
| 107 | + WHEN ((employment_type ~~* '%part%time%'::text) OR (employment_type ~~* '%parttime%'::text)) THEN 'parttime'::text |
| 108 | + WHEN ((employment_type ~~* '%full%time%'::text) OR (employment_type ~~* '%fulltime%'::text)) THEN 'fulltime'::text |
| 109 | + WHEN (employment_type = 'FullTime'::text) THEN 'fulltime'::text |
| 110 | + WHEN (employment_type = 'PartTime'::text) THEN 'parttime'::text |
| 111 | + ELSE ''::text |
| 112 | + END |
| 113 | + ) STORED |
| 114 | +); |
| 115 | + |
| 116 | +-- ---- unique key (the upserts depend on this) ------------------------------- |
| 117 | +-- on_conflict="source,job_id" in CachedJobsStore.upsert_postings targets this. |
| 118 | +DO $$ |
| 119 | +BEGIN |
| 120 | + IF NOT EXISTS ( |
| 121 | + SELECT 1 FROM pg_constraint WHERE conname = 'cached_jobs_source_job_id_unique' |
| 122 | + ) THEN |
| 123 | + ALTER TABLE public.cached_jobs |
| 124 | + ADD CONSTRAINT cached_jobs_source_job_id_unique UNIQUE (source, job_id); |
| 125 | + END IF; |
| 126 | +END$$; |
| 127 | + |
| 128 | +-- ---- indexes (verbatim from pg_get_indexdef; HNSW lives in pgvector.sql) ---- |
| 129 | +-- Lexical: the GIN on search_tsv is the hard prerequisite for the search RPC. |
| 130 | +CREATE INDEX IF NOT EXISTS cached_jobs_search_idx |
| 131 | + ON public.cached_jobs USING gin (search_tsv); |
| 132 | +-- Fuzzy title/company matching (pg_trgm). |
| 133 | +CREATE INDEX IF NOT EXISTS cached_jobs_title_trgm_idx |
| 134 | + ON public.cached_jobs USING gin (title gin_trgm_ops); |
| 135 | +CREATE INDEX IF NOT EXISTS cached_jobs_company_trgm_idx |
| 136 | + ON public.cached_jobs USING gin (company gin_trgm_ops); |
| 137 | +-- Filter dropdowns (partial — only live, non-empty rows). |
| 138 | +CREATE INDEX IF NOT EXISTS cached_jobs_work_mode_idx |
| 139 | + ON public.cached_jobs USING btree (work_mode) |
| 140 | + WHERE ((removed_at IS NULL) AND (work_mode <> ''::text)); |
| 141 | +CREATE INDEX IF NOT EXISTS cached_jobs_employment_type_idx |
| 142 | + ON public.cached_jobs USING btree (employment_type_norm) |
| 143 | + WHERE ((removed_at IS NULL) AND (employment_type_norm <> ''::text)); |
| 144 | +CREATE INDEX IF NOT EXISTS cached_jobs_remote_idx |
| 145 | + ON public.cached_jobs USING btree (remote) |
| 146 | + WHERE ((removed_at IS NULL) AND (remote = true)); |
| 147 | +CREATE INDEX IF NOT EXISTS cached_jobs_source_idx |
| 148 | + ON public.cached_jobs USING btree (source) |
| 149 | + WHERE (removed_at IS NULL); |
| 150 | +-- Recency sort. |
| 151 | +CREATE INDEX IF NOT EXISTS cached_jobs_posted_at_idx |
| 152 | + ON public.cached_jobs USING btree (posted_at DESC NULLS LAST) |
| 153 | + WHERE (removed_at IS NULL); |
| 154 | +-- Refresh worker keep-alive scan (source, last_seen_at). |
| 155 | +CREATE INDEX IF NOT EXISTS cached_jobs_refresh_idx |
| 156 | + ON public.cached_jobs USING btree (source, last_seen_at); |
| 157 | + |
| 158 | +-- ---- RLS (enabled, no policies — service-role-only) ------------------------ |
| 159 | +ALTER TABLE public.cached_jobs ENABLE ROW LEVEL SECURITY; |
0 commit comments