Files
c-relay-pg/src/pg_schema.h
T
Laan Tungir ca7a4b6722 v2.1.36 - Unified relay table, caching/backfill separation, inbox defaults, profile gating, and UI fixes
- Added caching_relays unified table with live_enabled/backfill_enabled columns
- Separated --reset-backfill from --start-caching as independent flags
- Removed redundant caching_enabled master setting; daemon derives from live/backfill
- Set caching_inbox_enabled=true by default; removed Inbox toggle from Backfill page
- Set caching_live_strategy=cache_all by default
- Fixed outbox relay discovery to store ALL discovered relays, not just covering set
- Added store_kind_0_information config (default: true) to gate profile sync trigger
- Regenerated pg_schema.h from pg_schema.sql to include caching_relays table
- Fixed process toggle button styling to match monochrome aesthetic
- Fixed radio button styling to match black/white/red theme
- Simplified Backfill page: removed Service Status and Inbox Status sections
- Backfill status now respects config setting, not just daemon state
- Admin config API now bumps caching_config_generation for caching-related changes
- make_and_restart_relay.sh now resets PostgreSQL schema on fresh restart
2026-08-03 19:37:09 -04:00

658 lines
28 KiB
C

#ifndef PG_SCHEMA_H
#define PG_SCHEMA_H
#define EMBEDDED_PG_SCHEMA_VERSION "6"
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 NOT NULL DEFAULT '',\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 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"
" AND is_generated = 'ALWAYS'\n"
" ) THEN\n"
" DROP INDEX IF EXISTS uq_events_addressable_pubkey_kind_dtag;\n"
" DROP INDEX IF EXISTS idx_events_d_tag_value;\n"
" ALTER TABLE events DROP COLUMN d_tag_value;\n"
" ALTER TABLE events ADD COLUMN d_tag_value TEXT NOT NULL DEFAULT '';\n"
" ELSIF 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 ADD COLUMN d_tag_value TEXT NOT NULL DEFAULT '';\n"
" END IF;\n"
"\n"
" -- One-time backfill of d_tag_value from tags JSONB.\n"
" -- Guarded by schema_info version check so it only runs once on upgrade\n"
" -- from v3, NOT on every startup (avoids full-table scan + write on boot).\n"
" IF COALESCE((SELECT value FROM schema_info WHERE key = 'version'), '0') < '4' THEN\n"
" UPDATE events e\n"
" SET d_tag_value = COALESCE((\n"
" SELECT tag->>1\n"
" FROM jsonb_array_elements(e.tags) AS tag\n"
" WHERE jsonb_typeof(tag) = 'array'\n"
" AND jsonb_array_length(tag) >= 2\n"
" AND tag->>0 = 'd'\n"
" LIMIT 1\n"
" ), '')\n"
" WHERE COALESCE(e.d_tag_value, '') = '';\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"
" d_value TEXT;\n"
"BEGIN\n"
" NEW.expires_at := NULL;\n"
" NEW.d_tag_value := '';\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"
" SELECT tag->>1\n"
" INTO d_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 = 'd'\n"
" LIMIT 1;\n"
"\n"
" IF d_value IS NOT NULL THEN\n"
" NEW.d_tag_value := d_value;\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"
"INSERT INTO schema_info(key, value, updated_at)\n"
"VALUES ('version', '6', EXTRACT(EPOCH FROM NOW())::BIGINT)\n"
"ON CONFLICT (key) DO UPDATE SET\n"
" value = EXCLUDED.value,\n"
" updated_at = EXCLUDED.updated_at;\n"
"\n"
"-- =====================================================================\n"
"-- LISTEN/NOTIFY support for api-worker monitoring (Phase 5)\n"
"-- Fires a notification on the 'event_stored' channel whenever a new event\n"
"-- is inserted, so the api-worker thread can wake reactively instead of\n"
"-- polling on a timer. The payload is a small JSON object with kind and a\n"
"-- truncated pubkey prefix (kept tiny to minimize per-insert overhead).\n"
"-- =====================================================================\n"
"CREATE OR REPLACE FUNCTION notify_event_stored() RETURNS trigger AS $$\n"
"BEGIN\n"
" PERFORM pg_notify('event_stored', json_build_object(\n"
" 'kind', NEW.kind,\n"
" 'pubkey', substring(NEW.pubkey, 1, 8)\n"
" )::text);\n"
" RETURN NEW;\n"
"END;\n"
"$$ LANGUAGE plpgsql;\n"
"\n"
"DROP TRIGGER IF EXISTS trg_notify_event_stored ON events;\n"
"CREATE TRIGGER trg_notify_event_stored\n"
" AFTER INSERT ON events\n"
" FOR EACH ROW EXECUTE FUNCTION notify_event_stored();\n"
"\n"
"-- =====================================================================\n"
"-- Config change notification: fires pg_notify('config_changed', key)\n"
"-- whenever a config row is updated, so the relay can react to\n"
"-- caching_enabled / caching_inbox_enabled toggles from the PHP admin\n"
"-- without polling.\n"
"-- =====================================================================\n"
"CREATE OR REPLACE FUNCTION notify_config_changed() RETURNS trigger AS $$\n"
"BEGIN\n"
" PERFORM pg_notify('config_changed', NEW.key);\n"
" RETURN NEW;\n"
"END;\n"
"$$ LANGUAGE plpgsql;\n"
"\n"
"DROP TRIGGER IF EXISTS trg_notify_config_changed ON config;\n"
"CREATE TRIGGER trg_notify_config_changed\n"
" AFTER UPDATE ON config\n"
" FOR EACH ROW EXECUTE FUNCTION notify_config_changed();\n"
"\n"
"-- =====================================================================\n"
"-- Caching relay integration tables\n"
"-- These tables are written by an external caching application and\n"
"-- consumed by c-relay-pg. They are schema-only here; the database\n"
"-- abstraction functions are added in a later phase.\n"
"-- =====================================================================\n"
"\n"
"-- Queue table where the external caching application inserts raw event\n"
"-- JSON. c-relay-pg destructively dequeues from it.\n"
"CREATE TABLE IF NOT EXISTS caching_event_inbox (\n"
" queue_id BIGSERIAL PRIMARY KEY,\n"
" event_id TEXT NOT NULL UNIQUE,\n"
" event_json JSONB NOT NULL,\n"
" source_relay TEXT,\n"
" source_class TEXT NOT NULL DEFAULT 'backfill',\n"
" priority SMALLINT NOT NULL DEFAULT 1,\n"
" received_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,\n"
"\n"
" CHECK (jsonb_typeof(event_json) = 'object'),\n"
" CHECK (jsonb_typeof(event_json->'id') = 'string'),\n"
" CHECK (length(event_json->>'id') = 64),\n"
" CHECK (event_id = event_json->>'id'),\n"
" CHECK (jsonb_typeof(event_json->'pubkey') = 'string'),\n"
" CHECK (length(event_json->>'pubkey') = 64),\n"
" CHECK (jsonb_typeof(event_json->'sig') = 'string'),\n"
" CHECK (length(event_json->>'sig') = 128),\n"
" CHECK (jsonb_typeof(event_json->'created_at') = 'number'),\n"
" CHECK (jsonb_typeof(event_json->'kind') = 'number'),\n"
" CHECK (jsonb_typeof(event_json->'tags') = 'array'),\n"
" CHECK (jsonb_typeof(event_json->'content') = 'string'),\n"
" CHECK (source_class IN ('live', 'discovery', 'backfill')),\n"
" CHECK (priority IN (0, 1))\n"
");\n"
"\n"
"CREATE INDEX IF NOT EXISTS idx_caching_inbox_dequeue\n"
" ON caching_event_inbox(priority, received_at, queue_id);\n"
"\n"
"-- Singleton status row for the external caching application. The caching\n"
"-- process overwrites this periodically.\n"
"CREATE TABLE IF NOT EXISTS caching_service_state (\n"
" id SMALLINT PRIMARY KEY DEFAULT 1,\n"
" service_version TEXT,\n"
" service_state TEXT NOT NULL DEFAULT 'stopped',\n"
" config_generation BIGINT NOT NULL DEFAULT 0,\n"
" heartbeat_at BIGINT NOT NULL DEFAULT 0,\n"
" followed_author_count INTEGER NOT NULL DEFAULT 0,\n"
" selected_relay_count INTEGER NOT NULL DEFAULT 0,\n"
" connected_relay_count INTEGER NOT NULL DEFAULT 0,\n"
" current_window_index INTEGER NOT NULL DEFAULT 0,\n"
" backfill_cursor INTEGER NOT NULL DEFAULT 0,\n"
" events_fetched BIGINT NOT NULL DEFAULT 0,\n"
" inbox_inserts BIGINT NOT NULL DEFAULT 0,\n"
" last_error TEXT,\n"
" last_error_at BIGINT,\n"
" updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,\n"
"\n"
" CHECK (id = 1),\n"
" CHECK (service_state IN ('stopped', 'starting', 'running', 'degraded'))\n"
");\n"
"\n"
"-- Per-author backfill progress for the external caching application.\n"
"CREATE TABLE IF NOT EXISTS caching_backfill_progress (\n"
" author_pubkey TEXT NOT NULL,\n"
" window_index INTEGER NOT NULL,\n"
" window_anchor BIGINT NOT NULL,\n"
" until_cursor BIGINT NOT NULL DEFAULT 0,\n"
" complete BOOLEAN NOT NULL DEFAULT FALSE,\n"
" updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,\n"
"\n"
" PRIMARY KEY (author_pubkey, window_index)\n"
");\n"
"\n"
"CREATE INDEX IF NOT EXISTS idx_caching_backfill_progress_window\n"
" ON caching_backfill_progress(window_index)\n"
" WHERE complete = FALSE;\n"
"\n"
"-- Durable followed-pubkey list with per-author backfill cursor state.\n"
"-- Replaces the window-coupled caching_backfill_progress table for the\n"
"-- per-author until-cursor drain backfill model.\n"
"CREATE TABLE IF NOT EXISTS caching_followed_pubkeys (\n"
" pubkey TEXT PRIMARY KEY,\n"
" is_root BOOLEAN NOT NULL DEFAULT FALSE,\n"
" until_cursor BIGINT NOT NULL DEFAULT 0,\n"
" backfill_complete BOOLEAN NOT NULL DEFAULT FALSE,\n"
" events_fetched INTEGER NOT NULL DEFAULT 0,\n"
" first_seen BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,\n"
" last_seen BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,\n"
" updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT\n"
");\n"
"\n"
"-- last_event_at: created_at of the most recent event we've seen for this\n"
"-- author. Used by the forward catch-up to bridge gaps when caching resumes\n"
"-- after being off. Seeded from the events table on upgrade.\n"
"ALTER TABLE caching_followed_pubkeys\n"
" ADD COLUMN IF NOT EXISTS last_event_at BIGINT NOT NULL DEFAULT 0;\n"
"\n"
"UPDATE caching_followed_pubkeys fp\n"
" SET last_event_at = COALESCE(\n"
" (SELECT MAX(e.created_at) FROM events e WHERE e.pubkey = fp.pubkey),\n"
" 0\n"
" )\n"
" WHERE fp.last_event_at = 0;\n"
"\n"
"CREATE INDEX IF NOT EXISTS idx_caching_followed_incomplete\n"
" ON caching_followed_pubkeys(backfill_complete, last_seen)\n"
" WHERE backfill_complete = FALSE;\n"
"\n"
"-- Per-relay backfill cursor tracking for the relay-by-relay drain model.\n"
"-- Each (author, relay) pair has its own until_cursor and completion flag so\n"
"-- slow relays do not cause premature completion marking for the author.\n"
"CREATE TABLE IF NOT EXISTS caching_backfill_relay_progress (\n"
" author_pubkey TEXT NOT NULL,\n"
" relay_url TEXT NOT NULL,\n"
" until_cursor BIGINT NOT NULL DEFAULT 0,\n"
" complete BOOLEAN NOT NULL DEFAULT FALSE,\n"
" events_fetched INTEGER NOT NULL DEFAULT 0,\n"
" updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,\n"
" PRIMARY KEY (author_pubkey, relay_url)\n"
");\n"
"\n"
"CREATE INDEX IF NOT EXISTS idx_caching_relay_progress_incomplete\n"
" ON caching_backfill_relay_progress(author_pubkey)\n"
" WHERE complete = FALSE;\n"
"-- Replace window-based progress columns with author-based progress.\n"
"ALTER TABLE caching_service_state\n"
" DROP COLUMN IF EXISTS current_window_index,\n"
" DROP COLUMN IF EXISTS backfill_cursor,\n"
" ADD COLUMN IF NOT EXISTS backfill_authors_complete INTEGER NOT NULL DEFAULT 0,\n"
" ADD COLUMN IF NOT EXISTS backfill_authors_total INTEGER NOT NULL DEFAULT 0;\n"
"\n"
"-- Unified relay table: replaces caching_upstream_relays, caching_bootstrap_relays\n"
"-- config key, and caching_live_relays config key. Each relay has independent\n"
"-- live_enabled and backfill_enabled flags. The caching service connects to any\n"
"-- relay where either flag is true. Status is updated by the caching service\n"
"-- heartbeat. Follow count is computed from caching_backfill_relay_progress.\n"
"CREATE TABLE IF NOT EXISTS caching_relays (\n"
" relay_url TEXT PRIMARY KEY,\n"
" live_enabled BOOLEAN NOT NULL DEFAULT FALSE,\n"
" backfill_enabled BOOLEAN NOT NULL DEFAULT FALSE,\n"
" status_code INT NOT NULL DEFAULT 0,\n"
" status_text TEXT NOT NULL DEFAULT '',\n"
" follow_count INT NOT NULL DEFAULT 0,\n"
" is_bootstrap BOOLEAN NOT NULL DEFAULT FALSE,\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"
"\n"
"-- =====================================================================\n"
"-- Profile cache (kind-0 metadata projection)\n"
"-- One row per pubkey, kept in sync with the latest kind-0 event via\n"
"-- triggers. Stores both `name` and `display_name` verbatim; the display\n"
"-- preference is a runtime config key (profile_name_preference), not a\n"
"-- generated column, so it can be changed without a migration.\n"
"-- =====================================================================\n"
"\n"
"-- Safe JSONB parse: returns NULL on malformed input instead of raising.\n"
"-- IMMUTABLE so it can be used in generated expressions and backfills.\n"
"CREATE OR REPLACE FUNCTION safe_jsonb(input text) RETURNS jsonb AS $$\n"
"DECLARE\n"
" result jsonb;\n"
"BEGIN\n"
" IF input IS NULL THEN\n"
" RETURN NULL;\n"
" END IF;\n"
" BEGIN\n"
" result := input::jsonb;\n"
" IF jsonb_typeof(result) <> 'object' THEN\n"
" RETURN NULL;\n"
" END IF;\n"
" RETURN result;\n"
" EXCEPTION WHEN others THEN\n"
" RETURN NULL;\n"
" END;\n"
"END;\n"
"$$ LANGUAGE plpgsql IMMUTABLE;\n"
"\n"
"-- Sanitize a text value for safe storage in a PostgreSQL TEXT column.\n"
"-- Strips U+0000 (which TEXT cannot store) and caps length to avoid\n"
"-- pathological values bloating the table. Everything else is preserved\n"
"-- byte-for-byte; presentation-layer concerns (bidi, Zalgo, etc.) are\n"
"-- handled at render time, not here.\n"
"CREATE OR REPLACE FUNCTION sanitize_pg_text(input text, max_len integer DEFAULT 1024)\n"
"RETURNS text AS $$\n"
"BEGIN\n"
" IF input IS NULL THEN\n"
" RETURN '';\n"
" END IF;\n"
" -- Remove NUL characters (U+0000) that PostgreSQL TEXT cannot store.\n"
" -- Using regexp_replace for portability across PG versions.\n"
" RETURN left(regexp_replace(input, '\\u0000', '', 'g'), max_len);\n"
"END;\n"
"$$ LANGUAGE plpgsql IMMUTABLE;\n"
"\n"
"CREATE TABLE IF NOT EXISTS profiles (\n"
" pubkey TEXT PRIMARY KEY,\n"
" event_id TEXT NOT NULL,\n"
" created_at BIGINT NOT NULL,\n"
" name TEXT NOT NULL DEFAULT '',\n"
" display_name TEXT NOT NULL DEFAULT '',\n"
" about TEXT NOT NULL DEFAULT '',\n"
" picture TEXT NOT NULL DEFAULT '',\n"
" banner TEXT NOT NULL DEFAULT '',\n"
" nip05 TEXT NOT NULL DEFAULT '',\n"
" website TEXT NOT NULL DEFAULT '',\n"
" lud16 TEXT NOT NULL DEFAULT '',\n"
" lud06 TEXT NOT NULL DEFAULT '',\n"
" raw_content TEXT NOT NULL DEFAULT '',\n"
" parse_ok BOOLEAN NOT NULL DEFAULT TRUE,\n"
" updated_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT\n"
");\n"
"\n"
"CREATE INDEX IF NOT EXISTS idx_profiles_name\n"
" ON profiles(name) WHERE name <> '';\n"
"CREATE INDEX IF NOT EXISTS idx_profiles_display_name\n"
" ON profiles(display_name) WHERE display_name <> '';\n"
"CREATE INDEX IF NOT EXISTS idx_profiles_nip05\n"
" ON profiles(nip05) WHERE nip05 <> '';\n"
"\n"
"-- Default display-name preference. Can be changed at runtime via the\n"
"-- normal admin config path. Valid values: 'display_name' or 'name'.\n"
"INSERT INTO config (key, value, data_type, description, category, requires_restart)\n"
"VALUES ('profile_name_preference', 'display_name',\n"
" 'string',\n"
" 'Which kind-0 field to prefer for display: display_name or name',\n"
" 'display', 0)\n"
"ON CONFLICT (key) DO NOTHING;\n"
"\n"
"-- Sync profiles row on kind-0 INSERT or UPDATE of content.\n"
"-- Gated by store_kind_0_information config setting (default: true).\n"
"-- Early-returns for non-kind-0 events (cost: one integer comparison).\n"
"-- Guards against an older kind-0 overwriting a newer one.\n"
"CREATE OR REPLACE FUNCTION sync_profile_from_event() RETURNS TRIGGER AS $$\n"
"DECLARE\n"
" j jsonb;\n"
" store_enabled text;\n"
"BEGIN\n"
" IF NEW.kind <> 0 THEN\n"
" RETURN NEW;\n"
" END IF;\n"
"\n"
" -- Check the config setting; skip if disabled.\n"
" SELECT value INTO store_enabled FROM config WHERE key = 'store_kind_0_information';\n"
" IF store_enabled IS NULL OR store_enabled <> 'true' THEN\n"
" RETURN NEW;\n"
" END IF;\n"
"\n"
" j := safe_jsonb(NEW.content);\n"
"\n"
" INSERT INTO profiles (\n"
" pubkey, event_id, created_at,\n"
" name, display_name, about, picture, banner,\n"
" nip05, website, lud16, lud06,\n"
" raw_content, parse_ok, updated_at\n"
" ) VALUES (\n"
" NEW.pubkey, NEW.id, NEW.created_at,\n"
" sanitize_pg_text(COALESCE(j->>'name','')),\n"
" sanitize_pg_text(COALESCE(j->>'display_name','')),\n"
" sanitize_pg_text(COALESCE(j->>'about','')),\n"
" sanitize_pg_text(COALESCE(j->>'picture',''), 2048),\n"
" sanitize_pg_text(COALESCE(j->>'banner',''), 2048),\n"
" sanitize_pg_text(COALESCE(j->>'nip05','')),\n"
" sanitize_pg_text(COALESCE(j->>'website',''), 2048),\n"
" sanitize_pg_text(COALESCE(j->>'lud16','')),\n"
" sanitize_pg_text(COALESCE(j->>'lud06',''), 2048),\n"
" NEW.content, (j IS NOT NULL),\n"
" EXTRACT(EPOCH FROM NOW())::BIGINT\n"
" )\n"
" ON CONFLICT (pubkey) DO UPDATE SET\n"
" event_id = EXCLUDED.event_id,\n"
" created_at = EXCLUDED.created_at,\n"
" name = EXCLUDED.name,\n"
" display_name = EXCLUDED.display_name,\n"
" about = EXCLUDED.about,\n"
" picture = EXCLUDED.picture,\n"
" banner = EXCLUDED.banner,\n"
" nip05 = EXCLUDED.nip05,\n"
" website = EXCLUDED.website,\n"
" lud16 = EXCLUDED.lud16,\n"
" lud06 = EXCLUDED.lud06,\n"
" raw_content = EXCLUDED.raw_content,\n"
" parse_ok = EXCLUDED.parse_ok,\n"
" updated_at = EXCLUDED.updated_at\n"
" WHERE EXCLUDED.created_at >= profiles.created_at;\n"
"\n"
" RETURN NEW;\n"
"END;\n"
"$$ LANGUAGE plpgsql;\n"
"\n"
"DROP TRIGGER IF EXISTS trg_events_sync_profile ON events;\n"
"CREATE TRIGGER trg_events_sync_profile\n"
"AFTER INSERT OR UPDATE OF content ON events\n"
"FOR EACH ROW EXECUTE FUNCTION sync_profile_from_event();\n"
"\n"
"-- Remove profiles row when its backing kind-0 is deleted (NIP-09).\n"
"-- Guarded on event_id so deleting a superseded event doesn't drop a\n"
"-- current profile.\n"
"CREATE OR REPLACE FUNCTION delete_profile_from_event() RETURNS TRIGGER AS $$\n"
"BEGIN\n"
" IF OLD.kind <> 0 THEN\n"
" RETURN OLD;\n"
" END IF;\n"
" DELETE FROM profiles\n"
" WHERE pubkey = OLD.pubkey\n"
" AND event_id = OLD.id;\n"
" RETURN OLD;\n"
"END;\n"
"$$ LANGUAGE plpgsql;\n"
"\n"
"DROP TRIGGER IF EXISTS trg_events_delete_profile ON events;\n"
"CREATE TRIGGER trg_events_delete_profile\n"
"AFTER DELETE ON events\n"
"FOR EACH ROW EXECUTE FUNCTION delete_profile_from_event();\n"
"\n"
"-- One-time backfill from existing kind-0 events. Idempotent: only\n"
"-- inserts profiles that don't already exist (ON CONFLICT DO NOTHING).\n"
"-- Runs on every startup but is a no-op once all profiles are populated,\n"
"-- which is cheap because the query only returns rows for kind-0 events\n"
"-- whose pubkey is not already in the profiles table.\n"
"DO $$\n"
"BEGIN\n"
" INSERT INTO profiles (pubkey, event_id, created_at, name, display_name,\n"
" about, picture, banner, nip05, website, lud16, lud06,\n"
" raw_content, parse_ok)\n"
" SELECT e.pubkey, e.id, e.created_at,\n"
" sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'name','')),\n"
" sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'display_name','')),\n"
" sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'about','')),\n"
" sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'picture',''), 2048),\n"
" sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'banner',''), 2048),\n"
" sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'nip05','')),\n"
" sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'website',''), 2048),\n"
" sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'lud16','')),\n"
" sanitize_pg_text(COALESCE(safe_jsonb(e.content)->>'lud06',''), 2048),\n"
" e.content,\n"
" (safe_jsonb(e.content) IS NOT NULL)\n"
" FROM events e\n"
" WHERE e.kind = 0\n"
" AND NOT EXISTS (SELECT 1 FROM profiles p WHERE p.pubkey = e.pubkey)\n"
" ON CONFLICT (pubkey) DO NOTHING;\n"
"END\n"
"$$;\n"
"\n"
"COMMIT;\n"
"";
#endif /* PG_SCHEMA_H */