Indexium/docs/database-schema.md

250 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Схема БД Indexium
> PostgreSQL 16+ с расширениями `pg_trgm`, `pgvector` (опционально), `uuid-ossp`/`pgcrypto`.
---
## 1. Расширения
```sql
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid()
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- fuzzy search
-- CREATE EXTENSION IF NOT EXISTS vector; -- pgvector, когда нужен семантический поиск
```
## 2. Таблицы
### `authors` — авторы (зеркало GitHub users)
```sql
CREATE TABLE authors (
github_id BIGINT PRIMARY KEY, -- GitHub user ID
login VARCHAR(39) NOT NULL UNIQUE, -- GitHub login
avatar_url TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
```
### `mods` — моды (один репозиторий = один мод на MVP)
```sql
CREATE TABLE mods (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
github_repo_id BIGINT UNIQUE NOT NULL,
slug VARCHAR(64) UNIQUE NOT NULL, -- URL-friendly, напр. "sodium-extra"
name VARCHAR(128) NOT NULL,
summary TEXT, -- короткое описание из манифеста
description TEXT, -- README.md (markdown, кэшируем)
author_github_id BIGINT NOT NULL REFERENCES authors(github_id),
github_repo_name VARCHAR(128) NOT NULL, -- "owner/repo"
default_branch VARCHAR(32) DEFAULT 'main',
icon_url TEXT,
verified BOOLEAN DEFAULT FALSE,
suspicious BOOLEAN DEFAULT FALSE,
search_vector TSVECTOR, -- материализованный FTS вектор
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_mods_search ON mods USING GIN (search_vector);
CREATE INDEX idx_mods_trgm ON mods USING GIN (name gin_trgm_ops, summary gin_trgm_ops);
CREATE INDEX idx_mods_author ON mods (author_github_id);
```
Триггер для `search_vector`:
```sql
CREATE OR REPLACE FUNCTION mods_search_vector_update() RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('english', coalesce(NEW.name,'')), 'A') ||
setweight(to_tsvector('english', coalesce(NEW.summary,'')), 'B') ||
setweight(to_tsvector('english', coalesce(NEW.description,'')), 'C');
RETURN NEW;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER trg_mods_search_vector
BEFORE INSERT OR UPDATE OF name, summary, description ON mods
FOR EACH ROW EXECUTE FUNCTION mods_search_vector_update();
```
### `mod_versions` — версии / релизы
```sql
CREATE TABLE mod_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
mod_id UUID NOT NULL REFERENCES mods(id) ON DELETE CASCADE,
version_number VARCHAR(32) NOT NULL, -- semver из манифеста/тега
game_versions VARCHAR(32)[] NOT NULL, -- e.g. ['1.20.1', '1.21']
loaders VARCHAR(16)[] NOT NULL, -- e.g. ['fabric','quilt','neoforge']
download_url TEXT NOT NULL, -- https://github.com/.../releases/download/...
file_name VARCHAR(128) NOT NULL, -- sodium-1.2.3.jar
file_sha256 CHAR(64) NOT NULL,
file_size BIGINT,
published_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(mod_id, version_number)
);
CREATE INDEX idx_versions_mod ON mod_versions (mod_id, published_at DESC);
CREATE INDEX idx_versions_lookup ON mod_versions USING GIN (game_versions, loaders);
CREATE INDEX idx_versions_sha ON mod_versions (file_sha256);
```
### `webhook_deliveries` — идемпотентность webhook'ов (<50ms ответ)
```sql
CREATE TABLE webhook_deliveries (
delivery_id VARCHAR(64) PRIMARY KEY, -- X-GitHub-Delivery (UUID от GitHub)
event VARCHAR(32) NOT NULL, -- "release"
action VARCHAR(32), -- "published"
repo_id BIGINT,
payload JSONB,
processed_at TIMESTAMPTZ DEFAULT now()
);
-- Уникальный индекс уже есть как PK, но явно для дедупа:
-- INSERT INTO webhook_deliveries (...) VALUES (...) ON CONFLICT (delivery_id) DO NOTHING
-- В handler: если affected_rows == 0 → 409 Already Processed, иначе push в Redis Streams.
```
> **Почему так:** Ingestion API должен ответить `202` за <50ms. Сначала `INSERT ... ON CONFLICT DO NOTHING` в `webhook_deliveries`, только потом `XADD` в Redis. Если `delivery_id` уже есть — сразу `409` без очереди.
### `mod_authors` — M2M авторы/контрибьюторы
На MVP `mods.author_github_id` достаточно (1 репо = 1 owner). Для организаций и соавторов — нормализуем сразу, чтобы не мигрировать болезненно:
```sql
CREATE TABLE mod_authors (
mod_id UUID NOT NULL REFERENCES mods(id) ON DELETE CASCADE,
github_id BIGINT NOT NULL REFERENCES authors(github_id) ON DELETE CASCADE,
role VARCHAR(16) NOT NULL DEFAULT 'owner', -- 'owner' | 'contributor' | 'maintainer'
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (mod_id, github_id)
);
CREATE INDEX idx_mod_authors_github ON mod_authors (github_id);
-- На MVP можно оставить mods.author_github_id как денормализованный owner
-- и дублировать его в mod_authors при создании мода (триггер или код).
```
> **MVP стратегия:** оставляем `mods.author_github_id` (как сейчас в `migrations/20260906000000_init_schema.sql`) для простых запросов, но добавляем `mod_authors` когда появится первый кейс организации. В `GET /mods/:slug` отдаём `authors: [{login, role}]` вместо одиночного `author`.
### `mod_versions.file_size` — откуда берётся
В `api-spec.md` поле `file_size` возвращается клиентам. Заполняется воркером из HTTP-заголовка:
```sql
-- уже в mod_versions: file_size BIGINT — bytes из Content-Length
```
Алгоритм воркера (`jar_parser.rs`):
1. `HEAD download_url` → `Content-Length` + `Accept-Ranges: bytes`.
2. Если `Content-Length` отсутствует — fallback на `GET` с `Range: bytes=0-0` и парсинг `Content-Range`.
3. Значение пишется в `mod_versions.file_size` при `INSERT`.
> GitHub CDN (`objects.githubusercontent.com`) всегда отдаёт `Content-Length` и поддерживает `Range` для release assets — проверено для `.jar` до 50MB.
### `dependencies` (опционально, нормализованная)
На MVP храним зависимости как `JSONB` в `mod_versions` или отдельной таблицей:
```sql
CREATE TABLE mod_dependencies (
version_id UUID REFERENCES mod_versions(id) ON DELETE CASCADE,
depends_on_mod_id UUID REFERENCES mods(id), -- nullable если внешний мод не в индексе
mod_id_str VARCHAR(64) NOT NULL, -- id из fabric.mod.json depends
version_range VARCHAR(64), -- ">=1.0.0"
PRIMARY KEY (version_id, mod_id_str)
);
```
## 3. Телеметрия — аналог bStats (см. docs/analytics.md)
```sql
-- Полуагрегат: один пинг = одна строка, TTL 30 дней (DELETE via cron)
CREATE TABLE mod_telemetry_pings (
id BIGSERIAL PRIMARY KEY,
mod_id UUID NOT NULL REFERENCES mods(id) ON DELETE CASCADE,
server_hash CHAR(64) NOT NULL, -- sha256(server_uuid + daily_salt)
mc_version VARCHAR(16) NOT NULL,
loader VARCHAR(16) NOT NULL,
os VARCHAR(16) NOT NULL,
java_version VARCHAR(16) NOT NULL,
player_count INT NOT NULL DEFAULT 0,
pinged_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_telemetry_lookup ON mod_telemetry_pings (mod_id, pinged_at DESC);
CREATE INDEX idx_telemetry_hash ON mod_telemetry_pings (server_hash, pinged_at);
-- Суточный агрегат — хранится навсегда
CREATE TABLE mod_daily_stats (
mod_id UUID NOT NULL REFERENCES mods(id) ON DELETE CASCADE,
date DATE NOT NULL,
active_servers INT NOT NULL DEFAULT 0, -- COUNT(DISTINCT server_hash) за день
active_players INT NOT NULL DEFAULT 0,
breakdown_json JSONB NOT NULL, -- { mc_versions:{}, loaders:{}, os:{}, java:{}, custom:{...} }
PRIMARY KEY (mod_id, date)
);
-- Соль для анонимизации (ротация daily)
CREATE TABLE analytics_salts (
date DATE PRIMARY KEY,
salt CHAR(64) NOT NULL
);
-- Хеш: server_hash = sha256(server_uuid || salt_for_today) — позволяет считать уникальные за день, но не трекать сквозь дни.
-- Rate limit: Redis SET server_hash:mod_id NX EX 900 (1 пинг / 15 мин)
-- TTL: DELETE FROM mod_telemetry_pings WHERE pinged_at < NOW() - INTERVAL '30 days' (cron hourly)
-- Агрегация: кроном раз в час INSERT INTO mod_daily_stats ... ON CONFLICT DO UPDATE COUNT(DISTINCT server_hash)
```
> Postgres хватает до ~10M пингов/мес. При росте — `SELECT create_hypertable('mod_telemetry_pings','pinged_at')` (TimescaleDB) или ClickHouse без смены схемы.
## 4. Пример запросов
### Поиск с FTS + фильтры
```sql
SELECT id, name, summary, ts_rank(search_vector, query) AS rank
FROM mods, plainto_tsquery('english', $1) query
WHERE search_vector @@ query
AND suspicious = false
AND EXISTS (
SELECT 1 FROM mod_versions v
WHERE v.mod_id = mods.id
AND v.game_versions && ARRAY[$2]::varchar[]
AND v.loaders && ARRAY[$3]::varchar[]
)
ORDER BY rank DESC
LIMIT 20 OFFSET $4;
```
### Fuzzy (опечатки)
```sql
SELECT name, similarity(name, 'sodim') AS sml
FROM mods
WHERE name % 'sodim' -- оператор pg_trgm
ORDER BY sml DESC LIMIT 10;
```
## 5. Миграции
Хранятся в `indexium-backend/migrations/` (sqlx):
```
migrations/
20260906000000_init_schema.sql -- mods, mod_versions
20260907000000_telemetry.sql -- mod_telemetry_pings, mod_daily_stats, analytics_salts
```
Запуск: `sqlx migrate run` / `cargo sqlx migrate run`.
## 6. Сиды
Для дев-окружения: `migrations/seeds/dev.sql` — 5 фейковых модов + версии, чтобы фронт сразу имел данные.
## 7. Будущие расширения
- `pgvector` колонка `embedding vector(1536)` для семантического поиска по README.
- Партиционирование `mod_versions` по `published_at` если >1M строк.
- Материализованное представление `popular_mods` (top по скачиваниям).