diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 00000000..4d0365b1 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,3 @@ +[alembic] +script_location = migrations/alembic +sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost/solfoundry diff --git a/backend/app/database.py b/backend/app/database.py index 0df0398b..b52ff89b 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -89,6 +89,13 @@ async def init_db() -> None: from app.models.user import User # noqa: F401 from app.models.bounty_table import BountyTable # noqa: F401 from app.models.agent import Agent # noqa: F401 + from app.models.contributor import ContributorDB # noqa: F401 + from app.models.submission import SubmissionDB # noqa: F401 + from app.models.tables import ( # noqa: F401 + PayoutTable, + BuybackTable, + ReputationHistoryTable, + ) await conn.run_sync(Base.metadata.create_all) diff --git a/backend/app/main.py b/backend/app/main.py index b590a1e8..4e94ae03 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -36,6 +36,21 @@ async def lifespan(app: FastAPI): await init_db() await ws_manager.init() + # Hydrate in-memory caches from PostgreSQL (source of truth) + try: + from app.services.payout_service import hydrate_from_database as hydrate_payouts + from app.services.reputation_service import hydrate_from_database as hydrate_reputation + + await hydrate_payouts() + await hydrate_reputation() + logger.info("PostgreSQL hydration complete") + except ImportError as exc: + logger.error("Hydration import failed: %s", exc) + except ConnectionRefusedError as exc: + logger.warning("PostgreSQL unavailable during hydration: %s — starting with empty caches", exc) + except Exception as exc: + logger.error("PostgreSQL hydration failed: %s — starting with empty caches", exc, exc_info=True) + # Sync bounties + contributors from GitHub Issues (replaces static seeds) try: result = await sync_all() diff --git a/backend/app/models/contributor.py b/backend/app/models/contributor.py index e8587255..70789dcb 100644 --- a/backend/app/models/contributor.py +++ b/backend/app/models/contributor.py @@ -1,4 +1,4 @@ -"""Contributor database and Pydantic models.""" +"""Contributor database and Pydantic models (Issue #162: shared Base).""" import uuid from datetime import datetime, timezone @@ -7,14 +7,12 @@ from pydantic import BaseModel, Field from sqlalchemy import Column, String, DateTime, JSON, Float, Integer, Text from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import DeclarativeBase - -class Base(DeclarativeBase): - pass +from app.database import Base class ContributorDB(Base): + """SQLAlchemy ORM model for the ``contributors`` table.""" __tablename__ = "contributors" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) diff --git a/backend/app/models/tables.py b/backend/app/models/tables.py new file mode 100644 index 00000000..09899c54 --- /dev/null +++ b/backend/app/models/tables.py @@ -0,0 +1,63 @@ +"""SQLAlchemy ORM tables for payouts, buybacks, reputation (Issue #162). + +Registered with the shared ``Base`` from ``app.database`` so +``Base.metadata.create_all`` creates them during ``init_db()``. +""" + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Boolean, Column, DateTime, Float, Index, Integer, String +from sqlalchemy.dialects.postgresql import UUID + +from app.database import Base + + +class PayoutTable(Base): + """On-chain payout records with tx_hash uniqueness constraint.""" + + __tablename__ = "payouts" + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + recipient = Column(String(100), nullable=False, index=True) + recipient_wallet = Column(String(64)) + amount = Column(Float, nullable=False) + token = Column(String(20), nullable=False, server_default="FNDRY") + bounty_id = Column(String(64), index=True) + bounty_title = Column(String(200)) + tx_hash = Column(String(128), unique=True, index=True) + status = Column(String(20), nullable=False, server_default="pending") + solscan_url = Column(String(256)) + created_at = Column(DateTime(timezone=True), nullable=False, + default=lambda: datetime.now(timezone.utc), index=True) + + +class BuybackTable(Base): + """Treasury buyback records (SOL exchanged for $FNDRY).""" + + __tablename__ = "buybacks" + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + amount_sol = Column(Float, nullable=False) + amount_fndry = Column(Float, nullable=False) + price_per_fndry = Column(Float, nullable=False) + tx_hash = Column(String(128), unique=True, index=True) + solscan_url = Column(String(256)) + created_at = Column(DateTime(timezone=True), nullable=False, + default=lambda: datetime.now(timezone.utc), index=True) + + +class ReputationHistoryTable(Base): + """Per-bounty reputation entries with contributor+bounty uniqueness.""" + + __tablename__ = "reputation_history" + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + contributor_id = Column(String(64), nullable=False, index=True) + bounty_id = Column(String(64), nullable=False, index=True) + bounty_title = Column(String(200), nullable=False) + bounty_tier = Column(Integer, nullable=False) + review_score = Column(Float, nullable=False) + earned_reputation = Column(Float, nullable=False, server_default="0") + anti_farming_applied = Column(Boolean, nullable=False, server_default="false") + created_at = Column(DateTime(timezone=True), nullable=False, + default=lambda: datetime.now(timezone.utc), index=True) + __table_args__ = ( + Index("ix_rep_cid_bid", "contributor_id", "bounty_id", unique=True),) diff --git a/backend/app/services/bounty_service.py b/backend/app/services/bounty_service.py index f3cec3db..407cf5bf 100644 --- a/backend/app/services/bounty_service.py +++ b/backend/app/services/bounty_service.py @@ -1,13 +1,17 @@ -"""In-memory bounty service for MVP (Issue #3). +"""Bounty service with PostgreSQL write-through persistence (Issue #162). -Provides CRUD operations and solution submission. -Claim lifecycle is out of scope (see Issue #16). +In-memory cache is the hot read path; every mutation is written through +to PostgreSQL via ``pg_store`` so the database is the durable source of +truth. Write errors are logged but do not block the API response +(fire-and-forget via ``asyncio.create_task``). """ +import asyncio +import logging from datetime import datetime, timezone from typing import Optional -from app.core.audit import audit_event +from app.core.audit import audit_event from app.models.bounty import ( BountyCreate, BountyDB, @@ -24,19 +28,37 @@ VALID_STATUS_TRANSITIONS, ) -# --------------------------------------------------------------------------- -# In-memory store (replaced by a database in production) -# --------------------------------------------------------------------------- +logger = logging.getLogger(__name__) +# In-memory cache (write-through to PostgreSQL) _bounty_store: dict[str, BountyDB] = {} +def _fire_and_forget(coro) -> None: + """Schedule an async coroutine as a background task. + + Attaches a done-callback that logs exceptions so write failures + are never silently swallowed. No-ops when called outside an + async context (e.g. synchronous tests). + """ + try: + loop = asyncio.get_running_loop() + task = loop.create_task(coro) + task.add_done_callback( + lambda t: logger.error("pg_store background write failed", exc_info=t.exception()) + if t.exception() else None + ) + except RuntimeError: + pass # No event loop (sync tests) + + # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _to_submission_response(s: SubmissionRecord) -> SubmissionResponse: + """Convert a SubmissionRecord to the public API response schema.""" return SubmissionResponse( id=s.id, bounty_id=s.bounty_id, @@ -50,6 +72,7 @@ def _to_submission_response(s: SubmissionRecord) -> SubmissionResponse: def _to_bounty_response(b: BountyDB) -> BountyResponse: + """Convert a BountyDB record to the full API response schema.""" subs = [_to_submission_response(s) for s in b.submissions] return BountyResponse( id=b.id, @@ -70,6 +93,7 @@ def _to_bounty_response(b: BountyDB) -> BountyResponse: def _to_list_item(b: BountyDB) -> BountyListItem: + """Convert a BountyDB record to a lightweight list item.""" subs = [_to_submission_response(s) for s in b.submissions] return BountyListItem( id=b.id, @@ -105,6 +129,8 @@ def create_bounty(data: BountyCreate) -> BountyResponse: created_by=data.created_by, ) _bounty_store[bounty.id] = bounty + from app.services.pg_store import persist_bounty + _fire_and_forget(persist_bounty(bounty)) return _to_bounty_response(bounty) @@ -186,14 +212,18 @@ def update_bounty( updated_by=bounty.created_by # In a real app, this would be the current user ) + from app.services.pg_store import persist_bounty + _fire_and_forget(persist_bounty(bounty)) return _to_bounty_response(bounty), None def delete_bounty(bounty_id: str) -> bool: - """Delete a bounty by ID. Returns True if deleted, False if not found.""" + """Delete a bounty by ID. Removes from cache and PostgreSQL.""" deleted = _bounty_store.pop(bounty_id, None) is not None if deleted: audit_event("bounty_deleted", bounty_id=bounty_id) + from app.services.pg_store import delete_bounty as pg_delete + _fire_and_forget(pg_delete(bounty_id)) return deleted @@ -230,6 +260,8 @@ def submit_solution( ) bounty.submissions.append(submission) bounty.updated_at = datetime.now(timezone.utc) + from app.services.pg_store import persist_bounty + _fire_and_forget(persist_bounty(bounty)) return _to_submission_response(submission), None @@ -271,7 +303,9 @@ def update_submission( submission_id=submission_id, new_status=status ) - + + from app.services.pg_store import persist_bounty + _fire_and_forget(persist_bounty(bounty)) return _to_submission_response(sub), None return None, "Submission not found" diff --git a/backend/app/services/contributor_service.py b/backend/app/services/contributor_service.py index 73ec7688..905a066e 100644 --- a/backend/app/services/contributor_service.py +++ b/backend/app/services/contributor_service.py @@ -1,4 +1,4 @@ -"""In-memory contributor service for MVP.""" +"""Contributor service with in-memory store (Issue #162: shared Base).""" import uuid from datetime import datetime, timezone diff --git a/backend/app/services/github_sync.py b/backend/app/services/github_sync.py index af2cec6b..a5636d92 100644 --- a/backend/app/services/github_sync.py +++ b/backend/app/services/github_sync.py @@ -327,6 +327,15 @@ async def sync_bounties() -> int: _bounty_store.clear() _bounty_store.update(new_store) + # Persist synced bounties to PostgreSQL (write-through) + try: + from app.services.pg_store import persist_bounty + + for bounty in new_store.values(): + await persist_bounty(bounty) + except Exception as exc: + logger.warning("DB persistence during sync failed: %s", exc) + _last_sync = datetime.now(timezone.utc) logger.info("Synced %d bounties from GitHub Issues", len(new_store)) return len(new_store) diff --git a/backend/app/services/payout_service.py b/backend/app/services/payout_service.py index eddb9020..31e82a04 100644 --- a/backend/app/services/payout_service.py +++ b/backend/app/services/payout_service.py @@ -1,11 +1,18 @@ -"""In-memory payout service (MVP -- data lost on restart, DB coming later).""" +"""Payout service with PostgreSQL write-through persistence (Issue #162). + +In-memory cache is the hot read path. On startup ``hydrate_from_database`` +loads all rows from PostgreSQL so the database is the durable source of +truth. Every create operation fires a background write to PostgreSQL. +""" from __future__ import annotations +import asyncio +import logging import threading from typing import Optional -from app.core.audit import audit_event +from app.core.audit import audit_event from app.models.payout import ( BuybackCreate, BuybackRecord, @@ -18,6 +25,8 @@ PayoutStatus, ) +logger = logging.getLogger(__name__) + _lock = threading.Lock() _payout_store: dict[str, PayoutRecord] = {} _buyback_store: dict[str, BuybackRecord] = {} @@ -25,6 +34,43 @@ SOLSCAN_TX_BASE = "https://solscan.io/tx" +async def hydrate_from_database() -> None: + """Load payouts and buybacks from PostgreSQL into in-memory cache. + + Called once during application startup. Errors propagate so the + lifespan handler can log them and decide on fallback behaviour. + """ + from app.services.pg_store import load_payouts, load_buybacks + + payouts = await load_payouts() + buybacks = await load_buybacks() + with _lock: + _payout_store.update(payouts) + _buyback_store.update(buybacks) + + +def _fire_db(record, kind: str) -> None: + """Schedule an async DB write as a background task. + + Logs errors via a done-callback so failures are never silent. + """ + try: + loop = asyncio.get_running_loop() + if kind == "payout": + from app.services.pg_store import insert_payout + coro = insert_payout(record) + else: + from app.services.pg_store import insert_buyback + coro = insert_buyback(record) + task = loop.create_task(coro) + task.add_done_callback( + lambda t: logger.error("pg_store %s write failed", kind, exc_info=t.exception()) + if t.exception() else None + ) + except RuntimeError: + pass # No event loop (sync tests) + + def _solscan_url(tx_hash: Optional[str]) -> Optional[str]: """Return a Solscan explorer link for *tx_hash*, or ``None``.""" if not tx_hash: @@ -92,6 +138,7 @@ def create_payout(data: PayoutCreate) -> PayoutResponse: token=record.token, tx_hash=record.tx_hash ) + _fire_db(record, "payout") return _payout_to_response(record) @@ -174,6 +221,7 @@ def create_buyback(data: BuybackCreate) -> BuybackResponse: amount_fndry=record.amount_fndry, tx_hash=record.tx_hash ) + _fire_db(record, "buyback") return _buyback_to_response(record) diff --git a/backend/app/services/pg_store.py b/backend/app/services/pg_store.py new file mode 100644 index 00000000..c355c909 --- /dev/null +++ b/backend/app/services/pg_store.py @@ -0,0 +1,158 @@ +"""PostgreSQL write-through persistence layer (Issue #162). + +All write helpers log errors and re-raise so callers can decide whether +to degrade gracefully or abort. The ``load_*`` functions hydrate +in-memory caches on startup so PostgreSQL is the source of truth. +""" + +import json +import logging +from typing import Any + +from sqlalchemy import text + +from app.database import get_db_session + +logger = logging.getLogger(__name__) + + +async def _execute_write(sql: str, params: dict[str, Any]) -> None: + """Execute a write statement, logging and re-raising on failure.""" + try: + async with get_db_session() as session: + await session.execute(text(sql), params) + await session.commit() + except Exception: + logger.error("pg_store write failed sql=%s", sql[:80], exc_info=True) + raise + + +# -- Bounty persistence ------------------------------------------------------ + +async def persist_bounty(bounty: Any) -> None: + """Upsert a bounty to PostgreSQL, updating ALL fields on conflict.""" + tier = bounty.tier.value if hasattr(bounty.tier, "value") else bounty.tier + status = bounty.status.value if hasattr(bounty.status, "value") else bounty.status + sub_count = len(bounty.submissions) if hasattr(bounty, "submissions") else 0 + await _execute_write( + "INSERT INTO bounties (id,title,description,tier,reward_amount," + "status,skills,github_issue_url,created_by,submission_count," + "popularity,created_at,updated_at,deadline) VALUES " + "(:id::uuid,:title,:desc,:tier,:rw,:st,:sk::jsonb,:gu,:cb,:sc," + "0,:ca,:ua,:dl) ON CONFLICT (id) DO UPDATE SET " + "title=EXCLUDED.title,description=EXCLUDED.description," + "tier=EXCLUDED.tier,status=EXCLUDED.status," + "reward_amount=EXCLUDED.reward_amount,skills=EXCLUDED.skills," + "github_issue_url=EXCLUDED.github_issue_url," + "created_by=EXCLUDED.created_by," + "submission_count=EXCLUDED.submission_count," + "popularity=EXCLUDED.popularity," + "deadline=EXCLUDED.deadline,updated_at=EXCLUDED.updated_at", + {"id": bounty.id, "title": bounty.title, "desc": bounty.description, + "tier": tier, "rw": bounty.reward_amount, "st": status, + "sk": json.dumps(bounty.required_skills), + "gu": bounty.github_issue_url, "cb": bounty.created_by, + "sc": sub_count, "ca": bounty.created_at, + "ua": bounty.updated_at, "dl": bounty.deadline}, + ) + + +async def delete_bounty(bounty_id: str) -> None: + """Remove a bounty row from PostgreSQL.""" + await _execute_write("DELETE FROM bounties WHERE id=:id::uuid", {"id": bounty_id}) + + +# -- Payout / buyback persistence -------------------------------------------- + +async def insert_payout(record: Any) -> None: + """Insert a payout record (no-op on duplicate).""" + await _execute_write( + "INSERT INTO payouts (id,recipient,recipient_wallet,amount,token," + "bounty_id,bounty_title,tx_hash,status,solscan_url) VALUES " + "(:id,:r,:w,:a,:t,:bid,:bt,:tx,:s,:su) ON CONFLICT (id) DO NOTHING", + {"id": record.id, "r": record.recipient, "w": record.recipient_wallet, + "a": record.amount, "t": record.token, "bid": record.bounty_id, + "bt": record.bounty_title, "tx": record.tx_hash, + "s": record.status.value, "su": record.solscan_url}, + ) + + +async def insert_buyback(record: Any) -> None: + """Insert a buyback record (no-op on duplicate).""" + await _execute_write( + "INSERT INTO buybacks (id,amount_sol,amount_fndry,price_per_fndry," + "tx_hash,solscan_url) VALUES (:id,:sol,:f,:p,:tx,:su) " + "ON CONFLICT (id) DO NOTHING", + {"id": record.id, "sol": record.amount_sol, "f": record.amount_fndry, + "p": record.price_per_fndry, "tx": record.tx_hash, + "su": record.solscan_url}, + ) + + +# -- Reputation persistence -------------------------------------------------- + +async def insert_reputation_entry(entry: Any) -> None: + """Insert a reputation history row (no-op on contributor+bounty dup).""" + await _execute_write( + "INSERT INTO reputation_history (id,contributor_id,bounty_id," + "bounty_title,bounty_tier,review_score,earned_reputation," + "anti_farming_applied) VALUES (:id,:cid,:bid,:t,:tier,:s,:r,:a) " + "ON CONFLICT (contributor_id,bounty_id) DO NOTHING", + {"id": entry.entry_id, "cid": entry.contributor_id, + "bid": entry.bounty_id, "t": entry.bounty_title, + "tier": entry.bounty_tier, "s": entry.review_score, + "r": entry.earned_reputation, "a": entry.anti_farming_applied}, + ) + + +# -- Hydration (load from PG on startup) ------------------------------------- + +async def load_payouts() -> dict[str, Any]: + """Load all payout records from PostgreSQL into a dict keyed by ID.""" + from app.models.payout import PayoutRecord, PayoutStatus + result: dict[str, Any] = {} + async with get_db_session() as session: + for row in await session.execute(text("SELECT * FROM payouts")): + result[str(row.id)] = PayoutRecord( + id=str(row.id), recipient=row.recipient, + recipient_wallet=row.recipient_wallet, amount=row.amount, + token=row.token, bounty_id=row.bounty_id, + bounty_title=row.bounty_title, tx_hash=row.tx_hash, + status=PayoutStatus(row.status), solscan_url=row.solscan_url, + created_at=row.created_at) + logger.info("Loaded %d payouts from PostgreSQL", len(result)) + return result + + +async def load_buybacks() -> dict[str, Any]: + """Load all buyback records from PostgreSQL into a dict keyed by ID.""" + from app.models.payout import BuybackRecord + result: dict[str, Any] = {} + async with get_db_session() as session: + for row in await session.execute(text("SELECT * FROM buybacks")): + result[str(row.id)] = BuybackRecord( + id=str(row.id), amount_sol=row.amount_sol, + amount_fndry=row.amount_fndry, + price_per_fndry=row.price_per_fndry, tx_hash=row.tx_hash, + solscan_url=row.solscan_url, created_at=row.created_at) + logger.info("Loaded %d buybacks from PostgreSQL", len(result)) + return result + + +async def load_reputation() -> dict[str, list[Any]]: + """Load reputation history from PostgreSQL, grouped by contributor ID.""" + from app.models.reputation import ReputationHistoryEntry + result: dict[str, list[Any]] = {} + async with get_db_session() as session: + for row in await session.execute( + text("SELECT * FROM reputation_history ORDER BY created_at")): + entry = ReputationHistoryEntry( + entry_id=str(row.id), contributor_id=row.contributor_id, + bounty_id=row.bounty_id, bounty_title=row.bounty_title, + bounty_tier=row.bounty_tier, review_score=row.review_score, + earned_reputation=row.earned_reputation, + anti_farming_applied=row.anti_farming_applied, + created_at=row.created_at) + result.setdefault(row.contributor_id, []).append(entry) + logger.info("Loaded reputation for %d contributors", len(result)) + return result diff --git a/backend/app/services/reputation_service.py b/backend/app/services/reputation_service.py index 8d8036da..714417b3 100644 --- a/backend/app/services/reputation_service.py +++ b/backend/app/services/reputation_service.py @@ -1,10 +1,13 @@ -"""Contributor reputation scoring service. +"""Reputation service with PostgreSQL write-through persistence (Issue #162). -Calculates reputation from review scores and bounty tier. Manages tier -progression, anti-farming, score history, and badges. In-memory MVP. -PostgreSQL migration path: reputation_history table on contributor_id. +Calculates reputation from review scores and bounty tier. Manages tier +progression, anti-farming, score history, and badges. On startup +``hydrate_from_database`` loads history from PostgreSQL; new entries are +written through so the database is the durable source of truth. """ +import asyncio +import logging import threading import uuid from datetime import datetime, timezone @@ -26,10 +29,43 @@ ) from app.services import contributor_service +logger = logging.getLogger(__name__) + _reputation_store: dict[str, list[ReputationHistoryEntry]] = {} _reputation_lock = threading.Lock() +async def hydrate_from_database() -> None: + """Load reputation history from PostgreSQL into in-memory cache. + + Called once during application startup. Errors propagate so the + lifespan handler can log them and decide on fallback behaviour. + """ + from app.services.pg_store import load_reputation + + loaded = await load_reputation() + if loaded: + with _reputation_lock: + _reputation_store.update(loaded) + + +def _fire_reputation_write(entry: ReputationHistoryEntry) -> None: + """Schedule an async write of a reputation entry to PostgreSQL. + + Logs errors via a done-callback so failures are never silent. + """ + try: + loop = asyncio.get_running_loop() + from app.services.pg_store import insert_reputation_entry + task = loop.create_task(insert_reputation_entry(entry)) + task.add_done_callback( + lambda t: logger.error("pg_store reputation write failed", exc_info=t.exception()) + if t.exception() else None + ) + except RuntimeError: + pass # No event loop (sync tests) + + def calculate_earned_reputation( review_score: float, bounty_tier: int, is_veteran_on_tier1: bool ) -> float: @@ -175,6 +211,7 @@ def record_reputation(data: ReputationRecordCreate) -> ReputationHistoryEntry: data.contributor_id, round(total, 2) ) + _fire_reputation_write(entry) return entry diff --git a/backend/migrations/002_full_pg_migration.sql b/backend/migrations/002_full_pg_migration.sql new file mode 100644 index 00000000..16ee2f80 --- /dev/null +++ b/backend/migrations/002_full_pg_migration.sql @@ -0,0 +1,41 @@ +-- Migration 002: Full PostgreSQL persistence tables (Issue #162). +-- Idempotent — safe to re-run. All tables use IF NOT EXISTS. + +CREATE TABLE IF NOT EXISTS payouts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + recipient VARCHAR(100) NOT NULL, + recipient_wallet VARCHAR(64), + amount DOUBLE PRECISION NOT NULL, + token VARCHAR(20) DEFAULT 'FNDRY', + bounty_id VARCHAR(64), + bounty_title VARCHAR(200), + tx_hash VARCHAR(128) UNIQUE, + status VARCHAR(20) DEFAULT 'pending', + solscan_url VARCHAR(256), + created_at TIMESTAMPTZ DEFAULT now() +); +CREATE INDEX IF NOT EXISTS ix_payouts_recipient ON payouts(recipient); + +CREATE TABLE IF NOT EXISTS buybacks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + amount_sol DOUBLE PRECISION NOT NULL, + amount_fndry DOUBLE PRECISION NOT NULL, + price_per_fndry DOUBLE PRECISION NOT NULL, + tx_hash VARCHAR(128) UNIQUE, + solscan_url VARCHAR(256), + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS reputation_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contributor_id VARCHAR(64) NOT NULL, + bounty_id VARCHAR(64) NOT NULL, + bounty_title VARCHAR(200) NOT NULL, + bounty_tier INTEGER NOT NULL, + review_score DOUBLE PRECISION NOT NULL, + earned_reputation DOUBLE PRECISION DEFAULT 0, + anti_farming_applied BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT now() +); +CREATE UNIQUE INDEX IF NOT EXISTS ix_rep_cid_bid + ON reputation_history(contributor_id, bounty_id); diff --git a/backend/migrations/alembic/env.py b/backend/migrations/alembic/env.py new file mode 100644 index 00000000..e623976a --- /dev/null +++ b/backend/migrations/alembic/env.py @@ -0,0 +1,37 @@ +"""Alembic async environment for PostgreSQL migrations (Issue #162).""" + +import asyncio +import os +from alembic import context +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import create_async_engine +from app.database import Base +from app.models.tables import PayoutTable, BuybackTable, ReputationHistoryTable # noqa: F401 +from app.models.contributor import ContributorDB # noqa: F401 + +target_metadata = Base.metadata +DATABASE_URL = os.getenv( + "DATABASE_URL", "postgresql+asyncpg://postgres:postgres@localhost/solfoundry") + + +def run_migrations_offline() -> None: + """Generate SQL without a live connection.""" + context.configure(url=DATABASE_URL, target_metadata=target_metadata, literal_binds=True) + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + """Run migrations against a live async database.""" + engine = create_async_engine(DATABASE_URL, poolclass=pool.NullPool) + async with engine.connect() as conn: + await conn.run_sync( + lambda c: context.configure(connection=c, target_metadata=target_metadata)) + await conn.run_sync(lambda c: context.run_migrations()) + await engine.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/backend/requirements.txt b/backend/requirements.txt index 93038c56..e687dd18 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,4 +15,5 @@ python-jose[cryptography]>=3.3,<4.0 solders>=0.21,<1.0 aiosqlite>=0.20.0,<1.0.0 structlog>=24.0.0,<25.0.0 -python-json-logger>=2.0.0,<3.0.0 \ No newline at end of file +python-json-logger>=2.0.0,<3.0.0 +alembic>=1.13.0,<2.0.0 \ No newline at end of file diff --git a/backend/tests/test_pg_migration.py b/backend/tests/test_pg_migration.py new file mode 100644 index 00000000..60f62f24 --- /dev/null +++ b/backend/tests/test_pg_migration.py @@ -0,0 +1,110 @@ +"""Tests for PostgreSQL full migration (Issue #162).""" + +import asyncio +import os +from pathlib import Path +import pytest + +os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///:memory:") +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-ci") + +from app.database import Base, init_db +from app.models.bounty import BountyCreate, BountyStatus +from app.models.payout import BuybackCreate, PayoutCreate +from app.services import bounty_service, payout_service + + +@pytest.fixture(scope="module") +def event_loop(): + """Module-scoped event loop for async fixtures.""" + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +@pytest.fixture(scope="module", autouse=True) +def setup_db(event_loop): + """Create all ORM tables in the in-memory SQLite database.""" + event_loop.run_until_complete(init_db()) + + +@pytest.fixture(autouse=True) +def clear(): + """Reset stores between tests.""" + bounty_service._bounty_store.clear() + payout_service._payout_store.clear() + payout_service._buyback_store.clear() + yield + + +def test_tables_registered(): + """ORM tables exist in metadata.""" + for t in ("bounties", "payouts", "buybacks", "reputation_history"): + assert t in Base.metadata.tables + + +def test_migration_sql(): + """Raw SQL migration file is valid and idempotent.""" + p = Path(__file__).parent.parent / "migrations" / "002_full_pg_migration.sql" + assert p.exists() + sql = p.read_text() + for t in ("payouts", "buybacks", "reputation_history"): + assert t in sql + assert "IF NOT EXISTS" in sql + + +def test_alembic_env(): + """Alembic env.py exists for managed migrations.""" + assert (Path(__file__).parent.parent / "migrations" / "alembic" / "env.py").exists() + + +@pytest.mark.asyncio +async def test_session(): + """Database session executes queries.""" + from app.database import get_db_session + from sqlalchemy import text + async with get_db_session() as s: + assert (await s.execute(text("SELECT 1"))).scalar() == 1 + + +def test_bounty_crud(): + """Create and delete bounty via write-through service.""" + r = bounty_service.create_bounty(BountyCreate(title="PG Test", reward_amount=100.0)) + assert r.id in bounty_service._bounty_store + assert bounty_service.delete_bounty(r.id) + + +def test_payout_create(): + """Create a payout and verify it lands in the store.""" + r = payout_service.create_payout(PayoutCreate(recipient="u", amount=100.0)) + assert r.id in payout_service._payout_store + + +def test_buyback_create(): + """Create a buyback and verify it lands in the store.""" + r = payout_service.create_buyback( + BuybackCreate(amount_sol=1.0, amount_fndry=2000.0, price_per_fndry=0.0005)) + assert r.id in payout_service._buyback_store + + +def test_seed(): + """seed_bounties() loads all LIVE_BOUNTIES into the store.""" + from app.seed_data import seed_bounties, LIVE_BOUNTIES + seed_bounties() + assert len(bounty_service._bounty_store) == len(LIVE_BOUNTIES) + + +def test_api_compat(): + """BountyResponse schema and status transitions unchanged.""" + r = bounty_service.create_bounty(BountyCreate(title="Compat", reward_amount=100.0)) + assert frozenset({"id", "title", "status", "submissions"}).issubset(r.model_dump()) + from app.models.bounty import VALID_STATUS_TRANSITIONS + assert VALID_STATUS_TRANSITIONS[BountyStatus.PAID] == set() + + +@pytest.mark.asyncio +async def test_pg_store_raises_on_bad_sql(): + """_execute_write must raise on invalid SQL, never swallow.""" + from app.services.pg_store import _execute_write + with pytest.raises(Exception): + await _execute_write("INVALID SQL", {})