diff --git a/backend/.env.example b/backend/.env.example index 8026b5c9..761a64ee 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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= diff --git a/backend/src/api/test_precedent_routes.py b/backend/src/api/test_precedent_routes.py index e3a09f4d..26233d4b 100644 --- a/backend/src/api/test_precedent_routes.py +++ b/backend/src/api/test_precedent_routes.py @@ -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) diff --git a/backend/src/dbstudio/studio.py b/backend/src/dbstudio/studio.py index 6d35d616..19cf3ac8 100644 --- a/backend/src/dbstudio/studio.py +++ b/backend/src/dbstudio/studio.py @@ -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 @@ -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 @@ -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 diff --git a/backend/src/dbstudio/test_studio.py b/backend/src/dbstudio/test_studio.py index c284c1aa..535e51ae 100644 --- a/backend/src/dbstudio/test_studio.py +++ b/backend/src/dbstudio/test_studio.py @@ -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 @@ -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"] @@ -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')") @@ -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" @@ -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 @@ -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. diff --git a/backend/src/patchnotes/db.py b/backend/src/patchnotes/db.py index 9bb4ed01..0c2cfa5d 100644 --- a/backend/src/patchnotes/db.py +++ b/backend/src/patchnotes/db.py @@ -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 @@ -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: diff --git a/backend/src/patchnotes/test_db.py b/backend/src/patchnotes/test_db.py index d139fdee..7dc0298f 100644 --- a/backend/src/patchnotes/test_db.py +++ b/backend/src/patchnotes/test_db.py @@ -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() @@ -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: @@ -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() @@ -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() @@ -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() @@ -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: @@ -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() @@ -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() @@ -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() @@ -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() @@ -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() @@ -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() @@ -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() @@ -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() @@ -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() @@ -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) diff --git a/backend/src/precedent/db.py b/backend/src/precedent/db.py index 06d0aaa5..1b7fc68f 100644 --- a/backend/src/precedent/db.py +++ b/backend/src/precedent/db.py @@ -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 @@ -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) @@ -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 @@ -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: diff --git a/backend/src/precedent/test_db.py b/backend/src/precedent/test_db.py index 4132d971..67fb70b0 100644 --- a/backend/src/precedent/test_db.py +++ b/backend/src/precedent/test_db.py @@ -38,7 +38,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("precedent.db.register_vector") @mock.patch("precedent.db.psycopg2.connect") def test_migrate_creates_extension_and_table_once(self, mock_connect, mock_register) -> None: @@ -59,7 +59,7 @@ def test_migrate_creates_extension_and_table_once(self, mock_connect, mock_regis class InsertCaseTest(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("precedent.db.register_vector") @mock.patch("precedent.db.psycopg2.connect") def test_insert_case_returns_id(self, mock_connect, mock_register) -> None: @@ -83,7 +83,7 @@ def test_insert_case_returns_id(self, mock_connect, mock_register) -> None: class SearchSimilarTest(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("precedent.db.register_vector") @mock.patch("precedent.db.psycopg2.connect") def test_search_applies_relevance_cutoff(self, mock_connect, mock_register) -> None: @@ -99,7 +99,7 @@ def test_search_applies_relevance_cutoff(self, mock_connect, mock_register) -> N self.assertEqual(results, [{"id": "1", "distance": 0.2}]) conn.close.assert_called_once() - @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("precedent.db.register_vector") @mock.patch("precedent.db.psycopg2.connect") def test_search_players_soft_boost_lowers_and_forwards_names( @@ -115,7 +115,7 @@ def test_search_players_soft_boost_lowers_and_forwards_names( args = cursor.execute.call_args.args self.assertIn(["alice", "bob"], args[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("precedent.db.register_vector") @mock.patch("precedent.db.psycopg2.connect") def test_search_no_players_degrades_to_pure_distance(self, mock_connect, mock_register) -> None: @@ -129,7 +129,7 @@ def test_search_no_players_degrades_to_pure_distance(self, mock_connect, mock_re args = cursor.execute.call_args.args self.assertIn([], args[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("precedent.db.register_vector") @mock.patch("precedent.db.psycopg2.connect") def test_search_query_text_tokenized_for_lexical_boost( @@ -157,7 +157,7 @@ def _list(self, mock_connect, **kwargs): result = db.list_cases(**kwargs) return result, cursor, conn - @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("precedent.db.register_vector") @mock.patch("precedent.db.psycopg2.connect") def test_list_cases_orders_newest_first_without_embedding( @@ -180,7 +180,7 @@ def test_list_cases_orders_newest_first_without_embedding( mock_connect.assert_called_once() conn.close.assert_called_once() - @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("precedent.db.register_vector") @mock.patch("precedent.db.psycopg2.connect") def test_list_cases_needs_every_word_somewhere(self, mock_connect, mock_register) -> None: @@ -194,7 +194,7 @@ def test_list_cases_needs_every_word_somewhere(self, mock_connect, mock_register self.assertEqual(("%xray%", "%Wren%", 25, 0), page_params) self.assertEqual(["%xray%", "%Wren%"], count_params) - @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("precedent.db.register_vector") @mock.patch("precedent.db.psycopg2.connect") def test_list_cases_matches_pattern_characters_literally(self, mock_connect, mock_register) -> None: @@ -205,7 +205,7 @@ def test_list_cases_matches_pattern_characters_literally(self, mock_connect, moc class KnownPlayersTest(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("precedent.db.register_vector") @mock.patch("precedent.db.psycopg2.connect") def test_known_players_once_each_in_newest_spelling(self, mock_connect, mock_register) -> None: @@ -236,7 +236,7 @@ def _update(self, cursor): embedding=[0.5], ) - @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("precedent.db.register_vector") @mock.patch("precedent.db.psycopg2.connect") def test_update_case_found_persists_new_embedding( @@ -270,7 +270,7 @@ def test_update_case_found_persists_new_embedding( self.assertIn([0.5], params) conn.close.assert_called_once() - @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("precedent.db.register_vector") @mock.patch("precedent.db.psycopg2.connect") def test_update_case_not_found(self, mock_connect, mock_register) -> None: @@ -295,7 +295,7 @@ class AuditTrailTest(unittest.TestCase): """The audit row is written inside the caller's transaction, so a case can never be changed without a record of the change.""" - @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("precedent.db.register_vector") @mock.patch("precedent.db.psycopg2.connect") def test_insert_records_create_with_actor(self, mock_connect, mock_register) -> None: @@ -324,7 +324,7 @@ def test_insert_records_create_with_actor(self, mock_connect, mock_register) -> self.assertEqual("uuid-1", params[4]) self.assertIsNone(params[5]) # nothing existed before - @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("precedent.db.register_vector") @mock.patch("precedent.db.psycopg2.connect") def test_update_records_before_and_after(self, mock_connect, mock_register) -> None: @@ -366,7 +366,7 @@ def test_update_records_before_and_after(self, mock_connect, mock_register) -> N self.assertEqual("Old summary", params[5].adapted["summary"]) self.assertEqual("New summary", params[6].adapted["summary"]) - @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("precedent.db.register_vector") @mock.patch("precedent.db.psycopg2.connect") def test_delete_keeps_a_copy_of_what_was_destroyed( @@ -396,7 +396,7 @@ def test_delete_keeps_a_copy_of_what_was_destroyed( self.assertEqual("2026-08-26T10:57:00+00:00", params[5].adapted["created_at"]) self.assertIsNone(params[6]) - @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("precedent.db.register_vector") @mock.patch("precedent.db.psycopg2.connect") def test_delete_survives_a_row_without_created_at( @@ -418,7 +418,7 @@ def test_delete_survives_a_row_without_created_at( self.assertTrue(db.delete_case("case-1", actor=db.AuditActor(source="web"))) self.assertNotIn("created_at", _audit_calls(cursor)[0][1][5].adapted) - @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("precedent.db.register_vector") @mock.patch("precedent.db.psycopg2.connect") def test_no_audit_row_when_nothing_changed(self, mock_connect, mock_register) -> None: @@ -429,7 +429,7 @@ def test_no_audit_row_when_nothing_changed(self, mock_connect, mock_register) -> self.assertFalse(db.delete_case("missing", actor=db.AuditActor(source="web"))) self.assertEqual([], _audit_calls(cursor)) - @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("precedent.db.register_vector") @mock.patch("precedent.db.psycopg2.connect") def test_bot_writes_are_marked_as_unverified_source( @@ -462,7 +462,7 @@ def setUp(self) -> None: def tearDown(self) -> None: db._MIGRATED = False - @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("precedent.db.register_vector") @mock.patch("precedent.db.psycopg2.connect") def test_migrate_creates_audit_table_without_a_cascading_fk( @@ -485,7 +485,7 @@ def test_migrate_creates_audit_table_without_a_cascading_fk( class DeleteCaseTest(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("precedent.db.register_vector") @mock.patch("precedent.db.psycopg2.connect") def test_delete_case_found(self, mock_connect, mock_register) -> None: @@ -506,7 +506,7 @@ def test_delete_case_found(self, mock_connect, mock_register) -> None: self.assertTrue(db.delete_case("some-id")) conn.close.assert_called_once() - @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("precedent.db.register_vector") @mock.patch("precedent.db.psycopg2.connect") def test_delete_case_not_found(self, mock_connect, mock_register) -> None: @@ -519,7 +519,7 @@ def test_delete_case_not_found(self, mock_connect, mock_register) -> None: class GetCaseTest(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("precedent.db.register_vector") @mock.patch("precedent.db.psycopg2.connect") def test_get_case_found(self, mock_connect, mock_register) -> None: @@ -530,7 +530,7 @@ def test_get_case_found(self, mock_connect, mock_register) -> None: self.assertEqual(db.get_case("1"), {"id": "1", "summary": "s"}) - @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("precedent.db.register_vector") @mock.patch("precedent.db.psycopg2.connect") def test_get_case_not_found(self, mock_connect, mock_register) -> None: @@ -543,7 +543,7 @@ def test_get_case_not_found(self, mock_connect, mock_register) -> None: class PingDbTest(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("precedent.db.register_vector") @mock.patch("precedent.db.psycopg2.connect") def test_ping_db_runs_select_1(self, mock_connect, mock_register) -> None: diff --git a/backend/src/scripts/backfill_precedent_embeddings.py b/backend/src/scripts/backfill_precedent_embeddings.py index d83a5b5a..0759235b 100644 --- a/backend/src/scripts/backfill_precedent_embeddings.py +++ b/backend/src/scripts/backfill_precedent_embeddings.py @@ -45,9 +45,9 @@ def _connect(): - dsn = os.environ.get("SUPABASE_DB_URL", "").strip() + dsn = os.environ.get("DATABASE_URL", "").strip() if not dsn: - raise SystemExit("SUPABASE_DB_URL is not set") + raise SystemExit("DATABASE_URL is not set") conn = psycopg2.connect(dsn) register_vector(conn) return conn diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index 8adb59e8..0628bcb8 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -14,26 +14,33 @@ services: - SKINS_DEV=1 - STAFF_KEY=dev-staff-key - PLUGIN_KEY=dev-plugin-key - # Patch notes need Postgres. Staging keeps its own, so nothing here reaches production. - - SUPABASE_DB_URL=postgresql://postgres:dev-patchnotes@patchnotes-db:5432/tfmc + # Precedent and patch notes. Staging keeps its own database, so nothing here reaches production. + - DATABASE_URL=postgresql://provincesystem:${POSTGRES_PASSWORD:-dev-postgres}@db:5432/provincesystem volumes: - ./backend/src/output:/app/src/output - ./backend/src/input:/app/src/input - ./backend/src/defines:/app/src/defines - ./backend/src/data:/app/src/data depends_on: - - patchnotes-db + db: + condition: service_healthy security_opt: - no-new-privileges:true restart: unless-stopped - patchnotes-db: - image: pgvector/pgvector:pg16 + db: + image: pgvector/pgvector:pg17 environment: - - POSTGRES_PASSWORD=dev-patchnotes - - POSTGRES_DB=tfmc + - POSTGRES_USER=provincesystem + - POSTGRES_DB=provincesystem + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-dev-postgres} volumes: - - patchnotes-db:/var/lib/postgresql/data + - db:/var/lib/postgresql/data + healthcheck: + test: ["CMD", "pg_isready", "-h", "127.0.0.1", "-U", "provincesystem", "-d", "provincesystem"] + interval: 5s + timeout: 3s + retries: 12 security_opt: - no-new-privileges:true restart: unless-stopped @@ -56,4 +63,4 @@ services: restart: unless-stopped volumes: - patchnotes-db: + db: diff --git a/docker-compose.yml b/docker-compose.yml index e09e1f04..c1006ea2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,29 @@ services: - ./backend/src/data:/app/src/data env_file: - backend/.env + depends_on: + db: + condition: service_healthy + security_opt: + - no-new-privileges:true + restart: always + + # The site's Postgres (Precedent and patch notes). backend/.env sets + # DATABASE_URL=postgresql://provincesystem:@db:5432/provincesystem; + # the password comes from the project's .env. + db: + image: pgvector/pgvector:pg17 + environment: + POSTGRES_USER: provincesystem + POSTGRES_DB: provincesystem + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} + volumes: + - db:/var/lib/postgresql/data + healthcheck: + test: ["CMD", "pg_isready", "-h", "127.0.0.1", "-U", "provincesystem", "-d", "provincesystem"] + interval: 5s + timeout: 3s + retries: 12 security_opt: - no-new-privileges:true restart: always @@ -39,3 +62,6 @@ services: security_opt: - no-new-privileges:true restart: always + +volumes: + db: diff --git a/scripts/site-deploy.sh b/scripts/site-deploy.sh index 085e6ae3..8e12d574 100755 --- a/scripts/site-deploy.sh +++ b/scripts/site-deploy.sh @@ -22,8 +22,8 @@ sha=$3 [[ $sha =~ ^[0-9a-f]{40}$ ]] || usage case "$site" in - www) target=main repo=/home/tfmc/ProvinceSystem data=/home/tfmc/provincesystem-data front=3000 back=8000 ;; - dev) target=dev repo=/home/tfmc/tfmc-staging data=/home/tfmc/provincesystem-dev-data front=13001 back=18001 ;; + www) target=main repo=/home/tfmc/ProvinceSystem data=/home/tfmc/provincesystem-data front=3000 back=8000 file=docker-compose.yml ;; + dev) target=dev repo=/home/tfmc/tfmc-staging data=/home/tfmc/provincesystem-dev-data front=13001 back=18001 file=docker-compose.staging.yml ;; *) usage ;; esac [[ $(id -un) == tfmc ]] || { echo "run as tfmc: sudo -u tfmc $0 deploy $site $sha" >&2; exit 2; } @@ -36,6 +36,9 @@ flock 9 log() { echo "$*" >&2; } compose() { /usr/local/sbin/ps-compose "$target" "$@"; } +# The site's Postgres, when the checked-out compose file has one. +has_db() { grep -q '^ db:$' "$repo/$file"; } +start_db() { ! has_db || compose up -d --wait db >&2; } record() { printf '%s %s %s -> %s %s\n' "$(date -u +%FT%TZ)" "$site" "${old:0:7}" "${sha:0:7}" "$*" >> "$state/site-deploy.log"; } cd "$repo" @@ -77,6 +80,20 @@ mv "$data/data/.deploy-backup.db" "$data/deploy-backups/province.db.before-deplo find "$data/deploy-backups" -maxdepth 1 -name 'province.db.before-deploy-*' -printf '%T@ %p\n' \ | sort -rn | tail -n +11 | cut -d' ' -f2- | xargs -r rm -f +if has_db; then + log "backing up the site's Postgres" + start_db + backup="$data/deploy-backups/postgres.before-deploy-${sha:0:7}-$(date -u +%Y%m%dT%H%M%SZ).dump" + if ! compose exec -T db pg_dump -U provincesystem -d provincesystem --format=custom > "$backup.part"; then + rm -f "$backup.part" + log "could not back up the site's Postgres; nothing was changed" + exit 1 + fi + mv "$backup.part" "$backup" + find "$data/deploy-backups" -maxdepth 1 -name 'postgres.before-deploy-*' -printf '%T@ %p\n' \ + | sort -rn | tail -n +11 | cut -d' ' -f2- | xargs -r rm -f +fi + checkout() { if [[ $site == www ]]; then git reset -q --hard "$1" || return 1 @@ -99,6 +116,7 @@ healthy() { } rebuild() { + start_db || return 1 ((${#services[@]} == 0)) || compose up -d --build --no-deps "${services[@]}" >&2 }