# PostgreSQL Nostr Relay Architecture Analysis

This document summarizes common approaches and best practices for building Nostr relays backed by PostgreSQL, based on industry-standard designs and existing open-source relay implementations.

## Identified Projects
*   **Nostrss**: High-performance Rust-based relay.
*   **Nostrgres**: Focused on PostgreSQL integration with complex event filtering.
*   **Relay-rs (Postgres branch)**: General purpose high-throughput relay.
*   **Nostream** ([github.com/Cameri/nostream](https://github.com/Cameri/nostream)): Production-ready TypeScript relay backed by PostgreSQL and Redis.
*   **Nostr-Relay-NestJS** ([github.com/CodyTseng/nostr-relay-nestjs](https://github.com/CodyTseng/nostr-relay-nestjs)): High-performance relay built with NestJS and PostgreSQL, utilizing Kysely for type-safe database interactions.

## Detailed Analysis

### 1. Common Schema Patterns
*   **Event Storage (JSONB)**: Most PostgreSQL-backed relays store the raw event as a JSONB object in a central `events` table. This provides flexibility for the evolving Nostr spec while allowing efficient extraction of fields.
*   **Tag Denormalization**: While the raw event is in JSONB, performant relays extract tags (like `p`, `e`, `t`, `d`) into a separate `tags` table with foreign key relationships to the `events` table. This avoids slow JSONB traversal during complex filter queries.
*   **Author/Publisher Tracking**: A dedicated `authors` or `pubkeys` table is typically used to maintain indexing on the `pubkey` field, enabling quick lookups of user activity.

### 2. Performance Optimization
*   **GIN Indexes on JSONB**: Crucial for filtering on specific event tags or custom properties stored within the event object. GIN indexes with `jsonb_path_ops` are generally preferred for equality checks.
*   **Time-Series Partitioning**: Given the append-only nature of Nostr, partitioning the `events` table by time (e.g., daily or weekly chunks) is highly recommended. This significantly improves query performance for recent data and simplifies data expiration/deletion.
*   **Clustering**: Clustering the `events` table by the timestamp index can reduce disk I/O, as it keeps temporally related events physically adjacent on the disk.

### 3. Schema Management Approaches
*   **Migrations**: Most mature relays utilize migration tools (like `Flyway`, `Diesel migrations`, or `Golang-migrate`) to version control database schema changes. This is critical for production stability.
*   **Auto-generation**: Some lightweight prototypes auto-generate schemas at startup. This is generally discouraged for production due to the risk of destructive changes, data loss, or blocking DDL operations on large tables.

## Recommendations for Building a New Relay
1.  **Prioritize Denormalization**: Do not rely solely on JSONB queries for high-traffic filters. Extract indexed tags into dedicated columns or tables.
2.  **Use Partitioning from Day One**: Implementing native PostgreSQL partitioning (e.g., using `pg_partman`) is much easier before the dataset grows to millions of rows.
3.  **Connection Pooling**: PostgreSQL requires aggressive connection management. Use a high-performance pooler like `PgBouncer` to handle the large number of concurrent, short-lived connections common in WebSocket-based relay traffic.
4.  **Asynchronous Writes**: Decouple the WebSocket ingestion thread from the database writer thread to ensure that slow database writes do not impact the relay's responsiveness.

## Nostream ([github.com/Cameri/nostream](https://github.com/Cameri/nostream))

A production-ready Nostr relay written in TypeScript (Node.js), backed by PostgreSQL 14+ and Redis. Uses Knex.js for versioned migrations.

### PostgreSQL Schema

The schema is fully normalized — no JSONB blob for the whole event. Tags are stored separately and populated by a trigger.

**`events` table** — one row per event, tags kept as a JSONB column for the trigger to process:
```sql
CREATE TABLE events (
    id               UUID     PRIMARY KEY DEFAULT uuid_generate_v4(),
    event_id         BYTEA    UNIQUE NOT NULL,  -- 32-byte hash
    event_pubkey     BYTEA    NOT NULL,
    event_kind       INTEGER  UNSIGNED NOT NULL,
    event_created_at INTEGER  UNSIGNED NOT NULL,
    event_content    TEXT     NOT NULL,
    event_tags       JSONB,                     -- raw tags array, source of truth for trigger
    event_signature  BYTEA    NOT NULL,
    remote_address   TEXT,
    expires_at       INTEGER,
    deleted_at       TIMESTAMP,
    event_deduplication JSONB,                 -- used for replaceable event conflict key
    first_seen       TIMESTAMP DEFAULT now()
);

-- Indexes
CREATE INDEX ON events (event_pubkey);
CREATE INDEX ON events (event_kind);
CREATE INDEX ON events (event_created_at);
CREATE INDEX ON events (expires_at);
CREATE INDEX event_tags_idx ON events USING GIN (event_tags);  -- GIN on raw JSONB

-- Unique index enforcing NIP-16 replaceable event semantics
CREATE UNIQUE INDEX replaceable_events_idx ON events (event_pubkey, event_kind)
    WHERE event_kind = 0 OR event_kind = 3
       OR (event_kind >= 10000 AND event_kind < 20000);
```

**`event_tags` table** — denormalized single-letter tags, populated by a PostgreSQL trigger on `events`:
```sql
CREATE TABLE event_tags (
    id         UUID  PRIMARY KEY DEFAULT uuid_generate_v4(),
    event_id   BYTEA NOT NULL,
    tag_name   TEXT  NOT NULL,   -- single-letter only (e.g. 'e', 'p', 't')
    tag_value  TEXT  NOT NULL
);

CREATE INDEX ON event_tags (event_id);
CREATE INDEX ON event_tags (tag_name, tag_value);

-- Trigger: on INSERT/UPDATE/DELETE to events, repopulate event_tags
CREATE OR REPLACE FUNCTION process_event_tags() RETURNS TRIGGER AS $$
DECLARE
  tag_element jsonb;
BEGIN
  DELETE FROM event_tags WHERE event_id = OLD.event_id;
  IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
    FOR tag_element IN SELECT jsonb_array_elements(NEW.event_tags) LOOP
      IF length(trim('"' FROM (tag_element->0)::text)) = 1
         AND (tag_element->1)::text IS NOT NULL THEN
        INSERT INTO event_tags (event_id, tag_name, tag_value)
        VALUES (NEW.event_id,
                trim('"' FROM (tag_element->0)::text),
                trim('"' FROM (tag_element->1)::text));
      END IF;
    END LOOP;
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER insert_event_tags
AFTER INSERT OR UPDATE OR DELETE ON events
FOR EACH ROW EXECUTE FUNCTION process_event_tags();
```

Tag queries (`#e`, `#p`, etc.) join `event_tags` rather than traversing the JSONB array.

**`users` and `invoices` tables** — for paid relay admission (Lightning payments):
```sql
CREATE TABLE users (
    pubkey          BYTEA    PRIMARY KEY,
    is_admitted     BOOLEAN  DEFAULT FALSE,
    balance         BIGINT   DEFAULT 0,      -- in msats
    tos_accepted_at DATETIME
);

CREATE TABLE invoices (
    id               UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    pubkey           BYTEA NOT NULL,
    bolt11           TEXT  NOT NULL,
    amount_requested BIGINT UNSIGNED NOT NULL,
    amount_paid      BIGINT UNSIGNED,
    unit             ENUM('msats','sats','btc'),
    status           ENUM('pending','completed','expired'),
    description      TEXT,
    confirmed_at     DATETIME,
    expires_at       DATETIME
);
```

### How Redis Is Used

Redis is **not** used for subscription fan-out. Subscription delivery is handled entirely in-process: each `WebSocketAdapter` holds a `Map<subscriptionId, filters[]>`, and a broadcast event walks all connected clients and filters locally.

Redis has two actual roles:

1. **Sliding-window rate limiting** — The `SlidingWindowRateLimiter` uses Redis sorted sets to track request timestamps per key (pubkey, IP, etc.) within a rolling time window:
   - `ZREMRANGEBYSCORE key 0 (now - period)` — evict old entries
   - `ZADD key timestamp member` — record this hit
   - `ZRANGE key 0 -1` — count hits in window
   - `EXPIRE key period` — auto-cleanup
   - Keys follow the pattern `<pubkey>:events:<period>` or `<ip>:message:<period>`

2. **General-purpose cache** — A `RedisAdapter` wraps the client with `get`/`set`/`hasKey` helpers, used for caching admission checks (e.g. `<pubkey>:is-admitted`). The event handler has a `TODO` comment noting that the `userRepository.findByPubkey()` call should be replaced with a cache lookup — meaning this is partially implemented.

---

## Schema Definitions

### Relay-rs (Postgres)
```sql
-- Events table
CREATE TABLE "event" (
	id bytea NOT NULL,
	pub_key bytea NOT NULL,
	created_at timestamp with time zone NOT NULL,
	kind integer NOT NULL,
	"content" bytea NOT NULL,
	hidden bit(1) NOT NULL DEFAULT 0::bit(1),
	delegated_by bytea NULL,
	first_seen timestamp with time zone NOT NULL DEFAULT now(),
	expires_at timestamp(0) with time zone,
	CONSTRAINT event_pkey PRIMARY KEY (id)
);
CREATE INDEX event_created_at_idx ON "event" (created_at,kind);
CREATE INDEX event_pub_key_idx ON "event" (pub_key);
CREATE INDEX event_delegated_by_idx ON "event" (delegated_by);
CREATE INDEX event_expires_at_idx ON "event" (expires_at);

-- Tags table
CREATE TABLE "tag" (
	id int8 NOT NULL GENERATED BY DEFAULT AS IDENTITY,
	event_id bytea NOT NULL,
	"name" varchar NOT NULL,
	value bytea NULL,
	value_hex bytea NULL,
	CONSTRAINT tag_fk FOREIGN KEY (event_id) REFERENCES "event"(id) ON DELETE CASCADE,
	CONSTRAINT unique_constraint_name UNIQUE (event_id, "name", value, value_hex)
);
CREATE INDEX tag_event_id_idx ON tag USING btree (event_id, name);
CREATE INDEX tag_value_idx ON tag USING btree (value);
CREATE INDEX tag_value_hex_idx ON tag USING btree (value_hex);

-- Account table
CREATE TABLE "account" (
    pubkey varchar NOT NULL,
    is_admitted BOOLEAN NOT NULL DEFAULT FALSE,
    balance BIGINT NOT NULL DEFAULT 0,
    tos_accepted_at TIMESTAMP,
    CONSTRAINT account_pkey PRIMARY KEY (pubkey)
);

-- Invoice table
CREATE TYPE status AS ENUM ('Paid', 'Unpaid', 'Expired');
CREATE TABLE "invoice" (
    payment_hash varchar NOT NULL,
    pubkey varchar NOT NULL,
    invoice varchar NOT NULL,
    amount BIGINT NOT NULL,
    status status NOT NULL DEFAULT 'Unpaid',
    description varchar,
    created_at timestamp,
    confirmed_at timestamp,
    CONSTRAINT invoice_payment_hash PRIMARY KEY (payment_hash),
    CONSTRAINT invoice_pubkey_fkey FOREIGN KEY (pubkey) REFERENCES account (pubkey) ON DELETE CASCADE
);
```

### Nostr-Relay-NestJS (PostgreSQL via Kysely)
```sql
-- Events table
CREATE TABLE events (
    id            CHAR(64) PRIMARY KEY,
    pubkey        CHAR(64) NOT NULL,
    created_at    BIGINT   NOT NULL,
    kind          INTEGER  NOT NULL,
    tags          JSONB    NOT NULL DEFAULT '[]',
    generic_tags  TEXT[]   NOT NULL DEFAULT '{}',
    content       TEXT     NOT NULL DEFAULT '',
    sig           CHAR(128) NOT NULL,
    expired_at    BIGINT,
    d_tag_value   TEXT,
    delegator     CHAR(64),
    create_date   TIMESTAMP NOT NULL DEFAULT now(),
    update_date   TIMESTAMP NOT NULL DEFAULT now(),
    delete_date   TIMESTAMP
);

-- Indices
CREATE EXTENSION IF NOT EXISTS btree_gin;
CREATE INDEX generic_tags_kind_idx ON events USING gin (generic_tags, kind);
CREATE INDEX pubkey_kind_created_at_idx ON events (pubkey, kind, created_at);
CREATE INDEX delegator_kind_created_at_idx ON events (delegator, kind, created_at);
CREATE INDEX created_at_kind_idx ON events (created_at, kind);

-- Generic tags table (for optimized tag queries)
CREATE TABLE generic_tags (
    id         SERIAL PRIMARY KEY,
    tag        TEXT    NOT NULL,
    author     CHAR(64) NOT NULL,
    kind       INTEGER  NOT NULL,
    event_id   CHAR(64) NOT NULL REFERENCES events(id),
    created_at BIGINT   NOT NULL
);

-- Indices for tag filtering
CREATE UNIQUE INDEX g_tag_event_id_idx ON generic_tags (tag, event_id);
CREATE INDEX g_tag_kind_created_at_idx ON generic_tags (tag, kind, created_at);
CREATE INDEX g_tag_created_at_idx ON generic_tags (tag, created_at);
```
Nostr-Relay-NestJS features a hybrid storage model where tags are stored both in a JSONB field (for general lookup) and a normalized `generic_tags` table (for optimized tag query performance). The schema uses Kysely for type-safe interaction.

