258 lines
12 KiB
Markdown
258 lines
12 KiB
Markdown
# 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.
|
|
|