File size: 4,247 Bytes
80a4a65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""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())