Files
c-relay-pg/plans/postgres_implementation_plan.md
T

16 KiB

C-Relay-PG: PostgreSQL Implementation Plan

Current State Assessment

What We Have

  • PostgreSQL 18.3 installed and running on localhost:5432
  • libpq development headers available via pkg-config (/usr/include/postgresql, /usr/lib/x86_64-linux-gnu)
  • db_ops.h abstraction layer with 40+ functions already defined
  • db_ops.c — single 1,398-line file implementing all functions against SQLite
  • ip_ban.c — already uses db_ops API (not raw SQLite) for persistence
  • config.c — uses db_ops API for all database access

SQLite Coupling Points (Must Fix)

Location Issue
src/main.c:11 #include <sqlite3.h>
src/main.c:51 sqlite3* g_db = NULL; — global handle owned by main.c
src/main.c:897 if (g_db) — direct handle check
src/main.c:2228 if (!g_db) — direct handle check
src/main.c:2358 g_db = NULL; — strict mode nullification
src/main.c:743 Log message references sqlite3_open()
src/main.c:854-885 SQLite PRAGMAs (WAL, mmap_size, cache_size)
src/main.c:795-824 SQLite-specific DDL in migration code
src/main.c:900 PRAGMA wal_checkpoint(TRUNCATE)
src/config.c:4727 sqlite_master query
src/config.c:4388 INSERT OR REPLACE SQL
src/db_ops.c:13 extern sqlite3* g_db;
src/db_ops.c:871 strftime('%s', 'now') — SQLite-specific
src/db_ops.c:889,909,956 INSERT OR REPLACE — SQLite-specific
src/db_ops.c:1341 sqlite_master — SQLite-specific
src/db_ops.c:1387-1395 sqlite3_wal_checkpoint_v2() — SQLite-only
src/ip_ban.c:122,205 strftime('%s', 'now') and INSERT OR REPLACE in SQL strings
src/sql_schema.h Entire embedded schema is SQLite DDL
src/subscriptions.c:1187-1197 Commented-out raw SQLite code

What Already Works Through db_ops

  • ip_ban.c — uses db_exec_sql(), db_prepare(), db_step_stmt(), etc.
  • config.c — uses db_get_config_value_dup(), db_set_config_value_full(), etc.
  • subscriptions.c — uses db_log_subscription_*() functions
  • api.c — uses db_execute_readonly_query_json(), stat helpers
  • nip009.c — uses db_delete_event_by_id(), db_get_event_pubkey()

Implementation Phases

Phase 0: PostgreSQL Setup & Smoke Test

Create the PostgreSQL role, database, and verify connectivity from C.

Tasks:
  0.1  Create PostgreSQL role "crelay" with password
  0.2  Create database "crelay" owned by role "crelay"
  0.3  Write a minimal C test program that connects via libpq and runs SELECT 1
  0.4  Verify the test compiles and links with: gcc test_pg.c -lpq -o test_pg

Phase 1: Internalize g_db — Move Database Ownership Into db_ops

Goal: Eliminate extern sqlite3* g_db from main.c. The db_ops module must own its connection handle internally. This is the critical prerequisite — without it, swapping backends is impossible.

flowchart LR
    subgraph Before
        MAIN[main.c owns sqlite3* g_db] --> DBOPS[db_ops.c uses extern g_db]
    end
    subgraph After
        MAIN2[main.c calls db_init/db_close] --> DBOPS2[db_ops.c owns static g_db internally]
    end
Tasks:
  1.1  Move `sqlite3* g_db` from main.c into db_ops.c as a static variable
  1.2  Move `char g_database_path[512]` from config.c into db_ops.c as static
  1.3  Remove `#include <sqlite3.h>` from main.c
  1.4  Replace all `g_db` references in main.c with db_ops API calls:
       - `if (g_db)` → `if (db_is_available())`
       - `g_db = NULL` → `db_close()` (or new `db_detach_main()`)
  1.5  Replace SQLite PRAGMA calls in main.c init_database() with new db_ops functions:
       - `db_set_journal_mode_wal()`
       - `db_set_mmap_size(long size)`
       - `db_set_cache_size(int kb)`
  1.6  Move schema migration logic from main.c into db_ops (new `db_apply_schema()`)
  1.7  Replace `sqlite_master` query in config.c:4727 with `db_table_exists()`
  1.8  Replace `INSERT OR REPLACE` in config.c:4388 with `db_prepare()`/`db_step_stmt()`
  1.9  Build and run existing test suite to confirm no regressions

Phase 2: Rename db_ops.c → db_ops_sqlite.c, Create Dispatcher

Goal: Split the single db_ops.c into a backend-specific file and a thin dispatcher, preparing the architecture for a second backend.

New file layout:
  src/db_ops.h              — unchanged public API
  src/db_ops_sqlite.c       — renamed from db_ops.c (all SQLite implementation)
  src/db_ops_postgres.c     — new file (stub initially)
  src/db_ops.c              — thin dispatcher that forwards to active backend
Tasks:
  2.1  Rename src/db_ops.c → src/db_ops_sqlite.c
  2.2  Prefix all function names in db_ops_sqlite.c with `sqlite_` (e.g., `sqlite_db_init()`)
  2.3  Create src/db_ops.c dispatcher with compile-time backend selection:
       - #ifdef DB_BACKEND_POSTGRES → call postgres_* functions
       - #else → call sqlite_* functions (default)
  2.4  Create src/db_ops_postgres.c with stub implementations that return DB_ERROR
  2.5  Update Makefile with DB_BACKEND variable:
       - `DB_BACKEND ?= sqlite`
       - Conditional source file inclusion and -lpq linking
  2.6  Build with DB_BACKEND=sqlite — verify identical behavior
  2.7  Build with DB_BACKEND=postgres — verify it compiles (stubs return errors)

Phase 3: PostgreSQL Schema

Goal: Create the PostgreSQL schema file and apply it to the database.

Tasks:
  3.1  Create src/pg_schema.sql with PostgreSQL DDL from the plan:
       - events table with JSONB tags column
       - GIN index on tags
       - config, auth_rules, relay_seckey, subscriptions, ip_bans tables
       - Materialized views for dashboard
  3.2  Create src/pg_schema.h — embedded schema as C string (like sql_schema.h)
  3.3  Add `postgres_db_apply_schema()` function in db_ops_postgres.c
  3.4  Apply schema to the crelay database and verify with psql

Phase 4: Implement db_ops_postgres.c — Core Functions

Goal: Implement the PostgreSQL backend for all db_ops.h functions using libpq.

This is the largest phase. Functions are grouped by dependency order.

4A: Connection Management

Tasks:
  4A.1  Implement postgres_db_init() — PQconnectdb() with connection string
  4A.2  Implement postgres_db_close() — PQfinish()
  4A.3  Implement postgres_db_is_available() — PQstatus() check
  4A.4  Implement postgres_db_last_error() — PQerrorMessage()
  4A.5  Implement postgres_db_get_database_path() — return connection string
  4A.6  Implement thread-local connection pool:
        - postgres_db_set_thread_connection()
        - postgres_db_clear_thread_connection()
        - postgres_db_open_worker_connection() — new PQconnectdb per worker
        - postgres_db_close_worker_connection() — PQfinish per worker

4B: Statement API (db_stmt_t wrapper for PGresult)

Tasks:
  4B.1  Define postgres db_stmt_t struct wrapping PGresult + cursor state
  4B.2  Implement postgres_db_prepare() — PQprepare() with auto-generated name
  4B.3  Implement postgres_db_bind_text_param/int_param/int64_param
        Note: libpq uses $1,$2 params, not ?. Need parameter array approach.
  4B.4  Implement postgres_db_step_stmt() — PQexecPrepared() + row iteration
  4B.5  Implement postgres_db_column_text_value/int_value/int64_value/double_value
  4B.6  Implement postgres_db_reset_stmt() and postgres_db_finalize_stmt()
  4B.7  Implement postgres_db_exec_sql() — PQexec() for DDL/DML

Key design decision: libpq doesn't have a step-based cursor like SQLite. Two approaches:

  1. PQexecPrepared returns all rows at once — iterate with row index
  2. DECLARE CURSOR / FETCH for streaming large result sets

For this relay's workload (most queries return <1000 rows), approach 1 is simpler and sufficient.

4C: Config & Key Storage

Tasks:
  4C.1  Implement postgres_db_get_all_config_rows()
  4C.2  Implement postgres_db_get_config_value_dup()
  4C.3  Implement postgres_db_set_config_value_full()
        — Use INSERT ... ON CONFLICT (key) DO UPDATE instead of INSERT OR REPLACE
  4C.4  Implement postgres_db_update_config_value_only()
        — Use EXTRACT(EPOCH FROM NOW()) instead of strftime('%s','now')
  4C.5  Implement postgres_db_upsert_config_value()
  4C.6  Implement postgres_db_store_relay_private_key_hex()
  4C.7  Implement postgres_db_get_relay_private_key_hex_dup()
  4C.8  Implement postgres_db_store_config_event()
  4C.9  Implement postgres_db_get_config_row_count()

4D: Event Storage & Retrieval

Tasks:
  4D.1  Implement postgres_db_insert_event_with_json()
        — INSERT ... ON CONFLICT (id) DO NOTHING
        — Map extended_errcode to DB_CONSTRAINT for duplicates
  4D.2  Implement postgres_db_event_id_exists()
  4D.3  Implement postgres_db_retrieve_event_by_id()
  4D.4  Implement postgres_db_get_event_pubkey()
  4D.5  Implement postgres_db_get_event_time_bounds()
  4D.6  Implement postgres_db_get_latest_event_pubkey_for_kind_dup()

4E: Event Deletion (NIP-09)

Tasks:
  4E.1  Implement postgres_db_delete_event_by_id()
  4E.2  Implement postgres_db_delete_events_by_address()

4F: Tag Operations

Tasks:
  4F.1  Implement postgres_db_store_event_tags_cjson()
        — Note: With JSONB+GIN, this may become a no-op for PostgreSQL
        — Tags are already stored in the events.tags JSONB column
  4F.2  Implement postgres_db_populate_event_tags_from_existing()
        — No-op for PostgreSQL (no separate event_tags table needed)

4G: Auth Rules

Tasks:
  4G.1  Implement postgres_db_is_pubkey_blacklisted()
  4G.2  Implement postgres_db_is_hash_blacklisted()
  4G.3  Implement postgres_db_is_pubkey_whitelisted()
  4G.4  Implement postgres_db_count_active_whitelist_rules()
  4G.5  Implement postgres_db_add_auth_rule()
  4G.6  Implement postgres_db_remove_auth_rule()
  4G.7  Implement postgres_db_delete_wot_whitelist_rules()
  4G.8  Implement postgres_db_count_wot_whitelist_rules()

4H: Subscription Logging

Tasks:
  4H.1  Implement postgres_db_log_subscription_created()
        — Use INSERT ... ON CONFLICT DO UPDATE
  4H.2  Implement postgres_db_log_subscription_closed()
        — Use EXTRACT(EPOCH FROM NOW()) for timestamps
  4H.3  Implement postgres_db_log_subscription_disconnected()
  4H.4  Implement postgres_db_update_subscription_events_sent()
  4H.5  Implement postgres_db_cleanup_orphaned_subscriptions()

4I: Monitoring & Stats

Tasks:
  4I.1  Implement postgres_db_get_total_event_count_ll()
  4I.2  Implement postgres_db_get_event_count_since()
  4I.3  Implement postgres_db_get_event_kind_distribution_rows()
  4I.4  Implement postgres_db_get_top_pubkeys_rows()
  4I.5  Implement postgres_db_get_subscription_details_rows()
  4I.6  Implement postgres_db_count_with_sql()
  4I.7  Implement postgres_db_execute_readonly_query_json()
        — Map PG column types instead of SQLITE_INTEGER/SQLITE_TEXT/etc.

4J: Schema & DDL Helpers

Tasks:
  4J.1  Implement postgres_db_table_exists()
        — Use information_schema.tables instead of sqlite_master
  4J.2  Implement postgres_db_get_schema_version_dup()
  4J.3  Implement postgres_db_wal_checkpoint_passive() — no-op for PostgreSQL
  4J.4  Implement postgres_db_wal_checkpoint_truncate() — no-op for PostgreSQL

Phase 5: Fix SQLite-Specific SQL in Callers

Goal: Ensure SQL strings passed through db_prepare() / db_exec_sql() from outside db_ops are portable.

Tasks:
  5.1  Audit ip_ban.c SQL strings:
       — Replace `strftime('%s', 'now')` with `EXTRACT(EPOCH FROM NOW())::BIGINT`
         or pass timestamp as bind parameter from C (time(NULL))
       — Replace `INSERT OR REPLACE` with `INSERT ... ON CONFLICT DO UPDATE`
  5.2  Audit config.c SQL strings:
       — Replace `sqlite_master` query with db_table_exists()
       — Replace `INSERT OR REPLACE` with ON CONFLICT syntax
  5.3  Audit main.c init_database():
       — Move all PRAGMA calls behind db_ops backend-specific init
       — Move schema migration behind db_apply_schema()
  5.4  Strategy decision: use C-side timestamps (time(NULL)) as bind params
       instead of database-side timestamp functions for portability

Phase 6: CLI Connection String Support

Goal: Accept PostgreSQL connection parameters from the command line.

Tasks:
  6.1  Add --db-host, --db-port, --db-name, --db-user, --db-password CLI options
  6.2  Add --db-connstring option for full libpq connection string
  6.3  Construct connection string from individual params if --db-connstring not given
  6.4  Pass connection string to db_init() (works for both backends)
  6.5  Update make_and_restart_relay.sh to support PostgreSQL connection params
  6.6  Update AGENTS.md with new CLI options

Phase 7: Integration Testing

Goal: Verify the PostgreSQL backend passes all existing tests.

Tasks:
  7.1  Build with DB_BACKEND=postgres
  7.2  Start relay with PostgreSQL connection string
  7.3  Run tests/run_nip_tests.sh — verify NIP compliance
  7.4  Run tests/run_all_tests.sh — verify full test suite
  7.5  Run tests/performance_benchmarks.sh — compare with SQLite baseline
  7.6  Test first-time startup flow (key generation, schema creation)
  7.7  Test config event storage and retrieval
  7.8  Test auth rules (whitelist/blacklist)
  7.9  Test IP ban persistence across restarts
  7.10 Test thread pool worker connections with PostgreSQL

Phase 8: Query Builder — JSONB Tag Queries

Goal: Replace the event_tags JOIN queries with PostgreSQL JSONB containment queries for tag filtering in REQ handling.

Tasks:
  8.1  Identify where REQ filter → SQL translation happens (websockets.c / subscriptions.c)
  8.2  Create backend-aware query builder or use #ifdef for tag filter SQL:
       — SQLite: `id IN (SELECT event_id FROM event_tags WHERE tag_name=? AND tag_value IN (?))`
       — PostgreSQL: `tags @> '[["p", "value"]]'::jsonb`
  8.3  Test tag-filtered REQ queries against PostgreSQL
  8.4  Verify GIN index is being used (EXPLAIN ANALYZE)

SQL Dialect Mapping Reference

SQLite PostgreSQL
INSERT OR REPLACE INTO t ... INSERT INTO t ... ON CONFLICT (pk) DO UPDATE SET ...
strftime('%s', 'now') EXTRACT(EPOCH FROM NOW())::BIGINT
SELECT 1 FROM sqlite_master WHERE type='table' AND name=? SELECT 1 FROM information_schema.tables WHERE table_name=?
PRAGMA journal_mode=WAL N/A (PostgreSQL handles WAL internally)
PRAGMA mmap_size=N N/A
PRAGMA cache_size=-N SET shared_buffers (server-level, not per-connection)
sqlite3_wal_checkpoint_v2() N/A (no-op)
sqlite3_changes() PQcmdTuples(result)
sqlite3_extended_errcode() PQresultErrorField(result, PG_DIAG_SQLSTATE)
? bind parameter $1, $2, ... positional parameters
JSON column type JSONB column type
json_each() for tag queries @> containment operator with GIN index
INTEGER PRIMARY KEY auto-increment SERIAL or BIGSERIAL

Architecture After Implementation

flowchart TD
    subgraph Application Layer
        MAIN[main.c] --> DBOPS_H[db_ops.h - public API]
        CONFIG[config.c] --> DBOPS_H
        IPBAN[ip_ban.c] --> DBOPS_H
        SUBS[subscriptions.c] --> DBOPS_H
        API[api.c] --> DBOPS_H
        NIP09[nip009.c] --> DBOPS_H
        WS[websockets.c] --> DBOPS_H
    end

    subgraph Database Abstraction
        DBOPS_H --> DISPATCH[db_ops.c - dispatcher]
        DISPATCH -->|DB_BACKEND_SQLITE| SQLITE[db_ops_sqlite.c]
        DISPATCH -->|DB_BACKEND_POSTGRES| PG[db_ops_postgres.c]
    end

    subgraph Backends
        SQLITE --> SQLITE3[libsqlite3]
        PG --> LIBPQ[libpq]
        LIBPQ --> PGSERVER[PostgreSQL Server]
    end

Risk Mitigation

Risk Mitigation
Breaking SQLite backend during refactor Phase 1-2 are pure refactors — test suite must pass after each
libpq statement API mismatch Use PQexecParams with row-index iteration, not cursor-based
SQL dialect differences in caller code Phase 5 audits all SQL outside db_ops; use C timestamps for portability
Connection failures Implement reconnection with exponential backoff in postgres_db_init
Thread safety Each worker thread gets its own PGconn via db_open_worker_connection
Performance regression Phase 7 benchmarks compare SQLite vs PostgreSQL on same workload

Build Commands

# SQLite backend (default, unchanged behavior)
make

# PostgreSQL backend
make DB_BACKEND=postgres

# Run with PostgreSQL
./build/c_relay_pg_x86 --db-connstring "host=localhost dbname=crelay user=crelay password=secret"