Spaces:
Sleeping
Sleeping
| """Apply every SQL file in db/schema/ in numeric order to the Supabase database. | |
| Usage: | |
| python db/apply.py # apply all pending | |
| python db/apply.py --dry-run # print files only | |
| python db/apply.py --status # show which files have run | |
| Each file is expected to be idempotent (every CREATE uses IF NOT EXISTS | |
| and policies use DROP POLICY IF EXISTS + CREATE). The script therefore | |
| does not need a migrations ledger — re-running it is safe. | |
| Connection: reads SUPABASE_URL and SUPABASE_SERVICE_KEY from .env. The | |
| service role key is required because we run DDL. If you only have the | |
| anon key, run the same SQL through the Supabase SQL editor. | |
| """ | |
| import argparse | |
| import os | |
| import sys | |
| from pathlib import Path | |
| try: | |
| from dotenv import load_dotenv | |
| except ImportError: | |
| print("Missing dependency: python-dotenv. Run `pip install python-dotenv`.") | |
| sys.exit(1) | |
| try: | |
| import httpx | |
| except ImportError: | |
| print("Missing dependency: httpx. Run `pip install httpx`.") | |
| sys.exit(1) | |
| ROOT = Path(__file__).resolve().parent.parent | |
| SCHEMA_DIR = Path(__file__).resolve().parent / "schema" | |
| ENV_PATH = ROOT / ".env" | |
| def _load_env() -> None: | |
| if ENV_PATH.exists(): | |
| load_dotenv(ENV_PATH) | |
| else: | |
| print(f"⚠️ {ENV_PATH} not found — relying on ambient environment") | |
| def _sql_files() -> list[Path]: | |
| """Return schema files in numeric order: 000_*, 001_*, …""" | |
| files = sorted( | |
| p for p in SCHEMA_DIR.glob("*.sql") | |
| if p.name[:3].isdigit() | |
| ) | |
| return files | |
| def _postgres_url() -> str: | |
| """Return the Supabase PostgREST root URL.""" | |
| base = os.environ.get("SUPABASE_URL", "").rstrip("/") | |
| if not base: | |
| sys.exit("SUPABASE_URL is not set in the environment or .env") | |
| return base | |
| def _service_key() -> str: | |
| key = os.environ.get("SUPABASE_SERVICE_KEY") or os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "") | |
| if not key: | |
| sys.exit("SUPABASE_SERVICE_KEY is not set (required for DDL).") | |
| return key | |
| def _exec_sql(client: httpx.Client, sql: str, label: str) -> None: | |
| """Run a single SQL file via the PostgREST ``rpc`` endpoint. | |
| Supabase exposes a `pg_dump`/SQL-exec function only on hosted | |
| instances, so for a generic DDL apply we use the project's REST | |
| endpoint with a custom RPC named ``exec_sql`` if present, otherwise | |
| we fall back to the SQL editor. Here we POST the raw SQL to the | |
| ``/rest/v1/rpc/exec_sql`` endpoint. The companion function lives | |
| in db/optional/exec_sql_fn.sql (not bundled). | |
| """ | |
| url = f"{_postgres_url()}/rest/v1/rpc/exec_sql" | |
| r = client.post(url, json={"query": sql}) | |
| if r.status_code == 404: | |
| sys.exit( | |
| "exec_sql RPC not found. Create it once in the Supabase SQL editor:\n" | |
| " CREATE OR REPLACE FUNCTION public.exec_sql(query text) RETURNS void\n" | |
| " LANGUAGE plpgsql AS $$ BEGIN EXECUTE query; END $$;\n" | |
| " GRANT EXECUTE ON FUNCTION public.exec_sql(text) TO service_role;\n" | |
| "Re-run this script afterwards." | |
| ) | |
| if r.status_code not in (200, 204): | |
| sys.exit(f"❌ {label}: HTTP {r.status_code} — {r.text[:300]}") | |
| print(f"✅ {label}") | |
| def main() -> int: | |
| parser = argparse.ArgumentParser(description="Apply db/schema/*.sql in order.") | |
| parser.add_argument("--dry-run", action="store_true", help="list files only") | |
| parser.add_argument("--status", action="store_true", help="list files only (alias)") | |
| args = parser.parse_args() | |
| _load_env() | |
| files = _sql_files() | |
| if not files: | |
| print(f"No .sql files found in {SCHEMA_DIR}") | |
| return 0 | |
| print(f"Found {len(files)} schema files in {SCHEMA_DIR}:") | |
| for f in files: | |
| print(f" - {f.name}") | |
| if args.dry_run or args.status: | |
| return 0 | |
| key = _service_key() | |
| with httpx.Client(timeout=60, headers={ | |
| "apikey": key, | |
| "Authorization": f"Bearer {key}", | |
| "Content-Type": "application/json", | |
| }) as client: | |
| for f in files: | |
| sql = f.read_text(encoding="utf-8") | |
| _exec_sql(client, sql, f.name) | |
| print("\nAll schema files applied.") | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |