Репозиторії
Репозиторій — це шар доступу до даних: єдине місце, де (за задумом) пишеться SQL через Drizzle. Кожен репозиторій відповідає за одну таблицю чи тісно пов'язану групу таблиць і надає сервісам набір іменованих операцій — findById, findByEmail, create, markUsed — замість сирих запитів.
Навіщо потрібен цей шар
- SQL в одному місці. Сервіс говорить мовою домену («знайди користувача за email»), а не мовою запитів. Коли змінюється схема, індекс чи спосіб пошуку (наприклад,
LOWER(email)), правиться один метод репозиторію, а не всі сервіси. - Типи з схеми. Типи рядків (
UserRow,UserInsert) виводяться з таблиць Drizzle черезInferSelectModel/InferInsertModel, тому зміна колонки одразу підсвічує всі місця використання. - Тестованість сервісів. Сервіси імпортують репозиторії лише через barrel
#app/repositories/index.js, тож у тестах його легко замінитиvi.mockі перевіряти бізнес-логіку без PostgreSQL. - Чітка відповідальність. Репозиторій не приймає бізнес-рішень, не логує і не загортає помилки в
Result— він лише читає й пише дані. Рішення «що робити, якщо запису немає» приймає сервіс. - Оптимізація запитів у одному місці. Пагінація, агрегати в SQL, пакетне підтягування пов'язаних даних без N+1, pgvector-пошук — усе це деталі реалізації, сховані за простим методом.
Про схему БД і міграції — База даних.
Структура
Репозиторії лежать у app/repositories/, згруповані за доменами: ai/, auth/, communication/, health/, notifications/, support/, user/. Кожен модуль експортує об'єкт-літерал (не клас) з async-методами, часто типізований інтерфейсом, наприклад export const userRepository: IUserRepository = { ... } у user-repository.ts.
Barrel repositories/index.ts реекспортує всі репозиторії і їх типи (UserRow, PromptType, KbArticleRow, …). Поза app/repositories імпорт іде лише через нього; це й повноту barrel перевіряє layering.spec.ts.
Доступ до БД
Кожен репозиторій імпортує єдиний інстанс Drizzle і таблиці зі схеми:
import { db } from '#database/db.js'
import { users } from '#database/schema.js'
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
export type UserRow = InferSelectModel<typeof users>
export type UserInsert = InferInsertModel<typeof users>Типи рядків виводяться з таблиць через InferSelectModel / InferInsertModel, а типи оновлень — через Partial<Pick<...>> (user-repository.ts).
Транзакції
Кілька записів, які мають відбутися разом або не відбутися взагалі, сервіс виконує через withTransaction (transaction.ts): вона відкриває транзакцію Drizzle, комітить, коли колбек повернувся, і відкочує, коли він кинув помилку. Репозиторні методи, які можуть входити в таку транзакцію, приймають останнім необовʼязковим аргументом executor (DbExecutor: db або tx); без нього працюють на db, як і раніше. Зараз так працюють userRepository.update і markUsed трьох репозиторіїв токенів.
// claim + дія в одній транзакції (one-time-token.ts)
const applied = await consumeTokenAndApply(
passwordResetRepository,
token,
(tx) => userRepository.update(token.userId, { password, sessionToken }, tx),
'Failed to update password',
)
// applied: { claimed: false } або { claimed: true, value: <оновлений користувач> }Типові методи та повернені значення
create—db.insert(...).values(...).returning(); якщо рядок не повернувся, кидаєтьсяError("Failed to create user"). ПоляcreatedAt/updatedAtвиставляє сам репозиторій.findBy*—select().from().where().limit(1).then((rows) => rows.at(0)); відсутність запису — цеundefined, а неnullі не виняток.update/delete— повертають оновлений рядок (UserRow | undefined) абоboolean.- Репозиторії повертають сирі рядки Drizzle (
bigintid,Date) або вузькі проєкції (AdminUserListRowзselect({ id, name, ... })). Вони не повертаютьResult— обгортання помилок робить сервіс, наприкладtryInternal(() => userRepository.findById(...))у main-service.ts.
Патерни запитів
| Патерн | Приклад |
|---|---|
Динамічний WHERE з масиву SQL[] + and(...) | listAdminUsers у user-repository.ts, buildHistoryWhereClause у user-online-repository.ts |
Пагінація limit/offset | findText у llm-usage-repository.ts, findAll у support-knowledge-repository.ts |
Затиснутий limit (1..100) + паралельний count() через Promise.all | listAdminUsers, listHistory |
Фрагменти sql | регістронезалежний пошук LOWER(email), атомарний інкремент attemptCount + 1 у sms-auth-challenge-repository.ts, count(*) у getTextStats |
| pgvector | setEmbedding і clearEmbedding — звичайний update().set() (вектор як літерал […], NULL разом з updatedAt одним запитом); searchByEmbedding — db.execute з оператором <=> і ::vector, колонки й таблиця підставляються з об'єктів схеми, а не рядками; у support-knowledge-repository.ts |
Joins, upsert (onConflict*) і транзакції в репозиторіях не використовуються. Пов'язані дані підтягуються окремим запитом на всю вибірку, а не на кожен рядок: findByUserIdWithLogs у push-subscription-repository.ts бере підписки, а потім одним запитом до 5 останніх логів кожної через row_number() over (partition by subscription_id order by sent_at desc, id desc) (push-notification-log-repository.ts). Агрегати рахуються в SQL (count(case …)), а не в JS.
Фрагмент pgvector-пошуку:
sql`
SELECT id, title, content, screenshot_key, screenshot_mime,
embedding <=> ${vecStr}::vector AS distance
FROM support_knowledge_base
WHERE is_active = true AND embedding IS NOT NULL
ORDER BY distance ASC
LIMIT ${limit}
`Результат db.execute вручну мапиться в camelCase і нормалізує id/distance, які можуть прийти рядками. Для роботи потрібне розширення pgvector, див. База даних → pgvector.
Redis
Redis-репозиторіїв немає: у app/repositories/ усі модулі працюють лише з PostgreSQL. Redis використовується напряму з сервісів і WS-шару — auth-throttle-service.ts (Lua INCR+EXPIRE, ключ auth:throttle:*), generate-ws-token-service.ts (setex), ws-session-auth.ts (auth:ws:*).
Зведена таблиця
| Репозиторій | Таблиця(і) | Ключові методи |
|---|---|---|
userRepository | users | create, findById, findByEmail, findByPhone, findBySessionToken, listAdminUsers, emailExistsForOtherUser, update, delete |
oauthAccountRepository | oauthAccounts | findByProviderAndProviderId, findByUserId, create |
emailVerificationRepository | emailVerifications | create, findByTokenHash, invalidatePendingByUserId, markUsed |
emailLinkVerificationRepository | emailLinkVerifications | те саме |
passwordResetRepository | passwordResetTokens | те саме |
smsAuthChallengeRepository | smsAuthChallenges | findLatestPendingForPhoneFlow, markIssued, incrementAttemptCount, markConfirmed, markInvalidated |
userOnlineRepository | userOnline | create, findBySocketUuid, completeBySocketUuid, listHistory, countHistory |
pushSubscriptionRepository | pushSubscriptions, pushNotificationLogs | findByEndpoint, findByUserIdWithLogs, update, touchLastUsed, deactivate, delete, getLogsBySubscriptionId |
pushNotificationLogRepository | pushNotificationLogs | insertMany, countsBySubscription, latestBySubscriptionIds |
llmUsageRepository | llmTextUsage | insertText, findText, countText, getTextStats |
promptRepository | llmSystemPrompts | findAll, findByType, update |
supportRepository | supportChatHistory | addChatMessage, addUserMessageOnce, findChatHistory, deleteAllChatHistory |
supportKnowledgeRepository | supportKnowledgeBase | findAll, create, update, setScreenshot, setEmbedding, searchByEmbedding |
healthRepository | — | pingDatabase (SELECT 1 для health-check) |
Правила
- У репозиторіях — лише доступ до даних: побудова запитів, проставлення
createdAt/updatedAt. Бізнес-рішення (перевірка сесії, коди помилок) — у сервісах. - Одноразовий токен (скидання пароля, підтвердження пошти, привʼязка пошти) споживається атомарно:
markUsed— цеUPDATE … SET used_at = … WHERE id = ? AND used_at IS NULL RETURNING *, тож із двох паралельних запитів з одним токеном рядок отримує лише один (інший —undefined). Сервіс виконує цей claim і дію токена (зміна пароля, підтвердження, нова пошта) в однійwithTransaction: збій після запису відкочує обидва, посилання лишається придатним, а пароль — незміненим. Три репозиторії токенів — один і той самийcreateTokenRepositoryнад трьома таблицями (їх не зливали: міграція без виграшу), а спільна частина сервісів — випуск токена, пошук придатного й claim разом із дією — уone-time-token.ts(issueToken,findUsableToken,consumeTokenAndApply). - Репозиторії не логують:
completeBySocketUuidповертаєfalse, а попередження «no record found on disconnect» пишеuserOnlineService. - Репозиторії викликаються лише з сервісів; контролери, middleware і WS-координатор ідуть через сервіси.
Тестування
Окремих spec-файлів для репозиторіїв немає. Їх мокають у тестах сервісів через vi.mock barrel-модуля, наприклад у push-subscription-service.spec.ts:
vi.mock('#app/repositories/index.js', () => ({
pushSubscriptionRepository: pushSubscriptionRepositoryMock,
}))Так само мокаються репозиторії в auth-service.spec.ts, main-service.spec.ts, support-service.spec.ts, knowledge-base-service.spec.ts та інших. Оскільки сервіси, що звертаються до db напряму, обходять цей мок, для них доведеться мокати #database/db.js.
Суміжні шари
- Контролери — HTTP/WS-адаптери: приймають запит і віддають відповідь.
- Сервіси — бізнес-логіка, повертають
AppResult<T>. - Репозиторії — єдине місце, де пишеться SQL.
- Трансформери — перетворення рядків БД на публічні DTO.
Загальна схема — Архітектура, покроковий приклад — Додавання фічі.