Spaces:
Running
Running
File size: 22,340 Bytes
b81a86b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 | -- =============================================================================
-- 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;
|