366 lines
14 KiB
C
366 lines
14 KiB
C
#ifndef PG_SCHEMA_H
|
|
#define PG_SCHEMA_H
|
|
|
|
#define EMBEDDED_PG_SCHEMA_VERSION "3"
|
|
|
|
static const char* const EMBEDDED_PG_SCHEMA_SQL =
|
|
"-- C-Relay-PG PostgreSQL Schema\n"
|
|
"-- Initial PostgreSQL backend schema\n"
|
|
"\n"
|
|
"BEGIN;\n"
|
|
"\n"
|
|
"CREATE TABLE IF NOT EXISTS schema_info (\n"
|
|
" key TEXT PRIMARY KEY,\n"
|
|
" value TEXT NOT NULL,\n"
|
|
" updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT\n"
|
|
");\n"
|
|
"\n"
|
|
"CREATE TABLE IF NOT EXISTS events (\n"
|
|
" id TEXT PRIMARY KEY,\n"
|
|
" pubkey TEXT NOT NULL,\n"
|
|
" created_at BIGINT NOT NULL,\n"
|
|
" kind INTEGER NOT NULL,\n"
|
|
" event_type TEXT NOT NULL CHECK (event_type IN ('regular', 'replaceable', 'ephemeral', 'addressable')),\n"
|
|
" content TEXT NOT NULL,\n"
|
|
" sig TEXT NOT NULL,\n"
|
|
" tags JSONB NOT NULL DEFAULT '[]'::jsonb,\n"
|
|
" d_tag_value TEXT GENERATED ALWAYS AS (\n"
|
|
" COALESCE(\n"
|
|
" NULLIF(\n"
|
|
" jsonb_path_query_first(tags, '$[*] ? (@[0] == \"d\")[1]') #>> '{}',\n"
|
|
" ''\n"
|
|
" ),\n"
|
|
" ''\n"
|
|
" )\n"
|
|
" ) STORED,\n"
|
|
" expires_at BIGINT,\n"
|
|
" event_json TEXT NOT NULL,\n"
|
|
" first_seen BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT\n"
|
|
");\n"
|
|
"\n"
|
|
"ALTER TABLE events\n"
|
|
" ADD COLUMN IF NOT EXISTS expires_at BIGINT;\n"
|
|
"\n"
|
|
"DO $$\n"
|
|
"BEGIN\n"
|
|
" IF NOT EXISTS (\n"
|
|
" SELECT 1\n"
|
|
" FROM information_schema.columns\n"
|
|
" WHERE table_schema = current_schema()\n"
|
|
" AND table_name = 'events'\n"
|
|
" AND column_name = 'd_tag_value'\n"
|
|
" ) THEN\n"
|
|
" ALTER TABLE events\n"
|
|
" ADD COLUMN d_tag_value TEXT GENERATED ALWAYS AS (\n"
|
|
" COALESCE(\n"
|
|
" NULLIF(\n"
|
|
" jsonb_path_query_first(tags, '$[*] ? (@[0] == \"d\")[1]') #>> '{}',\n"
|
|
" ''\n"
|
|
" ),\n"
|
|
" ''\n"
|
|
" )\n"
|
|
" ) STORED;\n"
|
|
" END IF;\n"
|
|
"END\n"
|
|
"$$;\n"
|
|
"\n"
|
|
"CREATE INDEX IF NOT EXISTS idx_events_pubkey ON events(pubkey);\n"
|
|
"CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind);\n"
|
|
"CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);\n"
|
|
"CREATE INDEX IF NOT EXISTS idx_events_event_type ON events(event_type);\n"
|
|
"CREATE INDEX IF NOT EXISTS idx_events_kind_created_at ON events(kind, created_at DESC);\n"
|
|
"CREATE INDEX IF NOT EXISTS idx_events_pubkey_created_at ON events(pubkey, created_at DESC);\n"
|
|
"CREATE INDEX IF NOT EXISTS idx_events_pubkey_kind ON events(pubkey, kind);\n"
|
|
"CREATE INDEX IF NOT EXISTS idx_events_tags_gin ON events USING GIN(tags);\n"
|
|
"CREATE INDEX IF NOT EXISTS idx_events_expires_at ON events(expires_at DESC) WHERE expires_at IS NOT NULL;\n"
|
|
"CREATE INDEX IF NOT EXISTS idx_events_d_tag_value ON events(d_tag_value) WHERE d_tag_value <> '';\n"
|
|
"CREATE UNIQUE INDEX IF NOT EXISTS uq_events_replaceable_pubkey_kind\n"
|
|
" ON events(pubkey, kind)\n"
|
|
" WHERE event_type = 'replaceable';\n"
|
|
"CREATE UNIQUE INDEX IF NOT EXISTS uq_events_addressable_pubkey_kind_dtag\n"
|
|
" ON events(pubkey, kind, d_tag_value)\n"
|
|
" WHERE event_type = 'addressable';\n"
|
|
"\n"
|
|
"-- Keep this table for compatibility with existing query paths during migration.\n"
|
|
"CREATE TABLE IF NOT EXISTS event_tags (\n"
|
|
" event_id TEXT NOT NULL REFERENCES events(id) ON DELETE CASCADE,\n"
|
|
" tag_name TEXT NOT NULL,\n"
|
|
" tag_value TEXT NOT NULL,\n"
|
|
" tag_index INTEGER NOT NULL DEFAULT 0,\n"
|
|
" PRIMARY KEY (event_id, tag_name, tag_value, tag_index)\n"
|
|
");\n"
|
|
"\n"
|
|
"CREATE INDEX IF NOT EXISTS idx_event_tags_lookup ON event_tags(tag_name, tag_value);\n"
|
|
"CREATE INDEX IF NOT EXISTS idx_event_tags_event ON event_tags(event_id);\n"
|
|
"CREATE INDEX IF NOT EXISTS idx_event_tags_value_name ON event_tags(tag_value, tag_name);\n"
|
|
"\n"
|
|
"CREATE OR REPLACE FUNCTION set_event_derived_fields()\n"
|
|
"RETURNS TRIGGER AS $$\n"
|
|
"DECLARE\n"
|
|
" expiration_value TEXT;\n"
|
|
"BEGIN\n"
|
|
" NEW.expires_at := NULL;\n"
|
|
"\n"
|
|
" SELECT tag->>1\n"
|
|
" INTO expiration_value\n"
|
|
" FROM jsonb_array_elements(NEW.tags) AS tag\n"
|
|
" WHERE jsonb_typeof(tag) = 'array'\n"
|
|
" AND jsonb_array_length(tag) >= 2\n"
|
|
" AND tag->>0 = 'expiration'\n"
|
|
" LIMIT 1;\n"
|
|
"\n"
|
|
" IF expiration_value IS NOT NULL AND expiration_value ~ '^[0-9]+$' THEN\n"
|
|
" NEW.expires_at := expiration_value::BIGINT;\n"
|
|
" END IF;\n"
|
|
"\n"
|
|
" RETURN NEW;\n"
|
|
"END;\n"
|
|
"$$ LANGUAGE plpgsql;\n"
|
|
"\n"
|
|
"CREATE OR REPLACE FUNCTION sync_event_tags_from_events()\n"
|
|
"RETURNS TRIGGER AS $$\n"
|
|
"BEGIN\n"
|
|
" DELETE FROM event_tags WHERE event_id = NEW.id;\n"
|
|
"\n"
|
|
" INSERT INTO event_tags (event_id, tag_name, tag_value, tag_index)\n"
|
|
" SELECT NEW.id,\n"
|
|
" tag->>0 AS tag_name,\n"
|
|
" tag->>1 AS tag_value,\n"
|
|
" (ord - 1)::INTEGER AS tag_index\n"
|
|
" FROM jsonb_array_elements(NEW.tags) WITH ORDINALITY AS expanded(tag, ord)\n"
|
|
" WHERE jsonb_typeof(tag) = 'array'\n"
|
|
" AND jsonb_array_length(tag) >= 2\n"
|
|
" AND COALESCE(tag->>0, '') <> ''\n"
|
|
" AND COALESCE(tag->>1, '') <> '';\n"
|
|
"\n"
|
|
" RETURN NEW;\n"
|
|
"END;\n"
|
|
"$$ LANGUAGE plpgsql;\n"
|
|
"\n"
|
|
"DROP TRIGGER IF EXISTS trg_events_set_derived_fields ON events;\n"
|
|
"CREATE TRIGGER trg_events_set_derived_fields\n"
|
|
"BEFORE INSERT OR UPDATE OF tags ON events\n"
|
|
"FOR EACH ROW\n"
|
|
"EXECUTE FUNCTION set_event_derived_fields();\n"
|
|
"\n"
|
|
"DROP TRIGGER IF EXISTS trg_events_sync_event_tags ON events;\n"
|
|
"CREATE TRIGGER trg_events_sync_event_tags\n"
|
|
"AFTER INSERT OR UPDATE OF tags ON events\n"
|
|
"FOR EACH ROW\n"
|
|
"EXECUTE FUNCTION sync_event_tags_from_events();\n"
|
|
"\n"
|
|
"CREATE TABLE IF NOT EXISTS relay_seckey (\n"
|
|
" id SMALLINT PRIMARY KEY DEFAULT 1,\n"
|
|
" private_key_hex TEXT NOT NULL CHECK (char_length(private_key_hex) = 64),\n"
|
|
" created_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,\n"
|
|
" CONSTRAINT relay_seckey_singleton CHECK (id = 1)\n"
|
|
");\n"
|
|
"\n"
|
|
"CREATE TABLE IF NOT EXISTS auth_rules (\n"
|
|
" id BIGSERIAL PRIMARY KEY,\n"
|
|
" rule_type TEXT NOT NULL CHECK (rule_type IN ('whitelist', 'blacklist', 'rate_limit', 'auth_required', 'wot_whitelist')),\n"
|
|
" pattern_type TEXT NOT NULL CHECK (pattern_type IN ('pubkey', 'kind', 'ip', 'global', 'event_id', 'content', 'hash')),\n"
|
|
" pattern_value TEXT,\n"
|
|
" active INTEGER NOT NULL DEFAULT 1,\n"
|
|
" created_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,\n"
|
|
" updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT\n"
|
|
");\n"
|
|
"\n"
|
|
"CREATE INDEX IF NOT EXISTS idx_auth_rules_pattern ON auth_rules(pattern_type, pattern_value);\n"
|
|
"CREATE INDEX IF NOT EXISTS idx_auth_rules_type ON auth_rules(rule_type);\n"
|
|
"CREATE INDEX IF NOT EXISTS idx_auth_rules_active ON auth_rules(active);\n"
|
|
"\n"
|
|
"CREATE TABLE IF NOT EXISTS config (\n"
|
|
" key TEXT PRIMARY KEY,\n"
|
|
" value TEXT NOT NULL,\n"
|
|
" data_type TEXT NOT NULL CHECK (data_type IN ('string', 'integer', 'boolean', 'json')),\n"
|
|
" description TEXT,\n"
|
|
" category TEXT DEFAULT 'general',\n"
|
|
" requires_restart INTEGER DEFAULT 0,\n"
|
|
" created_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,\n"
|
|
" updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT\n"
|
|
");\n"
|
|
"\n"
|
|
"CREATE INDEX IF NOT EXISTS idx_config_category ON config(category);\n"
|
|
"CREATE INDEX IF NOT EXISTS idx_config_restart ON config(requires_restart);\n"
|
|
"CREATE INDEX IF NOT EXISTS idx_config_updated ON config(updated_at DESC);\n"
|
|
"\n"
|
|
"CREATE TABLE IF NOT EXISTS subscriptions (\n"
|
|
" id BIGSERIAL PRIMARY KEY,\n"
|
|
" subscription_id TEXT NOT NULL,\n"
|
|
" wsi_pointer TEXT NOT NULL,\n"
|
|
" client_ip TEXT NOT NULL,\n"
|
|
" event_type TEXT NOT NULL CHECK (event_type IN ('created', 'closed', 'expired', 'disconnected')),\n"
|
|
" filter_json TEXT,\n"
|
|
" events_sent INTEGER DEFAULT 0,\n"
|
|
" created_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,\n"
|
|
" ended_at BIGINT,\n"
|
|
" duration BIGINT,\n"
|
|
" UNIQUE(subscription_id, wsi_pointer)\n"
|
|
");\n"
|
|
"\n"
|
|
"CREATE TABLE IF NOT EXISTS subscription_metrics (\n"
|
|
" id BIGSERIAL PRIMARY KEY,\n"
|
|
" date TEXT NOT NULL UNIQUE,\n"
|
|
" total_created INTEGER DEFAULT 0,\n"
|
|
" total_closed INTEGER DEFAULT 0,\n"
|
|
" total_events_broadcast INTEGER DEFAULT 0,\n"
|
|
" avg_duration DOUBLE PRECISION DEFAULT 0,\n"
|
|
" peak_concurrent INTEGER DEFAULT 0,\n"
|
|
" updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT\n"
|
|
");\n"
|
|
"\n"
|
|
"CREATE INDEX IF NOT EXISTS idx_subscriptions_id ON subscriptions(subscription_id);\n"
|
|
"CREATE INDEX IF NOT EXISTS idx_subscriptions_type ON subscriptions(event_type);\n"
|
|
"CREATE INDEX IF NOT EXISTS idx_subscriptions_created ON subscriptions(created_at DESC);\n"
|
|
"CREATE INDEX IF NOT EXISTS idx_subscriptions_client ON subscriptions(client_ip);\n"
|
|
"CREATE INDEX IF NOT EXISTS idx_subscriptions_wsi ON subscriptions(wsi_pointer);\n"
|
|
"CREATE INDEX IF NOT EXISTS idx_subscriptions_active_log ON subscriptions(event_type, ended_at, created_at DESC);\n"
|
|
"CREATE INDEX IF NOT EXISTS idx_subscription_metrics_date ON subscription_metrics(date DESC);\n"
|
|
"\n"
|
|
"CREATE TABLE IF NOT EXISTS ip_bans (\n"
|
|
" ip TEXT PRIMARY KEY,\n"
|
|
" failure_count INTEGER NOT NULL DEFAULT 0,\n"
|
|
" ban_count INTEGER NOT NULL DEFAULT 0,\n"
|
|
" banned_until BIGINT NOT NULL DEFAULT 0,\n"
|
|
" first_failure BIGINT NOT NULL DEFAULT 0,\n"
|
|
" has_authed_successfully INTEGER NOT NULL DEFAULT 0,\n"
|
|
" last_success_at BIGINT NOT NULL DEFAULT 0,\n"
|
|
" total_connections INTEGER NOT NULL DEFAULT 0,\n"
|
|
" total_failures INTEGER NOT NULL DEFAULT 0,\n"
|
|
" total_successes INTEGER NOT NULL DEFAULT 0,\n"
|
|
" first_seen BIGINT NOT NULL DEFAULT 0,\n"
|
|
" updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,\n"
|
|
" idle_failure_count INTEGER NOT NULL DEFAULT 0,\n"
|
|
" idle_ban_count INTEGER NOT NULL DEFAULT 0,\n"
|
|
" idle_banned_until BIGINT NOT NULL DEFAULT 0,\n"
|
|
" idle_first_failure BIGINT NOT NULL DEFAULT 0\n"
|
|
");\n"
|
|
"\n"
|
|
"-- Helper views for common queries\n"
|
|
"CREATE OR REPLACE VIEW recent_events AS\n"
|
|
"SELECT id, pubkey, created_at, kind, event_type, content\n"
|
|
"FROM events\n"
|
|
"WHERE event_type <> 'ephemeral'\n"
|
|
"ORDER BY created_at DESC\n"
|
|
"LIMIT 1000;\n"
|
|
"\n"
|
|
"CREATE OR REPLACE VIEW event_stats AS\n"
|
|
"SELECT\n"
|
|
" event_type,\n"
|
|
" COUNT(*) AS count,\n"
|
|
" AVG(char_length(content)) AS avg_content_length,\n"
|
|
" MIN(created_at) AS earliest,\n"
|
|
" MAX(created_at) AS latest\n"
|
|
"FROM events\n"
|
|
"GROUP BY event_type;\n"
|
|
"\n"
|
|
"-- Configuration events view (kind 33334)\n"
|
|
"CREATE OR REPLACE VIEW configuration_events AS\n"
|
|
"SELECT\n"
|
|
" id,\n"
|
|
" pubkey AS admin_pubkey,\n"
|
|
" created_at,\n"
|
|
" content,\n"
|
|
" tags,\n"
|
|
" sig\n"
|
|
"FROM events\n"
|
|
"WHERE kind = 33334\n"
|
|
"ORDER BY created_at DESC;\n"
|
|
"\n"
|
|
"-- View for subscription analytics\n"
|
|
"CREATE OR REPLACE VIEW subscription_analytics AS\n"
|
|
"SELECT\n"
|
|
" to_timestamp(created_at)::date AS date,\n"
|
|
" COUNT(*) AS subscriptions_created,\n"
|
|
" COUNT(CASE WHEN ended_at IS NOT NULL THEN 1 END) AS subscriptions_ended,\n"
|
|
" AVG(CASE WHEN duration IS NOT NULL THEN duration END) AS avg_duration_seconds,\n"
|
|
" MAX(events_sent) AS max_events_sent,\n"
|
|
" AVG(events_sent) AS avg_events_sent,\n"
|
|
" COUNT(DISTINCT client_ip) AS unique_clients\n"
|
|
"FROM subscriptions\n"
|
|
"GROUP BY to_timestamp(created_at)::date\n"
|
|
"ORDER BY date DESC;\n"
|
|
"\n"
|
|
"-- View for current active subscriptions (from log perspective)\n"
|
|
"CREATE OR REPLACE VIEW active_subscriptions_log AS\n"
|
|
"SELECT\n"
|
|
" subscription_id,\n"
|
|
" client_ip,\n"
|
|
" filter_json,\n"
|
|
" events_sent,\n"
|
|
" created_at,\n"
|
|
" (EXTRACT(EPOCH FROM NOW())::BIGINT - created_at) AS duration_seconds,\n"
|
|
" wsi_pointer\n"
|
|
"FROM subscriptions\n"
|
|
"WHERE event_type = 'created'\n"
|
|
" AND ended_at IS NULL;\n"
|
|
"\n"
|
|
"-- Event kinds distribution view\n"
|
|
"CREATE OR REPLACE VIEW event_kinds_view AS\n"
|
|
"SELECT\n"
|
|
" kind,\n"
|
|
" COUNT(*) AS count,\n"
|
|
" ROUND((COUNT(*) * 100.0 / NULLIF((SELECT COUNT(*) FROM events), 0))::numeric, 2) AS percentage\n"
|
|
"FROM events\n"
|
|
"GROUP BY kind\n"
|
|
"ORDER BY count DESC;\n"
|
|
"\n"
|
|
"-- Top pubkeys by event count view\n"
|
|
"CREATE OR REPLACE VIEW top_pubkeys_view AS\n"
|
|
"SELECT\n"
|
|
" pubkey,\n"
|
|
" COUNT(*) AS event_count,\n"
|
|
" ROUND((COUNT(*) * 100.0 / NULLIF((SELECT COUNT(*) FROM events), 0))::numeric, 2) AS percentage\n"
|
|
"FROM events\n"
|
|
"GROUP BY pubkey\n"
|
|
"ORDER BY event_count DESC;\n"
|
|
"\n"
|
|
"-- Time-based statistics view\n"
|
|
"CREATE OR REPLACE VIEW time_stats_view AS\n"
|
|
"SELECT\n"
|
|
" 'total' AS period,\n"
|
|
" COUNT(*) AS total_events,\n"
|
|
" COUNT(DISTINCT pubkey) AS unique_pubkeys,\n"
|
|
" MIN(created_at) AS oldest_event,\n"
|
|
" MAX(created_at) AS newest_event\n"
|
|
"FROM events\n"
|
|
"UNION ALL\n"
|
|
"SELECT\n"
|
|
" '24h' AS period,\n"
|
|
" COUNT(*) AS total_events,\n"
|
|
" COUNT(DISTINCT pubkey) AS unique_pubkeys,\n"
|
|
" MIN(created_at) AS oldest_event,\n"
|
|
" MAX(created_at) AS newest_event\n"
|
|
"FROM events\n"
|
|
"WHERE created_at >= (EXTRACT(EPOCH FROM NOW())::BIGINT - 86400)\n"
|
|
"UNION ALL\n"
|
|
"SELECT\n"
|
|
" '7d' AS period,\n"
|
|
" COUNT(*) AS total_events,\n"
|
|
" COUNT(DISTINCT pubkey) AS unique_pubkeys,\n"
|
|
" MIN(created_at) AS oldest_event,\n"
|
|
" MAX(created_at) AS newest_event\n"
|
|
"FROM events\n"
|
|
"WHERE created_at >= (EXTRACT(EPOCH FROM NOW())::BIGINT - 604800)\n"
|
|
"UNION ALL\n"
|
|
"SELECT\n"
|
|
" '30d' AS period,\n"
|
|
" COUNT(*) AS total_events,\n"
|
|
" COUNT(DISTINCT pubkey) AS unique_pubkeys,\n"
|
|
" MIN(created_at) AS oldest_event,\n"
|
|
" MAX(created_at) AS newest_event\n"
|
|
"FROM events\n"
|
|
"WHERE created_at >= (EXTRACT(EPOCH FROM NOW())::BIGINT - 2592000);\n"
|
|
"\n"
|
|
"INSERT INTO schema_info(key, value, updated_at)\n"
|
|
"VALUES ('version', '3', EXTRACT(EPOCH FROM NOW())::BIGINT)\n"
|
|
"ON CONFLICT (key) DO UPDATE SET\n"
|
|
" value = EXCLUDED.value,\n"
|
|
" updated_at = EXCLUDED.updated_at;\n"
|
|
"\n"
|
|
"COMMIT;\n"
|
|
;
|
|
|
|
#endif // PG_SCHEMA_H
|