Spaces:
Running
Running
| -- ============================================================================= | |
| -- AgriFlow — Supabase / Postgres Schema | |
| -- Apply with: psql $SUPABASE_DB_URL -f db/schema.sql | |
| -- ============================================================================= | |
| -- | |
| -- TABLE INVENTORY | |
| -- 1. kabupaten — mirror of sample_data/kabupaten_jatim.csv | |
| -- 2. commodity — mirror of sample_data/komoditas_constraints.csv | |
| -- 3. surplus_deficit — mirror of sample_data/surplus_deficit.csv | |
| -- 4. weather_forecast — mirror of sample_data/weather_forecast.csv | |
| -- 5. historical_prices — mirror of sample_data/historical_price_stats.csv | |
| -- 6. commodity_code_map — Bapanas integer ID ↔ canonical code mapping | |
| -- 7. policy_docs — RAG document store (pgvector embeddings) | |
| -- 8. price_history — TimesFM INPUT (daily price time series per city) | |
| -- 9. forecasts — TimesFM OUTPUT (per-commodity per-city forecasts) | |
| -- 10. subscriber — WhatsApp identity (hashed) + plan state | |
| -- 11. wa_usage_daily — per-day metered query counter (free-tier quota) | |
| -- 12. payment_order — upgrade orders + settlement status | |
| -- ============================================================================= | |
| -- Required extension for pgvector (RAG embeddings) | |
| CREATE EXTENSION IF NOT EXISTS vector; | |
| -- ============================================================================= | |
| -- 1. KABUPATEN | |
| -- ============================================================================= | |
| CREATE TABLE IF NOT EXISTS kabupaten ( | |
| kab_id VARCHAR(10) PRIMARY KEY, -- BPS wilayah code, e.g. "3578" | |
| nama VARCHAR(100) NOT NULL, -- e.g. "Kota Surabaya" | |
| latitude DOUBLE PRECISION NOT NULL, | |
| longitude DOUBLE PRECISION NOT NULL, | |
| ipm_2024 DOUBLE PRECISION NOT NULL, -- IPM BPS 2024 | |
| population_2024 INTEGER NOT NULL DEFAULT 0, | |
| tier VARCHAR(20) NOT NULL -- 'TIER_1_HIGH' | 'TIER_2_MEDIUM' | |
| CHECK (tier IN ('TIER_1_HIGH', 'TIER_2_MEDIUM')), | |
| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_kabupaten_tier ON kabupaten(tier); | |
| -- ============================================================================= | |
| -- 2. COMMODITY | |
| -- ============================================================================= | |
| CREATE TABLE IF NOT EXISTS commodity ( | |
| code VARCHAR(50) PRIMARY KEY, -- canonical code, e.g. "cabai_merah" | |
| nama VARCHAR(100) NOT NULL, | |
| max_distance_km DOUBLE PRECISION NOT NULL, | |
| min_viable_tons DOUBLE PRECISION NOT NULL, | |
| max_fresh_age_days INTEGER NOT NULL, | |
| bulog_priority BOOLEAN NOT NULL DEFAULT FALSE, | |
| is_imported BOOLEAN NOT NULL DEFAULT FALSE, | |
| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() | |
| ); | |
| -- ============================================================================= | |
| -- 3. SURPLUS_DEFICIT | |
| -- ============================================================================= | |
| CREATE TABLE IF NOT EXISTS surplus_deficit ( | |
| id BIGSERIAL PRIMARY KEY, | |
| kab_id VARCHAR(10) NOT NULL REFERENCES kabupaten(kab_id), | |
| commodity_code VARCHAR(50) NOT NULL REFERENCES commodity(code), | |
| role VARCHAR(10) NOT NULL | |
| CHECK (role IN ('SURPLUS', 'DEFICIT')), | |
| volume_tons DOUBLE PRECISION NOT NULL, | |
| price_idr_per_kg DOUBLE PRECISION NOT NULL, | |
| harvest_age_days INTEGER NOT NULL DEFAULT 0, | |
| data_source VARCHAR(20) NOT NULL DEFAULT 'PIHPS', | |
| recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_surdef_kab ON surplus_deficit(kab_id); | |
| CREATE INDEX IF NOT EXISTS idx_surdef_commodity ON surplus_deficit(commodity_code); | |
| CREATE INDEX IF NOT EXISTS idx_surdef_role ON surplus_deficit(role); | |
| CREATE INDEX IF NOT EXISTS idx_surdef_recorded_at ON surplus_deficit(recorded_at DESC); | |
| -- ============================================================================= | |
| -- 4. WEATHER_FORECAST | |
| -- ============================================================================= | |
| CREATE TABLE IF NOT EXISTS weather_forecast ( | |
| id BIGSERIAL PRIMARY KEY, | |
| origin_kab_id VARCHAR(10) NOT NULL REFERENCES kabupaten(kab_id), | |
| dest_kab_id VARCHAR(10) NOT NULL REFERENCES kabupaten(kab_id), | |
| max_rain_mm DOUBLE PRECISION NOT NULL, | |
| transit_window_days INTEGER NOT NULL DEFAULT 1, | |
| source VARCHAR(20) NOT NULL DEFAULT 'BMKG', | |
| valid_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| UNIQUE (origin_kab_id, dest_kab_id, valid_at) | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_weather_origin ON weather_forecast(origin_kab_id); | |
| CREATE INDEX IF NOT EXISTS idx_weather_dest ON weather_forecast(dest_kab_id); | |
| -- ============================================================================= | |
| -- 5. HISTORICAL_PRICES | |
| -- (aggregate stats per commodity — used by engine for fairness scoring) | |
| -- ============================================================================= | |
| CREATE TABLE IF NOT EXISTS historical_prices ( | |
| commodity_code VARCHAR(50) PRIMARY KEY REFERENCES commodity(code), | |
| median_idr_per_kg DOUBLE PRECISION NOT NULL, | |
| std_idr_per_kg DOUBLE PRECISION NOT NULL, | |
| sample_size INTEGER NOT NULL DEFAULT 0, | |
| period_start DATE, | |
| period_end DATE, | |
| updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() | |
| ); | |
| -- ============================================================================= | |
| -- 6. COMMODITY_CODE_MAP | |
| -- Maps Bapanas integer IDs ↔ AgriFlow canonical codes. | |
| -- Explicit in DB — NOT hardcoded in application logic. | |
| -- Mitigates risk if Bapanas API changes its numbering scheme. | |
| -- ============================================================================= | |
| CREATE TABLE IF NOT EXISTS commodity_code_map ( | |
| mapping_id INTEGER PRIMARY KEY, -- Bapanas integer commodity ID | |
| canonical_code VARCHAR(50) NOT NULL REFERENCES commodity(code), | |
| bapanas_name VARCHAR(100) NOT NULL, -- name as returned by Bapanas API | |
| pihps_code VARCHAR(50), -- PIHPS code if different | |
| notes TEXT, | |
| is_active BOOLEAN NOT NULL DEFAULT TRUE, | |
| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_ccmap_canonical ON commodity_code_map(canonical_code); | |
| CREATE INDEX IF NOT EXISTS idx_ccmap_active ON commodity_code_map(is_active); | |
| -- Seed: known Bapanas commodity IDs at time of AgriFlow v9 build (2026-05) | |
| -- Update this table when Bapanas changes their numbering — never change canonical_code. | |
| -- | |
| -- SELECT ... WHERE EXISTS, not a plain VALUES insert. canonical_code is a | |
| -- foreign key into commodity, and commodity is populated by the application | |
| -- loader, NOT by this file. A plain INSERT therefore aborts on a fresh | |
| -- database with: | |
| -- violates foreign key constraint "commodity_code_map_canonical_code_fkey" | |
| -- which kills the rest of the script -- including the RLS section at the end, | |
| -- silently leaving every table world-readable through PostgREST. | |
| -- | |
| -- Filtering on EXISTS makes this a no-op on an empty database instead. Because | |
| -- the whole file is IF NOT EXISTS / ON CONFLICT idempotent, re-running it after | |
| -- the loader has populated commodity backfills these mappings. | |
| INSERT INTO commodity_code_map (mapping_id, canonical_code, bapanas_name) | |
| SELECT v.mapping_id, v.canonical_code, v.bapanas_name | |
| FROM (VALUES | |
| (1, 'beras_premium', 'Beras Premium'), | |
| (2, 'beras_medium', 'Beras Medium'), | |
| (3, 'beras_ir64', 'Beras IR 64'), | |
| (4, 'jagung', 'Jagung Pipilan Kering'), | |
| (5, 'kedelai', 'Kedelai Biji Kering (Impor)'), | |
| (6, 'cabai_merah', 'Cabai Merah Besar'), | |
| (7, 'cabai_rawit', 'Cabai Rawit Merah'), | |
| (8, 'bawang_merah', 'Bawang Merah'), | |
| (9, 'bawang_putih', 'Bawang Putih (Bonggol)'), | |
| (10, 'daging_sapi', 'Daging Sapi Murni'), | |
| (11, 'daging_ayam', 'Daging Ayam Ras'), | |
| (12, 'telur_ayam', 'Telur Ayam Ras'), | |
| (13, 'minyak_goreng', 'Minyak Goreng Curah'), | |
| (14, 'gula_pasir', 'Gula Pasir Lokal'), | |
| (15, 'tepung_terigu', 'Tepung Terigu (Curah)'), | |
| (16, 'tomat', 'Tomat Sayur'), | |
| (17, 'kentang', 'Kentang'), | |
| (18, 'wortel', 'Wortel'), | |
| (19, 'kacang_tanah', 'Kacang Tanah Kupas') | |
| ) AS v(mapping_id, canonical_code, bapanas_name) | |
| WHERE EXISTS (SELECT 1 FROM commodity c WHERE c.code = v.canonical_code) | |
| ON CONFLICT (mapping_id) DO NOTHING; | |
| -- ============================================================================= | |
| -- 7. POLICY_DOCS | |
| -- Supabase pgvector store for RAG (Gemini embeddings, 768 dims). | |
| -- ============================================================================= | |
| CREATE TABLE IF NOT EXISTS policy_docs ( | |
| id BIGSERIAL PRIMARY KEY, | |
| title TEXT NOT NULL, | |
| source VARCHAR(100), -- e.g. 'Permendag 2024', 'BPOM No.5/2024' | |
| content TEXT NOT NULL, -- full text chunk | |
| embedding vector(768), -- text-embedding-004 output | |
| metadata JSONB NOT NULL DEFAULT '{}', | |
| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() | |
| ); | |
| -- IVFFlat index — good enough for < 1M rows; tune lists param at scale. | |
| -- Build AFTER initial bulk insert for speed. | |
| CREATE INDEX IF NOT EXISTS idx_policy_docs_embedding | |
| ON policy_docs | |
| USING ivfflat (embedding vector_cosine_ops) | |
| WITH (lists = 100); | |
| CREATE INDEX IF NOT EXISTS idx_policy_docs_source ON policy_docs(source); | |
| -- ============================================================================= | |
| -- 8. PRICE_HISTORY — TimesFM INPUT | |
| -- | |
| -- One row per (date, city_id, commodity_code). | |
| -- Fed into google/timesfm-2.0-500m-pytorch for forecasting. | |
| -- | |
| -- DATA CONTRACT (shared with TimesFM repo): | |
| -- date DATE — observation date (YYYY-MM-DD), no time zone | |
| -- city_id VARCHAR(10) — matches kabupaten.kab_id (BPS wilayah code) | |
| -- commodity_code VARCHAR(50) — matches commodity.code (AgriFlow canonical) | |
| -- price_per_kg NUMERIC(12,2) — observed price in IDR per kg | |
| -- ============================================================================= | |
| CREATE TABLE IF NOT EXISTS price_history ( | |
| id BIGSERIAL PRIMARY KEY, | |
| date DATE NOT NULL, | |
| city_id VARCHAR(10) NOT NULL REFERENCES kabupaten(kab_id), | |
| commodity_code VARCHAR(50) NOT NULL REFERENCES commodity(code), | |
| price_per_kg NUMERIC(12, 2) NOT NULL, | |
| data_source VARCHAR(30) NOT NULL DEFAULT 'PIHPS', -- 'PIHPS'|'BAPANAS'|'MANUAL' | |
| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| UNIQUE (date, city_id, commodity_code) | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_ph_date ON price_history(date DESC); | |
| CREATE INDEX IF NOT EXISTS idx_ph_city ON price_history(city_id); | |
| CREATE INDEX IF NOT EXISTS idx_ph_commodity ON price_history(commodity_code); | |
| -- Composite for the primary TimesFM query pattern: time series per city+commodity | |
| CREATE INDEX IF NOT EXISTS idx_ph_city_commodity_date | |
| ON price_history(city_id, commodity_code, date DESC); | |
| -- ============================================================================= | |
| -- 9. FORECASTS — TimesFM OUTPUT | |
| -- | |
| -- One row per (commodity, city_id, date) per model run. | |
| -- Written by the TimesFM inference pipeline; read by the AgriFlow dashboard. | |
| -- | |
| -- DATA CONTRACT (shared with TimesFM repo): | |
| -- commodity VARCHAR(50) — matches commodity.code | |
| -- city_id VARCHAR(10) — matches kabupaten.kab_id | |
| -- city_name VARCHAR(100) — denormalized for dashboard convenience | |
| -- date DATE — forecast date (YYYY-MM-DD) | |
| -- price_forecast NUMERIC(12,2) — point forecast, IDR per kg | |
| -- price_lower NUMERIC(12,2) — lower bound (e.g. 10th percentile) | |
| -- price_upper NUMERIC(12,2) — upper bound (e.g. 90th percentile) | |
| -- model VARCHAR(100) — model identifier, e.g. 'google/timesfm-2.0-500m-pytorch' | |
| -- generated_at TIMESTAMPTZ — when this forecast was produced | |
| -- ============================================================================= | |
| CREATE TABLE IF NOT EXISTS forecasts ( | |
| id BIGSERIAL PRIMARY KEY, | |
| commodity VARCHAR(50) NOT NULL REFERENCES commodity(code), | |
| city_id VARCHAR(10) NOT NULL REFERENCES kabupaten(kab_id), | |
| city_name VARCHAR(100) NOT NULL, | |
| date DATE NOT NULL, | |
| price_forecast NUMERIC(12, 2) NOT NULL, | |
| price_lower NUMERIC(12, 2), | |
| price_upper NUMERIC(12, 2), | |
| model VARCHAR(100) NOT NULL DEFAULT 'google/timesfm-2.0-500m-pytorch', | |
| generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| UNIQUE (commodity, city_id, date, model) | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_forecasts_commodity ON forecasts(commodity); | |
| CREATE INDEX IF NOT EXISTS idx_forecasts_city ON forecasts(city_id); | |
| CREATE INDEX IF NOT EXISTS idx_forecasts_date ON forecasts(date DESC); | |
| CREATE INDEX IF NOT EXISTS idx_forecasts_generated_at ON forecasts(generated_at DESC); | |
| -- Composite for the primary dashboard query: latest forecast per city+commodity | |
| CREATE INDEX IF NOT EXISTS idx_forecasts_city_commodity_date | |
| ON forecasts(city_id, commodity, date DESC); | |
| -- ============================================================================= | |
| -- 10. SUBSCRIBER — WhatsApp identity + plan | |
| -- | |
| -- PRIVACY CONTRACT: phone_hash is a salted SHA-256 digest of the normalized | |
| -- number (see whatsapp_bot/subscription.py::hash_phone). The raw number is | |
| -- NEVER written to this database. The salt lives in PHONE_HASH_SALT, outside | |
| -- the database, so a dump of this table alone cannot be reversed into a | |
| -- contact list even by brute force. | |
| -- | |
| -- dashboard_user_id links a WhatsApp identity to a Supabase Auth user once the | |
| -- two channels are tied together. Nullable: most WhatsApp users never sign in | |
| -- to the dashboard, and most dashboard users are government staff with no | |
| -- WhatsApp subscription. | |
| -- ============================================================================= | |
| CREATE TABLE IF NOT EXISTS subscriber ( | |
| phone_hash CHAR(64) PRIMARY KEY, -- hex SHA-256, salted | |
| plan VARCHAR(10) NOT NULL DEFAULT 'FREE' | |
| CHECK (plan IN ('FREE', 'PRO')), | |
| plan_expires_at TIMESTAMPTZ, -- NULL = perpetual (or FREE) | |
| dashboard_user_id UUID, -- Supabase auth.users.id | |
| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_subscriber_plan ON subscriber(plan); | |
| CREATE INDEX IF NOT EXISTS idx_subscriber_expires ON subscriber(plan_expires_at); | |
| CREATE INDEX IF NOT EXISTS idx_subscriber_dash_user ON subscriber(dashboard_user_id); | |
| -- ============================================================================= | |
| -- 11. WA_USAGE_DAILY — free-tier counter | |
| -- | |
| -- One row per (phone_hash, day). usage_date is a WIB (UTC+7) calendar date, | |
| -- computed by the application, NOT by the database — the server may run in UTC | |
| -- and CURRENT_DATE would then roll the quota over at 07:00 local time. | |
| -- | |
| -- Incremented via INSERT .. ON CONFLICT DO UPDATE so concurrent workers cannot | |
| -- lose a count and hand out queries beyond the limit. | |
| -- ============================================================================= | |
| CREATE TABLE IF NOT EXISTS wa_usage_daily ( | |
| phone_hash CHAR(64) NOT NULL, | |
| usage_date DATE NOT NULL, | |
| query_count INTEGER NOT NULL DEFAULT 0, | |
| PRIMARY KEY (phone_hash, usage_date) | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_wa_usage_date ON wa_usage_daily(usage_date DESC); | |
| -- ============================================================================= | |
| -- 12. PAYMENT_ORDER — upgrade orders | |
| -- | |
| -- No foreign key to subscriber: an order is created the moment a user asks to | |
| -- upgrade, which may be before any subscriber row exists for them. | |
| -- ============================================================================= | |
| CREATE TABLE IF NOT EXISTS payment_order ( | |
| order_id VARCHAR(20) PRIMARY KEY, -- e.g. 'AF-1A2B3C4D' | |
| phone_hash CHAR(64) NOT NULL, | |
| plan VARCHAR(10) NOT NULL DEFAULT 'PRO' | |
| CHECK (plan IN ('FREE', 'PRO')), | |
| amount_idr INTEGER NOT NULL, | |
| status VARCHAR(10) NOT NULL DEFAULT 'PENDING' | |
| CHECK (status IN ('PENDING', 'PAID', 'EXPIRED')), | |
| provider VARCHAR(30) NOT NULL DEFAULT 'MOCK', | |
| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| paid_at TIMESTAMPTZ | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_order_phone ON payment_order(phone_hash); | |
| CREATE INDEX IF NOT EXISTS idx_order_status ON payment_order(status); | |
| -- ============================================================================= | |
| -- 13. ROW LEVEL SECURITY — lock the PostgREST surface | |
| -- ============================================================================= | |
| -- | |
| -- WHY THIS SECTION EXISTS | |
| -- ----------------------- | |
| -- Supabase automatically exposes every table in the `public` schema over | |
| -- PostgREST, and the `anon` API key is PUBLIC BY DESIGN: it ships to every | |
| -- visitor's browser inside NEXT_PUBLIC_SUPABASE_ANON_KEY. Without RLS, anyone | |
| -- who opens devtools can lift that key and talk to this database directly: | |
| -- | |
| -- GET /rest/v1/subscriber -> read every subscriber row | |
| -- PATCH /rest/v1/subscriber -> set their own plan to 'PRO' | |
| -- | |
| -- That would bypass the JWT verification in whatsapp_bot/auth.py entirely. | |
| -- Locking the API layer while leaving PostgREST open is a locked front door | |
| -- next to an open back one. | |
| -- | |
| -- THE MODEL: DENY BY DEFAULT | |
| -- -------------------------- | |
| -- Enabling RLS with NO policies denies all access to non-owner roles. Since | |
| -- AgriFlow's dashboard never queries Supabase tables directly (it reads | |
| -- everything through the FastAPI service, and uses Supabase only to sign users | |
| -- in), nothing legitimate needs the PostgREST path. So we grant nothing. | |
| -- | |
| -- The FastAPI backend is unaffected: it connects over SUPABASE_DB_URL as the | |
| -- table owner, and in Postgres the owner bypasses RLS. PostgREST connects as | |
| -- `anon` or `authenticated`, which do not. | |
| -- | |
| -- ⚠ DO NOT ADD `FORCE ROW LEVEL SECURITY`. | |
| -- FORCE additionally subjects the table OWNER to RLS. With no policies | |
| -- defined, an owner that lacks the BYPASSRLS attribute then sees zero rows | |
| -- and cannot insert -- an outage that looks exactly like "the database is | |
| -- empty". Enabling RLS is correct here; forcing it buys nothing. | |
| -- | |
| -- Measured on PostgreSQL 17.2 against a NOSUPERUSER NOBYPASSRLS owner: | |
| -- RLS enabled, no FORCE -> owner SELECT returns its row | |
| -- RLS enabled + FORCE -> owner SELECT returns 0 rows, | |
| -- INSERT fails "violates row-level security" | |
| -- A superuser or BYPASSRLS role is immune and will not reproduce this, which | |
| -- is exactly why the mistake survives a casual test and bites in production. | |
| -- | |
| -- IF YOU LATER WANT BROWSER-DIRECT READS | |
| -- -------------------------------------- | |
| -- Add a narrow policy per table rather than disabling RLS, e.g. to let any | |
| -- signed-in user read reference data: | |
| -- | |
| -- CREATE POLICY kabupaten_read_authenticated ON kabupaten | |
| -- FOR SELECT TO authenticated USING (true); | |
| -- | |
| -- Never write a policy `USING (true)` for the `anon` role on subscriber, | |
| -- wa_usage_daily, or payment_order. Those are per-person records. | |
| -- ============================================================================= | |
| ALTER TABLE kabupaten ENABLE ROW LEVEL SECURITY; | |
| ALTER TABLE commodity ENABLE ROW LEVEL SECURITY; | |
| ALTER TABLE surplus_deficit ENABLE ROW LEVEL SECURITY; | |
| ALTER TABLE weather_forecast ENABLE ROW LEVEL SECURITY; | |
| ALTER TABLE historical_prices ENABLE ROW LEVEL SECURITY; | |
| ALTER TABLE commodity_code_map ENABLE ROW LEVEL SECURITY; | |
| ALTER TABLE policy_docs ENABLE ROW LEVEL SECURITY; | |
| ALTER TABLE price_history ENABLE ROW LEVEL SECURITY; | |
| ALTER TABLE forecasts ENABLE ROW LEVEL SECURITY; | |
| ALTER TABLE subscriber ENABLE ROW LEVEL SECURITY; | |
| ALTER TABLE wa_usage_daily ENABLE ROW LEVEL SECURITY; | |
| ALTER TABLE payment_order ENABLE ROW LEVEL SECURITY; | |
| -- Defence in depth: revoke table privileges from the PostgREST roles outright, | |
| -- so a carelessly-added policy later cannot by itself open a table up. | |
| -- | |
| -- Wrapped in a guard because `anon` and `authenticated` are Supabase-specific | |
| -- roles. This file should stay runnable against a plain Postgres instance (a | |
| -- local test database, CI), where those roles do not exist and an unguarded | |
| -- REVOKE would abort the script. | |
| DO $$ | |
| BEGIN | |
| IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN | |
| REVOKE ALL ON ALL TABLES IN SCHEMA public FROM anon; | |
| REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM anon; | |
| END IF; | |
| IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN | |
| REVOKE ALL ON ALL TABLES IN SCHEMA public FROM authenticated; | |
| REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM authenticated; | |
| END IF; | |
| END | |
| $$; | |
| -- Verification — run after applying. Every row must show rowsecurity = true. | |
| -- Any 'f' is a table still readable with the public anon key. | |
| -- | |
| -- SELECT tablename, rowsecurity | |
| -- FROM pg_tables | |
| -- WHERE schemaname = 'public' | |
| -- ORDER BY rowsecurity, tablename; | |