Skip to content

База даних ​

Бекенд працює з PostgreSQL через Drizzle ORM. Пакети drizzle-orm і drizzle-kit закріплені на релізі-кандидаті 1.0.0-rc.5 у packages/backend/package.json; оновлювати їх слід разом і на ту саму точну версію. Драйвер — pg (node-postgres). Redis використовується окремо — як сховище сесій та для тротлінгу.

Коротко на прикладі: додати колонку ​

Задача: нотатки з туторіалу Як додати фічу треба вміти закріплювати.

  1. Додайте поле в таблицю в packages/backend/src/database/schema.ts:

    ts
    pinned: boolean('pinned').notNull().default(false),
  2. Згенеруйте міграцію (база для цього не потрібна):

    bash
    pnpm --filter backend db:generate --name add_notes_pinned
    sql
    -- src/drizzle/migrations/<timestamp>_add_notes_pinned/migration.sql
    ALTER TABLE "notes" ADD COLUMN "pinned" boolean DEFAULT false NOT NULL;
  3. Застосуйте її: pnpm --filter backend db:migrate.

Результат: колонка є в базі, а тип NoteRow у репозиторії сам отримав поле pinned: boolean, бо виводиться зі схеми. pnpm typecheck покаже місця, де нове поле треба врахувати, наприклад у transformer, якщо віддаєте його клієнту.

DEFAULT false тут важливий: без default і з notNull() міграція впаде на таблиці, де вже є рядки. Міграції комітьте разом зі зміною schema.ts. Нижче описано підключення, схему, міграції і seed детально.

Підключення ​

Параметри беруться з env-змінних PGSQL_* (див. .env.example та Конфігурацію):

ЗміннаПриклад у .env.example
PGSQL_HOST127.0.0.1
PGSQL_PORT5432
PGSQL_USERpostgres
PGSQL_PASSWORDpostgres
PGSQL_DB_NAMEvibe_template
PGSQL_SSLfalse

Runtime-конфіг — src/config/database.ts; пул і клієнт Drizzle створюються в src/database/db.ts:

ts
const pool = new pg.Pool({
    ...connectionOptions, // host, port, ..., ssl: toPgSsl(PGSQL_SSL)
    max: 10,
    connectionTimeoutMillis: 2000,
})

const db = drizzle({
    client: pool,
    relations,
    logger: sqlLoggingEnabled(appConfig), // !appConfig.isProductionLike
})

Шифрування з'єднання (PGSQL_SSL). Параметри підключення спільні для головного пулу, пулу перевірки сесії, drizzle-kit, db:create і db:seed (pool-options.ts), тож значення діє скрізь одразу. false (за замовчуванням) — без TLS; true — TLS із перевіркою сертифіката сервера; require — TLS без перевірки сертифіката (шифрування без автентичності сервера, як sslmode=require у libpq). Будь-яке інше значення — помилка старту, а не мовчазний відкат на незашифроване з'єднання. У production-like середовищі з PGSQL_SSL=false на старті логується попередження (керована БД без TLS — ризик; для БД у довіреній приватній мережі це свідомий вибір).

SQL-логування Drizzle друкує кожен запит разом із параметрами, тобто з email, ідентифікаторами та всім, що передано параметром. Тому воно вимкнене в production-like середовищі (sql-logging.ts: APP_IS_PRODUCTION=true або APP_ENV=prod/production) і вмикається в решті.

Для drizzle-kit є окремий drizzle.config.ts: він бере підключення з тієї ж resolveDatabaseConfig(), що й застосунок, schema: "./src/database/schema.ts", out: "./src/drizzle/migrations", таблиця журналу drizzle.__drizzle_migrations, strict: true.

Схема та relations ​

Таблиці описано в одному файлі src/database/schema.ts (pgTable, pgEnum, індекси, customType), наприклад export const users = pgTable('users', { ... }). Каталог src/drizzle/ містить лише міграції; схема і relations живуть у src/database.

Зв'язки задаються через Relational Queries v2 у src/database/relations.ts:

ts
export const relations = defineRelations(schema, (r) => ({
    users: {
        oauthAccounts: r.many.oauthAccounts({ from: r.users.id, to: r.oauthAccounts.userId }),
        // ...
    },
}))

Об'єкт relations передається в drizzle({ relations }), тож db.query.<table>.findMany/findFirst з with доступні. Однак наразі репозиторії їх не використовують — запити пишуться core query builder-ом, як у user-repository.ts:

ts
async findById(id) {
  return await db
    .select()
    .from(users)
    .where(eq(users.id, id))
    .limit(1)
    .then((rows) => rows.at(0));
},

Типи рядків виводяться через InferSelectModel<typeof users> / InferInsertModel<typeof users>. Транзакцій (db.transaction) у коді застосунку зараз немає.

pgvector ​

PostgreSQL має містити розширення pgvector (vector). Ця вимога обов'язкова навіть тоді, коли AI не налаштовано: тип vector використовує сама схема, тому без розширення не пройде ні db:create, ні початкова міграція.

Навіщо потрібне розширення ​

Support AI відповідає на питання користувачів на основі бази знань (RAG: спершу знайти релевантні статті, потім передати їх у LLM як контекст). Шукати статті за ключовими словами погано: користувач формулює питання іншими словами, ніж автор статті. Тому пошук іде за змістом через embeddings, і для цього потрібна векторна арифметика в БД:

  1. Зберігання. Кожна стаття в support_knowledge_base має колонку embedding vector(1536) (schema.ts, customType vector1536). У ній лежить вектор, який OpenAI embeddings (text-embedding-3-small, 1536 вимірів) повертає для тексту title + content.

  2. Пошук. Питання користувача перекладається англійською, перетворюється на вектор тим самим способом, і БД шукає найближчі статті за косинусною відстанню, оператором <=> з pgvector (searchByEmbedding):

    sql
    SELECT id, title, content, embedding <=> $1::vector AS distance
    FROM support_knowledge_base
    WHERE is_active = true AND embedding IS NOT NULL
    ORDER BY distance ASC
    LIMIT 5;
  3. Відповідь. Знайдені статті підставляються в system prompt замість {context}, і LLM відповідає лише на їх основі.

Без pgvector довелося б тримати вектори в окремій векторній БД (ще один сервіс, синхронізація з PostgreSQL) або завантажувати всі вектори в застосунок і рахувати відстань у JavaScript. pgvector дає пошук в одному SQL-запиті поруч з рештою даних статті, з транзакціями й бекапами PostgreSQL. Повний сценарій описано на сторінці AI.

Де це видно в коді ​

МісцеЩо робить
create-database.tsCREATE EXTENSION IF NOT EXISTS vector у цільовій БД
20261017193054_init/migration.sqlколонка "embedding" vector(1536); саме розширення міграція не створює
support-knowledge-repository.tssetEmbedding (::vector), clearEmbedding, searchByEmbedding (<=>)

Що буде без розширення ​

  • db:create падає на CREATE EXTENSION IF NOT EXISTS vector з помилкою на кшталт extension "vector" is not available.
  • Якщо БД створено інакше, db:migrate падає на початковій міграції: type "vector" does not exist.

Як отримати PostgreSQL з pgvector ​

  • Docker (локально): образ pgvector/pgvector, наприклад pgvector/pgvector:pg16 (команда є в розділі Початок роботи). Звичайний образ postgres розширення не містить.
  • Встановлений PostgreSQL: пакет pgvector для своєї версії PostgreSQL, наприклад brew install pgvector (Homebrew) або postgresql-16-pgvector (apt, репозиторій PGDG). Інші способи описано в README pgvector.
  • Керовані сервіси: більшість хмарних PostgreSQL підтримують pgvector, але розширення треба дозволити або увімкнути за документацією провайдера.

CREATE EXTENSION може вимагати прав власника БД або суперкористувача. Якщо користувач з PGSQL_USER таких прав не має, адміністратор БД виконує команду один раз вручну, а db:create далі лише підтвердить наявність розширення (IF NOT EXISTS).

Перевірити, що розширення є:

sql
SELECT extversion FROM pg_extension WHERE extname = 'vector';

Обмеження поточної реалізації ​

  • Розмірність колонки 1536 (EMBEDDING_COLUMN_DIMENSIONS); OPENAI_EMBEDDING_DIMENSIONS має з нею збігатися, інакше старт зупиняється. Інша модель або розмірність потребує міграції колонки й переіндексації статей.
  • Векторного індексу (HNSW / IVFFlat) немає, тому пошук перебирає всі активні статті. Для бази знань підтримки на сотні статей цього достатньо; для більших обсягів варто додати індекс USING hnsw (embedding vector_cosine_ops) міграцією.

Міграції ​

Міграції лежать у src/drizzle/migrations у форматі Drizzle 1.0 «тека на міграцію»: кожна тека <timestamp>_<name> містить migration.sql і snapshot.json. З 1.0.0-rc.5 snapshot інкрементальні (версія 9): перша міграція зберігає повну схему (ddl), наступні — лише різницю (deltas). Snapshot старішого формату drizzle-kit не приймає і просить виконати drizzle-kit up; SQL міграцій ця конвертація не змінює:

МіграціяЩо робить
20261017193054_initпочаткова схема
20261024152453_llm_usage_nullablellm_text_usage: prompt_tokens, completion_tokens, total_tokens і user_id стають nullable (NULL = провайдер не повідомив usage / системний виклик без користувача), дефолти 0 прибрано; llm_text_feature отримує SUPPORT_EMBEDDING. Наявні рядки не змінюються. ALTER TYPE … ADD VALUE не відкочується простим DROP
20261024171546_drop_users_is_adminusers: DROP COLUMN is_admin. Ознака адміна — лише role = 'admin'. Незворотна: значення колонки втрачаються; перед застосуванням варто перевірити, що is_admin ніде не розходиться з role (SELECT count(*) FROM users WHERE is_admin <> (role = 'admin'))
20261102053350_llm_usage_set_null_support_history_indexllm_text_usage.user_id: зовнішній ключ ON DELETE SET NULL замість CASCADE, тож журнал usage (токени, вартість) переживає видалення акаунта, а рядок більше не пов'язаний з людиною; текст промпту, якщо він зберігався, прибирає llm:purge-prompts за віком. support_chat_history: індекс (user_id, created_at) замінює (user_id); історія чату, на відміну від usage, як і раніше видаляється разом з користувачем

Скрипти ​

Запускаються в пакеті backend (pnpm --filter ./packages/backend <script>):

СкриптКомандаЩо робить
db:createtsx src/db/create-database.tsпідключається до БД postgres, створює PGSQL_DB_NAME, якщо її немає, і виконує CREATE EXTENSION IF NOT EXISTS vector (код)
db:create:prodnode dist/db/create-database.jsте саме зі зібраного коду
db:generatedrizzle-kit generateгенерує нову теку міграції з різниці між схемою та snapshot
db:migratedrizzle-kit migrateзастосовує невиконані міграції
db:studiodrizzle-kit studioвеб-інтерфейс Drizzle Studio
db:seedtsx src/db/seed.tsідемпотентно вставляє початкові промпти, з --demo — ще й demo-акаунти, див. Seed (код)
db:seed:prodnode dist/db/seed.jsте саме зі зібраного коду

Seed ​

db:seed можна запускати повторно: наявні рядки він пропускає й нічого не перезаписує. Усе виконується в одній транзакції, тож при помилці в БД не залишається нічого, а команда завершується з кодом 1. Логіка — у src/db/seed/run-seed.ts.

ЩоКолиПовторний запуск
Промпти SUPPORT_SYSTEM, SUPPORT_FIRST_CHAT, SUPPORT_QUERY_TRANSLATION у llm_system_promptsзавждиON CONFLICT (type) DO NOTHING: промпти, відредаговані в адмінці, лишаються як є
Адмін (SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD) і, опційно, звичайний користувач (SEED_USER_*)лише з --demoнаявний користувач (пошук за LOWER(email)) пропускається; пароль і роль не змінюються
  • Тексти промптів беруться з тих самих defaults, що й runtime-fallback сервісів (config/support.ts), тому перший seed не змінює поведінку AI. Без рядків у таблиці AI працює на цих fallback, але адмінка не може змінити промпт: UPDATE не створює відсутній рядок.
  • Від APP_ENV seed не залежить: без --demo акаунти не створюються в жодному середовищі. З --demo у production-like середовищі виводиться попередження.
  • Email нормалізується (trim().toLowerCase()), пароль хешується hashPassword з metautil, email_verified_at = поточний час.
  • Якщо SEED_ADMIN_EMAIL уже належить користувачу з role = 'user', seed завершується успішно з попередженням exists_without_admin_role: роль автоматично не підвищується.
  • Ідемпотентність гарантована для послідовних запусків. Наявність користувача перевіряється SELECT перед INSERT, тому з двох одночасних запусків --demo один може впасти на unique-індексі email. Транзакція тоді відкочується, і повторний запуск завершується успішно.
  • Помилка виводиться без SQL-параметрів, detail і тексту pg-помилки, бо там можуть бути email, хеш пароля й session_token. Виводяться лише назва помилки та поля code, constraint, table, column; текст лишається тільки для системних помилок на кшталт ECONNREFUSED (describe-error.ts).
  • Звіт виводить для кожного запису inserted або skipped (<причина>): already_exists, exists_without_admin_role, missing_credentials, demo_disabled.

Кеш промптів

Backend читає промпти один раз на старті (promptService.load() у src/index.ts). Запускайте db:seed до старту backend або перезапустіть його після seed.

Перший адмін у production ​

Demo-сценарій для production не призначений. Варіанти:

  1. Зареєструватися звичайним способом і підвищити роль вручну:

    sql
    UPDATE users SET role = 'admin' WHERE LOWER(email) = '<admin-email>';
  2. Разово виконати pnpm db:seed:prod --demo з тимчасово заданими SEED_ADMIN_EMAIL і SEED_ADMIN_PASSWORD, а потім одразу прибрати їх з оточення.

Як змінити схему ​

  1. Змініть таблиці в src/database/schema.ts; для нових зв'язків — relations.ts.
  2. db:generate створить нову теку в src/drizzle/migrations; перевірте SQL вручну.
  3. db:migrate застосує її до локальної БД.
  4. Додайте методи у відповідний репозиторій (див. Архітектуру і Додавання фічі).

Redis ​

Клієнт ioredis створюється в src/database/redis.ts з конфігу #config/redis.js (REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_PREFIX). Його використовують сховище сесій (redis-session-storage.ts), WS-автентифікація, скидання пароля та тротлінг автентифікації. Докладніше — Автентифікація.

Клієнт відмовляє швидко, а не зависає: без offline queue і повторів, з таймаутом команди REDIS_COMMAND_TIMEOUT_MS (500 мс); коли Redis недоступний, запити отримують 503 service_unavailable. Контракт і обмеження — Відмова Redis.

Автор: Олександр Блажейко · blazheiko@gmail.com