aegida-console / docs / superpowers / specs / 2026-07-26-postgresql-persistence-design.md
2026-07-26-postgresql-persistence-design.md
Raw

PostgreSQL, Docker и серверная история чатов

Дата: 2026-07-26

Цель

Заменить устройство-локальное хранение пользователей, истории чатов и выбранной модели на PostgreSQL. Приложение должно запускаться вместе с базой через Docker Compose, а вход и API чата должны работать с данными реального пользователя из базы.

Инфраструктура

docker-compose.yml определяет два сервиса:

  • db: PostgreSQL 16, доступный с хоста на 5433:5432;
  • app: Next.js-сервис, собранный из локального Dockerfile и доступный на 3000:3000.

Параметры PostgreSQL фиксированы:

POSTGRES_USER=ai-control-chat-ui
POSTGRES_PASSWORD=ai-control-chat-ui
POSTGRES_DB=ai_control_chat_ui
DATABASE_URL=postgresql://ai-control-chat-ui:ai-control-chat-ui@db:5432/ai_control_chat_ui

db получает healthcheck через pg_isready. app запускается после перехода базы в healthy-состояние.

Dockerfile использует многоступенчатую сборку Node.js 22 Alpine. Next.js собирается с output: "standalone"; production-образ содержит standalone-сервер, публичные файлы, миграции и скрипты запуска, но не исходные dev-зависимости.

Миграции и тестовый пользователь

Миграции расположены в db/migrations, имеют монотонные имена 001_*.sql, и применяются Node-скриптом в транзакции. Таблица schema_migrations хранит имена уже применённых файлов; повторный запуск не изменяет уже применённую схему.

Перед запуском приложения контейнер выполняет миграции и безопасный idempotent seed. Seed создаёт или обновляет тестового пользователя:

email: demo@ai-control.local
password: Demo1234!

Пароль сохраняется только как bcrypt-хеш. В production пароль seed-пользователя переопределяется через AUTH_PASSWORD; значение по умолчанию предназначено только для локального Docker-сценария.

Схема данных

users

Поле Тип Ограничение
id UUID primary key
email text unique, not null
password_hash text not null
last_model_id text not null, default chatgpt, check: chatgpt, deepseek, qwen
created_at timestamptz not null
updated_at timestamptz not null

conversations

Поле Тип Ограничение
id UUID primary key
user_id UUID foreign key to users, cascade delete
title text not null
model_id text valid model check
created_at timestamptz not null
updated_at timestamptz not null

Индекс conversations(user_id, updated_at desc) служит загрузке боковой панели.

chat_messages

Поле Тип Ограничение
id UUID primary key
conversation_id UUID foreign key to conversations, cascade delete
role text user или assistant
content text not null
status text streaming, complete, error, stopped
created_at timestamptz not null

Индекс chat_messages(conversation_id, created_at) служит загрузке истории и построению контекста.

Серверная архитектура

lib/db содержит единственный pg.Pool, репозитории и транзакционные функции. SQL всегда параметризован; Route Handlers не содержат SQL-строк.

Репозитории:

  • users: поиск по email/ID, bcrypt-проверка пароля, чтение и обновление last_model_id;
  • conversations: создание, загрузка только диалогов владельца, удаление и обновление модели/заголовка;
  • messages: добавление пользовательского сообщения, создание assistant-shell, дописывание потока и финализация статуса.

В тестах репозитории получают зависимость Queryable, что позволяет проверять SQL-логику без реальной сети PostgreSQL. Интеграционный Docker smoke-сценарий проверяет миграции и подключение к настоящей базе.

Аутентификация

POST /api/auth/login получает email и пароль, запрашивает пользователя в PostgreSQL и сравнивает пароль с bcrypt-хешем. При успехе JWT подписывается существующим JWT_SECRET; sub — UUID строки users.id, email — email из базы.

GET /api/auth/me проверяет Bearer JWT и дополнительно читает пользователя по sub. Удалённый пользователь или несовпадение email возвращает 401.

Никакие логины и пароли не поступают в браузер кроме введённого пользователем пароля при входе. JWT продолжает жить 24 часа и передаётся только как Authorization: Bearer <token>.

API чатов

GET /api/conversations

Возвращает диалоги текущего пользователя с сообщениями, отсортированные по updated_at по убыванию. Перед возвратом любые сохранённые streaming-сообщения переводятся в stopped, чтобы после перезапуска не возникал вечный индикатор генерации.

PATCH /api/me/model

Принимает { model: ModelId }, проверяет модель и сохраняет users.last_model_id. Значение применяется к следующему новому чату и восстанавливается после перезагрузки.

POST /api/chat

Принимает только:

{
  conversationId: string;
  model: "chatgpt" | "deepseek" | "qwen";
  content?: string;
  retry?: boolean;
}

Для обычного сообщения сервер проверяет, что диалог принадлежит текущему пользователю, создаёт диалог при новом UUID, сохраняет пользовательское сообщение, строит серверный контекст в пределах 100 сообщений/32 000 символов на сообщение/128 000 символов суммарно и запускает Gateway.

Для retry сервер использует только последнее валидное пользовательское сообщение перед финальным error/stopped assistant-сообщением. Новой пользовательской записи не создаётся.

Перед отдачей потока сервер создаёт assistant-shell со статусом streaming. Поток одновременно передаётся клиенту и накапливается на сервере. При нормальном окончании shell обновляется до complete; при отмене — до stopped; при ошибке Gateway — до error с безопасным текстом. Изменение updated_at и выбранной модели диалога выполняется в той же транзакции, что и создание сообщения.

Gateway получает только нормализованные сообщения и отдельный AI_GATEWAY_API_KEY. JWT клиента в Gateway не передаётся.

Клиент

После успешной проверки JWT AuthGate вызывает GET /api/conversations, а хук чата использует сервер как единственный источник истины. Локальный storage больше не содержит историю чатов и модель; его прежние записи игнорируются.

При выборе модели клиент оптимистично обновляет UI и вызывает PATCH /api/me/model. Создание, удаление, отправка и retry изменяют UI оптимистично, затем синхронизируются с API; при ошибке происходит контролируемый reload истории.

Ошибки и безопасность

  • SQL-ошибки, URL базы, хеши паролей, ключи Gateway и внутренние исключения не возвращаются клиенту.
  • Несуществующий или чужой conversationId возвращает 404, не раскрывая данные другого пользователя.
  • Отсутствующий/некорректный JWT возвращает 401.
  • Некорректная модель, UUID, тело, лимит сообщения или retry-состояние возвращают 400.
  • Временная недоступность PostgreSQL возвращает безопасный 503.
  • Docker Compose не публикует PostgreSQL на 5432; с хоста он доступен только на 5433.

Проверка качества

  • Unit-тесты репозиториев, мигратора, bcrypt-входа, ownership API и server-side context window.
  • Route-тесты для входа, /api/me, /api/conversations, модели и стриминга чата.
  • Контейнерный smoke: docker compose up --build, healthcheck БД, миграции/seed, успешный вход и сохранение диалога.
  • Перед handoff: npm test, npm run lint, npx tsc --noEmit, npm run build и docker compose config.

Критерии готовности

  • docker compose up --build поднимает приложение и PostgreSQL с БД на localhost:5433.
  • Тестовый пользователь проходит вход через данные из PostgreSQL.
  • После перезапуска приложения история чатов и последняя модель восстанавливаются из базы.
  • Пользователь не может прочитать, изменить или отправить сообщение в чужой диалог.
  • Потоковые ответы и их финальные статусы сохраняются сервером.
  • JWT и ключи не попадают в PostgreSQL-логи, клиентский код или Gateway-запросы.