| """Catalog database schema — initial migration. |
| |
| Creates the tables referenced by the v4.0 catalog models. |
| Idempotent: safe to run multiple times. |
| """ |
| from __future__ import annotations |
|
|
| import asyncio |
| import os |
|
|
| import asyncpg |
|
|
|
|
| SCHEMA = """ |
| -- Tokens (T27) |
| CREATE TABLE IF NOT EXISTS tokens ( |
| token_id TEXT PRIMARY KEY, |
| chain TEXT NOT NULL, |
| address TEXT NOT NULL, |
| symbol TEXT, |
| name TEXT, |
| decimals INT, |
| deployer_wallet_id TEXT, |
| deployed_at TIMESTAMPTZ NOT NULL, |
| initial_supply BIGINT, |
| current_supply BIGINT, |
| is_honeypot BOOLEAN, |
| is_mintable BOOLEAN, |
| is_proxy BOOLEAN, |
| tax_buy_bps INT, |
| tax_sell_bps INT, |
| risk_tier TEXT, |
| risk_score INT, |
| risk_factors TEXT[], |
| rag_embedding_id TEXT, |
| updated_at TIMESTAMPTZ DEFAULT NOW() |
| ); |
| CREATE INDEX IF NOT EXISTS idx_tokens_chain ON tokens(chain); |
| CREATE INDEX IF NOT EXISTS idx_tokens_deployer ON tokens(deployer_wallet_id); |
| CREATE INDEX IF NOT EXISTS idx_tokens_risk ON tokens(risk_score DESC) WHERE risk_score IS NOT NULL; |
| |
| -- Alerts |
| CREATE TABLE IF NOT EXISTS alerts ( |
| alert_id TEXT PRIMARY KEY, |
| token_id TEXT, |
| wallet_id TEXT, |
| chain TEXT, |
| alert_type TEXT NOT NULL, |
| severity TEXT NOT NULL, |
| title TEXT NOT NULL, |
| description TEXT, |
| evidence JSONB DEFAULT '{}'::jsonb, |
| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), |
| resolved_at TIMESTAMPTZ |
| ); |
| CREATE INDEX IF NOT EXISTS idx_alerts_token ON alerts(token_id); |
| CREATE INDEX IF NOT EXISTS idx_alerts_wallet ON alerts(wallet_id); |
| CREATE INDEX IF NOT EXISTS idx_alerts_created ON alerts(created_at DESC); |
| CREATE INDEX IF NOT EXISTS idx_alerts_severity ON alerts(severity); |
| |
| -- News items (T28) |
| CREATE TABLE IF NOT EXISTS news_items ( |
| news_id TEXT PRIMARY KEY, |
| url TEXT NOT NULL, |
| title TEXT NOT NULL, |
| summary TEXT, |
| body_markdown TEXT, |
| source TEXT NOT NULL, |
| published_at TIMESTAMPTZ NOT NULL, |
| ingested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), |
| chains_mentioned TEXT[] DEFAULT ARRAY[]::TEXT[], |
| tokens_mentioned TEXT[] DEFAULT ARRAY[]::TEXT[], |
| wallets_mentioned TEXT[] DEFAULT ARRAY[]::TEXT[], |
| sentiment_score REAL, |
| ai_analysis TEXT, |
| rag_embedding_id TEXT, |
| body_tsv tsvector |
| ); |
| CREATE INDEX IF NOT EXISTS idx_news_published ON news_items(published_at DESC); |
| CREATE INDEX IF NOT EXISTS idx_news_source ON news_items(source); |
| CREATE INDEX IF NOT EXISTS idx_news_sentiment ON news_items(sentiment_score); |
| CREATE INDEX IF NOT EXISTS idx_news_body_tsv ON news_items USING gin(body_tsv); |
| |
| -- Auto-update the body_tsv column on insert/update |
| CREATE OR REPLACE FUNCTION news_items_tsv_update() RETURNS trigger AS $$ |
| BEGIN |
| NEW.body_tsv := to_tsvector('english', COALESCE(NEW.title, '') || ' ' || |
| COALESCE(NEW.summary, '') || ' ' || |
| COALESCE(NEW.body_markdown, '')); |
| RETURN NEW; |
| END |
| $$ LANGUAGE plpgsql; |
| |
| DROP TRIGGER IF EXISTS news_items_tsv_trigger ON news_items; |
| CREATE TRIGGER news_items_tsv_trigger |
| BEFORE INSERT OR UPDATE ON news_items |
| FOR EACH ROW EXECUTE FUNCTION news_items_tsv_update(); |
| |
| -- Scan reports (T29) |
| CREATE TABLE IF NOT EXISTS scan_reports ( |
| report_id TEXT PRIMARY KEY, |
| subject_type TEXT NOT NULL, |
| subject_id TEXT NOT NULL, |
| generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), |
| generated_by_model TEXT NOT NULL, |
| risk_score INT NOT NULL, |
| risk_tier TEXT NOT NULL, |
| sections JSONB DEFAULT '{}'::jsonb, |
| markdown_url TEXT, |
| paid_via_x402 TEXT |
| ); |
| CREATE INDEX IF NOT EXISTS idx_reports_subject ON scan_reports(subject_type, subject_id); |
| CREATE INDEX IF NOT EXISTS idx_reports_generated ON scan_reports(generated_at DESC); |
| |
| -- RAG findings metadata (Qdrant has the vectors; this is metadata for query) |
| CREATE TABLE IF NOT EXISTS rag_findings ( |
| finding_id TEXT PRIMARY KEY, |
| source_type TEXT NOT NULL, |
| source_url TEXT, |
| source_token_id TEXT, |
| source_wallet_id TEXT, |
| claim TEXT NOT NULL, |
| confidence REAL NOT NULL, |
| extracted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), |
| qdrant_point_id TEXT NOT NULL |
| ); |
| CREATE INDEX IF NOT EXISTS idx_findings_token ON rag_findings(source_token_id); |
| CREATE INDEX IF NOT EXISTS idx_findings_wallet ON rag_findings(source_wallet_id); |
| |
| -- x402 receipts (T34) |
| CREATE TABLE IF NOT EXISTS x402_receipts ( |
| tx_hash TEXT PRIMARY KEY, |
| agent_id TEXT, |
| tool TEXT NOT NULL, |
| amount_usd REAL NOT NULL, |
| chain TEXT, |
| paid_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), |
| tier TEXT |
| ); |
| CREATE INDEX IF NOT EXISTS idx_x402_paid_at ON x402_receipts(paid_at DESC); |
| CREATE INDEX IF NOT EXISTS idx_x402_tool ON x402_receipts(tool); |
| CREATE INDEX IF NOT EXISTS idx_x402_agent ON x402_receipts(agent_id); |
| """ |
|
|
|
|
| async def main(): |
| """Run the schema migration.""" |
| cfg = { |
| "host": os.getenv("POSTGRES_HOST", "rmi-postgres"), |
| "port": int(os.getenv("POSTGRES_PORT", "5432")), |
| "user": os.getenv("POSTGRES_USER", "rmi"), |
| "password": os.getenv("POSTGRES_PASSWORD", "RMI_PROD_POSTGRES_2026"), |
| "database": os.getenv("POSTGRES_DB", "rmi"), |
| } |
| print(f"Connecting to postgres at {cfg['host']}:{cfg['port']} db={cfg['database']}") |
| conn = await asyncpg.connect(**cfg) |
| try: |
| await conn.execute(SCHEMA) |
| print("✓ Schema applied") |
| |
| tables = await conn.fetch( |
| "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename" |
| ) |
| print(f"Tables ({len(tables)}):") |
| for t in tables: |
| print(f" {t['tablename']}") |
| finally: |
| await conn.close() |
|
|
|
|
| if __name__ == "__main__": |
| asyncio.run(main()) |
|
|