База даних
Бекенд працює з PostgreSQL через Drizzle ORM. Пакети drizzle-orm і drizzle-kit закріплені на релізі-кандидаті 1.0.0-rc.5 у packages/backend/package.json; оновлювати їх слід разом і на ту саму точну версію. Драйвер — pg (node-postgres). Redis використовується окремо — як сховище сесій та для тротлінгу.
Коротко на прикладі: додати колонку
Задача: нотатки з туторіалу Як додати фічу треба вміти закріплювати.
Додайте поле в таблицю в
packages/backend/src/database/schema.ts:tspinned: boolean('pinned').notNull().default(false),Згенеруйте міграцію (база для цього не потрібна):
bashpnpm --filter backend db:generate --name add_notes_pinnedsql-- src/drizzle/migrations/<timestamp>_add_notes_pinned/migration.sql ALTER TABLE "notes" ADD COLUMN "pinned" boolean DEFAULT false NOT NULL;Застосуйте її:
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_HOST | 127.0.0.1 |
PGSQL_PORT | 5432 |
PGSQL_USER | postgres |
PGSQL_PASSWORD | postgres |
PGSQL_DB_NAME | vibe_template |
PGSQL_SSL | false |
Runtime-конфіг — src/config/database.ts; пул і клієнт Drizzle створюються в src/database/db.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:
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:
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, і для цього потрібна векторна арифметика в БД:
Зберігання. Кожна стаття в
support_knowledge_baseмає колонкуembedding vector(1536)(schema.ts,customTypevector1536). У ній лежить вектор, який OpenAI embeddings (text-embedding-3-small, 1536 вимірів) повертає для текстуtitle + content.Пошук. Питання користувача перекладається англійською, перетворюється на вектор тим самим способом, і БД шукає найближчі статті за косинусною відстанню, оператором
<=>з pgvector (searchByEmbedding):sqlSELECT 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;Відповідь. Знайдені статті підставляються в system prompt замість
{context}, і LLM відповідає лише на їх основі.
Без pgvector довелося б тримати вектори в окремій векторній БД (ще один сервіс, синхронізація з PostgreSQL) або завантажувати всі вектори в застосунок і рахувати відстань у JavaScript. pgvector дає пошук в одному SQL-запиті поруч з рештою даних статті, з транзакціями й бекапами PostgreSQL. Повний сценарій описано на сторінці AI.
Де це видно в коді
| Місце | Що робить |
|---|---|
create-database.ts | CREATE EXTENSION IF NOT EXISTS vector у цільовій БД |
20261017193054_init/migration.sql | колонка "embedding" vector(1536); саме розширення міграція не створює |
support-knowledge-repository.ts | setEmbedding (::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).
Перевірити, що розширення є:
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_nullable | llm_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_admin | users: 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_index | llm_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:create | tsx src/db/create-database.ts | підключається до БД postgres, створює PGSQL_DB_NAME, якщо її немає, і виконує CREATE EXTENSION IF NOT EXISTS vector (код) |
db:create:prod | node dist/db/create-database.js | те саме зі зібраного коду |
db:generate | drizzle-kit generate | генерує нову теку міграції з різниці між схемою та snapshot |
db:migrate | drizzle-kit migrate | застосовує невиконані міграції |
db:studio | drizzle-kit studio | веб-інтерфейс Drizzle Studio |
db:seed | tsx src/db/seed.ts | ідемпотентно вставляє початкові промпти, з --demo — ще й demo-акаунти, див. Seed (код) |
db:seed:prod | node 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_ENVseed не залежить: без--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 не призначений. Варіанти:
Зареєструватися звичайним способом і підвищити роль вручну:
sqlUPDATE users SET role = 'admin' WHERE LOWER(email) = '<admin-email>';Разово виконати
pnpm db:seed:prod --demoз тимчасово заданимиSEED_ADMIN_EMAILіSEED_ADMIN_PASSWORD, а потім одразу прибрати їх з оточення.
Як змінити схему
- Змініть таблиці в
src/database/schema.ts; для нових зв'язків —relations.ts. db:generateстворить нову теку вsrc/drizzle/migrations; перевірте SQL вручну.db:migrateзастосує її до локальної БД.- Додайте методи у відповідний репозиторій (див. Архітектуру і Додавання фічі).
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.