Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ PLUGIN_KEY=dev-plugin-key
# PLUGIN_KEYS_SECONDARY=
STAFF_KEY=dev-staff-key
MINESKIN_API_KEY=
# Precedent lookup (/precedent) — Supabase Postgres + Voyage embeddings + Claude
SUPABASE_DB_URL=
# Precedent and patch notes: the site's Postgres (db service) + Voyage embeddings + Claude
DATABASE_URL=
VOYAGE_API_KEY=
ANTHROPIC_API_KEY=

Expand Down
2 changes: 1 addition & 1 deletion backend/src/api/test_precedent_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ def test_ping_success(self, mock_ping) -> None:

# --- database errors ---

@mock.patch.dict("os.environ", {"SUPABASE_DB_URL": ""})
@mock.patch.dict("os.environ", {"DATABASE_URL": ""})
def test_list_cases_without_database_says_not_set_up(self) -> None:
res = self.client.get("/precedent/staff/cases", headers=_HEADERS)
self.assertEqual(res.status_code, 503)
Expand Down
12 changes: 6 additions & 6 deletions backend/src/dbstudio/studio.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
- coreprotect: this site's CoreProtect SQLite, read through the guarded
reader (one reader at a time, a short budget) and never written: the
server writes it, and the site mounts it read-only.
- supabase: the Postgres database holding Precedent and patch notes.
- postgres: the site's Postgres database holding Precedent and patch notes.

Reads use connections that cannot write (SQLite mode=ro with query_only,
Postgres read-only transactions). Each request runs one statement, enforced by
Expand Down Expand Up @@ -91,9 +91,9 @@ def sources() -> list[Source]:
label = config.label or config.server.title()
found.append(Source("coreprotect", f"CoreProtect · {label}", "sqlite", False,
"Blocks, containers, chat, commands and sessions. Read-only."))
if os.getenv("SUPABASE_DB_URL", "").strip():
found.append(Source("supabase", "Precedent and patch notes", "postgres", True,
"Supabase Postgres, public schema"))
if os.environ.get("DATABASE_URL", "").strip():
found.append(Source("postgres", "Precedent and patch notes", "postgres", True,
"The site's Postgres, public schema"))
return found


Expand Down Expand Up @@ -301,8 +301,8 @@ def _postgres(sql: str, *, write: bool = False) -> Result:
started = time.monotonic()
try:
# UTF-8 so the text Postgres parses is the text _check() inspected.
conn = psycopg.connect(os.environ["SUPABASE_DB_URL"].strip(), connect_timeout=5, client_encoding="utf8")
except (KeyError, psycopg.OperationalError):
conn = psycopg.connect(os.environ.get("DATABASE_URL", "").strip(), connect_timeout=5, client_encoding="utf8")
except psycopg.OperationalError:
raise StudioError("database_unavailable", 503) from None
try:
conn.read_only = not write
Expand Down
24 changes: 12 additions & 12 deletions backend/src/dbstudio/test_studio.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ def test_coreprotect_queries_have_a_short_budget(app, env, coreprotect_db, monke

def test_unknown_database(app, env): # noqa: F811
c = client(app, account(env, "rory", "root")[1])
assert c.get("/admin/database/supabase/schema").status_code == 404
assert c.get("/admin/database/postgres/schema").status_code == 404
assert query(c, "SELECT 1", "nope").status_code == 404


Expand Down Expand Up @@ -205,15 +205,15 @@ def test_postgres_reads_use_a_read_only_server_cursor_and_edits_a_pipeline(monke
def connect(dsn, **_):
connections.append(FakeConnection(log))
return connections[-1]
monkeypatch.setenv("SUPABASE_DB_URL", "postgresql://example")
monkeypatch.setenv("DATABASE_URL", "postgresql://example")
monkeypatch.setattr(studio.psycopg, "connect", connect)
result = studio.run("supabase", "SELECT name, token FROM precedent_cases")
result = studio.run("postgres", "SELECT name, token FROM precedent_cases")
assert connections[0].read_only is True
assert log == ["SET LOCAL statement_timeout = '10s'", "DECLARE studio: SELECT name, token FROM precedent_cases",
"ROLLBACK"]
assert result.rows == [["case", studio.MASK]]
log.clear()
studio.run("supabase", "UPDATE precedent_cases SET title = 'x'", write=True)
studio.run("postgres", "UPDATE precedent_cases SET title = 'x'", write=True)
assert connections[1].read_only is False
assert log == ["SET LOCAL statement_timeout = '10s'", "PIPELINE", "UPDATE precedent_cases SET title = 'x'", "COMMIT"]

Expand All @@ -226,7 +226,7 @@ def connect(dsn, **_):
def real_postgres(monkeypatch):
if not REAL_POSTGRES:
pytest.skip("STUDIO_TEST_POSTGRES is not set")
monkeypatch.setenv("SUPABASE_DB_URL", REAL_POSTGRES)
monkeypatch.setenv("DATABASE_URL", REAL_POSTGRES)
with studio.psycopg.connect(REAL_POSTGRES, autocommit=True) as conn:
conn.execute("DROP TABLE IF EXISTS studio_probe; CREATE TABLE studio_probe (id int, v text)")
conn.execute("INSERT INTO studio_probe VALUES (1, 'orig')")
Expand All @@ -243,17 +243,17 @@ def test_real_postgres_refuses_hidden_statements(real_postgres, monkeypatch, wri
monkeypatch.setattr(studio, "_check", lambda *_: None)
sql = "SELECT 1 --\r; COMMIT; BEGIN READ WRITE; UPDATE studio_probe SET v = 'changed'; COMMIT"
with pytest.raises(studio.StudioError) as refused:
studio.run("supabase", sql, write=write)
studio.run("postgres", sql, write=write)
assert "multiple commands" in refused.value.message
assert real_postgres() == "orig"


def test_real_postgres_reads_and_edits(real_postgres):
assert studio.run("supabase", "SELECT id, v FROM studio_probe").rows == [[1, "orig"]]
assert studio.run("postgres", "SELECT id, v FROM studio_probe").rows == [[1, "orig"]]
with pytest.raises(studio.StudioError) as refused:
studio.run("supabase", "WITH x AS (UPDATE studio_probe SET v = 'cte' RETURNING 1) SELECT * FROM x")
studio.run("postgres", "WITH x AS (UPDATE studio_probe SET v = 'cte' RETURNING 1) SELECT * FROM x")
assert refused.value.code == "sql_error"
edited = studio.run("supabase", "UPDATE studio_probe SET v = 'edited' RETURNING id, v", write=True)
edited = studio.run("postgres", "UPDATE studio_probe SET v = 'edited' RETURNING id, v", write=True)
assert edited.rows == [[1, "edited"]] and edited.changes == 1
assert real_postgres() == "edited"

Expand All @@ -271,10 +271,10 @@ def test_real_postgres_reads_and_edits(real_postgres):
("/* hi */ begin", True, "not_allowed"),
])
def test_postgres_statements_that_could_escape_are_refused_before_connecting(monkeypatch, sql, write, code):
monkeypatch.setenv("SUPABASE_DB_URL", "postgresql://example")
monkeypatch.setenv("DATABASE_URL", "postgresql://example")
monkeypatch.setattr(studio.psycopg, "connect", lambda *a, **k: pytest.fail("connected"))
with pytest.raises(studio.StudioError) as refused:
studio.run("supabase", sql, write=write)
studio.run("postgres", sql, write=write)
assert refused.value.code == code


Expand Down Expand Up @@ -333,7 +333,7 @@ def test_schema_and_list_loads_are_audited(app, env): # noqa: F811
c = client(app, account(env, "rory", "root")[1])
assert c.get("/admin/database").status_code == 200
assert c.get("/admin/database/site/schema").status_code == 200
assert c.get("/admin/database/supabase/schema").status_code == 404
assert c.get("/admin/database/postgres/schema").status_code == 404
rows = [(r["action"], r["outcome"]) for r in audit_rows(env) if r["action"].startswith("database.")]
assert rows == [("database.list", "ok"), ("database.schema", "ok"), ("database.schema", "failed")]
# Only reads and edits count as queries.
Expand Down
8 changes: 4 additions & 4 deletions backend/src/patchnotes/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,9 @@ def __init__(self, status: str) -> None:


def _dsn() -> str:
dsn = os.environ.get("SUPABASE_DB_URL", "").strip()
dsn = os.environ.get("DATABASE_URL", "").strip()
if not dsn:
raise PatchnotesDBError("SUPABASE_DB_URL is not set")
raise PatchnotesDBError("DATABASE_URL is not set")
return dsn


Expand Down Expand Up @@ -237,8 +237,8 @@ def migrate() -> None:
global _MIGRATED
if _MIGRATED:
return
if not os.environ.get("SUPABASE_DB_URL", "").strip():
logger.warning("SUPABASE_DB_URL unset; patch notes DB migration skipped")
if not os.environ.get("DATABASE_URL", "").strip():
logger.warning("DATABASE_URL unset; patch notes DB migration skipped")
return
conn = _connect()
try:
Expand Down
32 changes: 16 additions & 16 deletions backend/src/patchnotes/test_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def test_migrate_skips_without_dsn(self, mock_connect) -> None:
mock_connect.assert_not_called()
self.assertFalse(db._MIGRATED)

@mock.patch.dict("os.environ", {"SUPABASE_DB_URL": "postgres://x"}, clear=True)
@mock.patch.dict("os.environ", {"DATABASE_URL": "postgres://x"}, clear=True)
@mock.patch("patchnotes.db.psycopg2.connect")
def test_migrate_creates_table_once(self, mock_connect) -> None:
cursor = mock.MagicMock()
Expand All @@ -113,7 +113,7 @@ def test_migrate_creates_table_once(self, mock_connect) -> None:


class AutoApproveTest(unittest.TestCase):
@mock.patch.dict("os.environ", {"SUPABASE_DB_URL": "postgres://x"}, clear=True)
@mock.patch.dict("os.environ", {"DATABASE_URL": "postgres://x"}, clear=True)
@mock.patch("patchnotes.db.get_week_status", return_value={"postponed": False})
@mock.patch("patchnotes.db.psycopg2.connect")
def test_skips_rewritten_lines(self, mock_connect, _status) -> None:
Expand All @@ -127,7 +127,7 @@ def test_skips_rewritten_lines(self, mock_connect, _status) -> None:


class PreviewTest(unittest.TestCase):
@mock.patch.dict("os.environ", {"SUPABASE_DB_URL": "postgres://x"}, clear=True)
@mock.patch.dict("os.environ", {"DATABASE_URL": "postgres://x"}, clear=True)
@mock.patch("patchnotes.db.psycopg2.connect")
def test_replace_preview_expires_in_one_hour(self, mock_connect) -> None:
cursor = mock.MagicMock()
Expand All @@ -153,7 +153,7 @@ def test_replace_preview_expires_in_one_hour(self, mock_connect) -> None:
self.assertEqual(params[0], "2026-W39")
self.assertEqual(params[1].adapted, [{"id": "b1", "section": "new", "body": "Added a station"}])

@mock.patch.dict("os.environ", {"SUPABASE_DB_URL": "postgres://x"}, clear=True)
@mock.patch.dict("os.environ", {"DATABASE_URL": "postgres://x"}, clear=True)
@mock.patch("patchnotes.db.psycopg2.connect")
def test_load_preview_deletes_expired_rows(self, mock_connect) -> None:
cursor = mock.MagicMock()
Expand All @@ -166,7 +166,7 @@ def test_load_preview_deletes_expired_rows(self, mock_connect) -> None:


class InsertBulletTest(unittest.TestCase):
@mock.patch.dict("os.environ", {"SUPABASE_DB_URL": "postgres://x"}, clear=True)
@mock.patch.dict("os.environ", {"DATABASE_URL": "postgres://x"}, clear=True)
@mock.patch("patchnotes.db.psycopg2.connect")
def test_insert_stores_a_pending_bullet(self, mock_connect) -> None:
cursor = mock.MagicMock()
Expand All @@ -193,7 +193,7 @@ def test_insert_rejects_unknown_section(self) -> None:
with self.assertRaises(ValueError):
db.insert_bullet(section="secret", body="nope", week="2026-W39")

@mock.patch.dict("os.environ", {"SUPABASE_DB_URL": "postgres://x"}, clear=True)
@mock.patch.dict("os.environ", {"DATABASE_URL": "postgres://x"}, clear=True)
@mock.patch("patchnotes.db.current_week", return_value="2026-W39")
@mock.patch("patchnotes.db.psycopg2.connect")
def test_sourced_insert_writes_the_bullet_once(self, mock_connect, _week) -> None:
Expand Down Expand Up @@ -223,7 +223,7 @@ def test_sourced_insert_writes_the_bullet_once(self, mock_connect, _week) -> Non
_bullet_sql, bullet_params = cursor.execute.call_args_list[1].args
self.assertEqual(bullet_params, ("2026-W39", "new", "Gathering: Added a node"))

@mock.patch.dict("os.environ", {"SUPABASE_DB_URL": "postgres://x"}, clear=True)
@mock.patch.dict("os.environ", {"DATABASE_URL": "postgres://x"}, clear=True)
@mock.patch("patchnotes.db.psycopg2.connect")
def test_sourced_insert_skips_a_duplicate(self, mock_connect) -> None:
cursor = mock.MagicMock()
Expand All @@ -241,7 +241,7 @@ def test_sourced_insert_skips_a_duplicate(self, mock_connect) -> None:


class ReviewTest(unittest.TestCase):
@mock.patch.dict("os.environ", {"SUPABASE_DB_URL": "postgres://x"}, clear=True)
@mock.patch.dict("os.environ", {"DATABASE_URL": "postgres://x"}, clear=True)
@mock.patch("patchnotes.db.psycopg2.connect")
def test_approve_updates_only_pending(self, mock_connect) -> None:
cursor = mock.MagicMock()
Expand All @@ -256,7 +256,7 @@ def test_approve_updates_only_pending(self, mock_connect) -> None:
self.assertEqual(params, ("approved", None, "bullet-1"))
self.assertEqual(cursor.execute.call_count, 1)

@mock.patch.dict("os.environ", {"SUPABASE_DB_URL": "postgres://x"}, clear=True)
@mock.patch.dict("os.environ", {"DATABASE_URL": "postgres://x"}, clear=True)
@mock.patch("patchnotes.db.psycopg2.connect")
def test_deny_stores_the_reason(self, mock_connect) -> None:
cursor = mock.MagicMock()
Expand All @@ -279,7 +279,7 @@ def test_deny_rejects_a_blank_reason(self, mock_connect) -> None:
db.deny_bullet("bullet-1", " ")
mock_connect.assert_not_called()

@mock.patch.dict("os.environ", {"SUPABASE_DB_URL": "postgres://x"}, clear=True)
@mock.patch.dict("os.environ", {"DATABASE_URL": "postgres://x"}, clear=True)
@mock.patch("patchnotes.db.psycopg2.connect")
def test_missing_bullet(self, mock_connect) -> None:
cursor = mock.MagicMock()
Expand All @@ -289,7 +289,7 @@ def test_missing_bullet(self, mock_connect) -> None:
with self.assertRaises(db.BulletNotFound):
db.approve_bullet("missing")

@mock.patch.dict("os.environ", {"SUPABASE_DB_URL": "postgres://x"}, clear=True)
@mock.patch.dict("os.environ", {"DATABASE_URL": "postgres://x"}, clear=True)
@mock.patch("patchnotes.db.psycopg2.connect")
def test_already_reviewed(self, mock_connect) -> None:
cursor = mock.MagicMock()
Expand All @@ -302,7 +302,7 @@ def test_already_reviewed(self, mock_connect) -> None:


class ListTest(unittest.TestCase):
@mock.patch.dict("os.environ", {"SUPABASE_DB_URL": "postgres://x"}, clear=True)
@mock.patch.dict("os.environ", {"DATABASE_URL": "postgres://x"}, clear=True)
@mock.patch("patchnotes.db.psycopg2.connect")
def test_queue_is_pending_only(self, mock_connect) -> None:
cursor = mock.MagicMock()
Expand All @@ -315,7 +315,7 @@ def test_queue_is_pending_only(self, mock_connect) -> None:
self.assertIn("status = 'pending'", sql)
self.assertEqual(params, ("2026-W39", "2026-W39"))

@mock.patch.dict("os.environ", {"SUPABASE_DB_URL": "postgres://x"}, clear=True)
@mock.patch.dict("os.environ", {"DATABASE_URL": "postgres://x"}, clear=True)
@mock.patch("patchnotes.db.psycopg2.connect")
def test_published_week_is_approved_only(self, mock_connect) -> None:
cursor = mock.MagicMock()
Expand All @@ -329,7 +329,7 @@ def test_published_week_is_approved_only(self, mock_connect) -> None:
self.assertNotIn("deny_reason IS", sql)
self.assertEqual(params, ("2026-W39",))

@mock.patch.dict("os.environ", {"SUPABASE_DB_URL": "postgres://x"}, clear=True)
@mock.patch.dict("os.environ", {"DATABASE_URL": "postgres://x"}, clear=True)
@mock.patch("patchnotes.db.psycopg2.connect")
def test_published_notes_are_one_approved_query(self, mock_connect) -> None:
cursor = mock.MagicMock()
Expand Down Expand Up @@ -358,7 +358,7 @@ def test_published_notes_are_one_approved_query(self, mock_connect) -> None:
self.assertEqual(bullet_params, (["2026-W39", "2026-W38"],))
self.assertIn("status = 'approved'", bullet_sql)

@mock.patch.dict("os.environ", {"SUPABASE_DB_URL": "postgres://x"}, clear=True)
@mock.patch.dict("os.environ", {"DATABASE_URL": "postgres://x"}, clear=True)
@mock.patch("patchnotes.db.psycopg2.connect")
def test_published_weeks_newest_first(self, mock_connect) -> None:
cursor = mock.MagicMock()
Expand Down Expand Up @@ -441,7 +441,7 @@ def test_reads_expire_jobs_and_latest_orders_newest_first(self) -> None:
self.assertIn("ORDER BY created_at DESC", self.cursor.execute.call_args.args[0])

def test_migration_adds_jobs_and_partial_unique_index(self) -> None:
with mock.patch.dict("os.environ", {"SUPABASE_DB_URL": "postgres://x"}), mock.patch.object(db, "_MIGRATED", False):
with mock.patch.dict("os.environ", {"DATABASE_URL": "postgres://x"}), mock.patch.object(db, "_MIGRATED", False):
db.migrate()
sql = " ".join(call.args[0] for call in self.cursor.execute.call_args_list)
self.assertIn("CREATE TABLE IF NOT EXISTS patchnote_jobs", sql)
Expand Down
12 changes: 6 additions & 6 deletions backend/src/precedent/db.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Supabase Postgres storage for precedent cases (pgvector similarity search)."""
"""Postgres storage for precedent cases (pgvector similarity search)."""

from __future__ import annotations

Expand All @@ -23,7 +23,7 @@ class PrecedentDBError(RuntimeError):


class PrecedentNotConfiguredError(PrecedentDBError):
"""Raised when this site has no precedent database (SUPABASE_DB_URL unset)."""
"""Raised when this site has no precedent database (DATABASE_URL unset)."""


@dataclass(frozen=True)
Expand Down Expand Up @@ -87,9 +87,9 @@ def _write_audit(


def _dsn() -> str:
dsn = os.environ.get("SUPABASE_DB_URL", "").strip()
dsn = os.environ.get("DATABASE_URL", "").strip()
if not dsn:
raise PrecedentNotConfiguredError("SUPABASE_DB_URL is not set")
raise PrecedentNotConfiguredError("DATABASE_URL is not set")
return dsn


Expand All @@ -107,8 +107,8 @@ def migrate() -> None:
global _MIGRATED
if _MIGRATED:
return
if not os.environ.get("SUPABASE_DB_URL", "").strip():
logger.warning("SUPABASE_DB_URL unset; precedent DB migration skipped")
if not os.environ.get("DATABASE_URL", "").strip():
logger.warning("DATABASE_URL unset; precedent DB migration skipped")
return
conn = _connect()
try:
Expand Down
Loading
Loading