Статус: техническое задание для сообщества / контрибьюторов. Цель: расширить набор поддерживаемых СУБД за пределами ядра
umbot, чтобы любой разработчик мог опубликовать свой адаптер в npm и подключить его одной строкой — без изменения исходников фреймворка.
Из коробки umbot поставляет два DB-адаптера: FileAdapter (JSON-файл, для разработки) и MongoAdapter (production). Для production-нагрузок сообществу часто нужны Redis, PostgreSQL, SQLite, MySQL, DynamoDB и т.д.
Вместо того чтобы раздувать ядро и тянуть в dependencies драйверы всех СУБД, правильная модель — внешние пакеты-адаптеры. Фреймворк уже спроектирован под это: слой БД полностью абстрагирован интерфейсами, а подключение происходит через bot.use().
Это ТЗ описывает, как написать такой внешний пакет, чтобы он корректно работал с umbot и был совместим с его системой метрик, жизненным циклом и типами.
BaseDbAdapterЭкспортируется из umbot/plugins:
import { BaseDbAdapter } from 'umbot/plugins';
Это абстрактный класс Base<TDbInfo extends IDatabaseInfo> (src/plugins/db/Base/Base.ts), реализующий IDatabaseAdapter. Он уже содержит:
AppContext (хранение подключения в appContext.database);EMetric.DB_SELECT/INSERT/UPDATE/REMOVE) — публичные методы select/insert/update/remove оборачивают ваши _select/_insert/_update/_remove;save() (insert-or-update) и selectOne();init, connect, destroy, close).Правило: наследуйтесь от BaseDbAdapter и переопределяйте только методы с подчёркиванием (_select, _insert, ...). Не переопределяйте публичные select/insert/update/remove — иначе сломаете метрики и переподключение.
| Метод | Сигнатура | Что делает |
|---|---|---|
connect |
(): Promise<boolean> | boolean |
Установить соединение. Вернуть true при успехе. |
isConnected |
(): Promise<boolean> | boolean |
Проверить, живо ли соединение (ping). |
_select |
(selectData: IQuery, where: IQueryData | null, isOne: boolean) => IModelRes | Promise<IModelRes> |
Поиск записей. |
_insert |
(insertData: IQuery) => boolean | Promise<boolean> |
Вставка. true/false. |
_update |
(updateData: IQuery) => boolean | Promise<boolean> |
Обновление. true/false. |
_remove |
(removeData: IQuery) => boolean | Promise<boolean> |
Удаление. true/false. |
destroy |
(): void | Promise<void> |
Закрыть пул/соединение при остановке. |
close |
(tableName: string) => void | Promise<void> |
Освободить ресурсы конкретной таблицы. |
Опционально:
| Метод | Когда переопределять |
|---|---|
_query |
Если хотите поддержать model.query(callback) — произвольный запрос. По умолчанию возвращает null. |
escapeString |
Для SQL-баз обязательно переопределить: базовая реализация просто приводит к строке. |
ensureSchema |
Для баз со схемой (SQL) обязательно: создать таблицы и индексы. См. раздел ниже. |
ensureSchemaФреймворк хранит данные в трёх таблицах — UsersData, ImageTokens, SoundTokens. Кто их создаёт:
FileAdapter, MongoDB) — сами: файл или коллекция появляются при первой записи.Для этого у адаптера есть метод ensureSchema(tables). Фреймворк вызывает его после каждого успешного
connect() и до первого запроса к базе (параллельные запросы ждут его завершения) и передаёт описание таблиц —
DB_TABLES_SCHEMA (экспортируется из umbot): имя таблицы, первичный ключ, uniqueKeys, поля с типами
(string с maxLength / text) и наборы полей, по которым нужны индексы. Базовая реализация ничего не делает и
возвращает true.
Требования:
CREATE TABLE IF NOT EXISTS,
CREATE INDEX IF NOT EXISTS);false или бросить исключение — фреймворк запишет ошибку в лог и продолжит работу;DB_TABLES_SCHEMA — добавляйте недостающие (ALTER TABLE ... ADD COLUMN), а не только создавайте таблицу.import { BaseDbAdapter } from 'umbot/plugins';
import type { IDbTableSchema } from 'umbot';
class PgAdapter extends BaseDbAdapter {
async ensureSchema(tables: readonly IDbTableSchema[]): Promise<boolean> {
for (const table of tables) {
const columns = Object.entries(table.fields).map(
([name, field]) =>
`"${name}" ${field.type === 'text' ? 'TEXT' : `VARCHAR(${field.maxLength ?? 255})`}`,
);
await this.#pool.query(
`CREATE TABLE IF NOT EXISTS "${table.tableName}" (${columns.join(', ')})`,
);
for (const fields of table.indexes) {
const name = `umbot_${table.tableName}_${fields.join('_')}`;
const list = fields.map((field) => `"${field}"`).join(', ');
await this.#pool.query(
`CREATE INDEX IF NOT EXISTS "${name}" ON "${table.tableName}" (${list})`,
);
}
}
return true;
}
}
MongoAdapter в ensureSchema создаёт индексы из indexes (коллекции MongoDB создаёт сама).
Вход — IQuery (что фреймворк передаёт в ваши методы):
{
tableName: 'UsersData', // имя таблицы/коллекции
primaryKeyName: 'userId', // первичный ключ (string | number | null)
query: { userId: '123', platform: 'telegram' }, // условия WHERE (может быть null)
uniqueKeys: ['platform'], // поля составного ключа (опционально, с 3.1.4)
data: { name: 'John' }, // данные для SET/INSERT (может быть null)
rules: [{ name: ['name'], type: 'string', max: 50 }] // правила валидации
}
uniqueKeys — составной ключ. Если значение первичного ключа уникально только в паре с другими полями,
модель передаёт эти поля в uniqueKeys и добавляет их в query для select/update/remove. Так устроена UsersData:
userId уникален в пределах платформы (пользователь Telegram 42 и пользователь VK 42 — разные люди), поэтому
запрос выглядит как { userId: '42', platform: 'telegram' }, а uniqueKeys: ['platform']. Адаптер, который
фильтрует по всем полям query (SQL WHERE, фильтр MongoDB), поддерживает это без изменений. Адаптер, который
ищет запись только по primaryKeyName (например, по ключу объекта, как FileAdapter), обязан учитывать и поля
uniqueKeys — иначе записи разных пользователей сольются. FileAdapter хранит такие строки под ключом
<platform>:<userId> и сам переносит строки прежнего формата (ключ — userId) при первом обращении.
Условия — IQueryData. Значения могут быть примитивами или объектами с операторами. Фреймворк не навязывает диалект — адаптер сам решает, как интерпретировать операторы ($gt, $in и т.д.):
{ userId: '123', platform: 'alisa' } // равенство
{ age: { $gt: 18 }, status: 'active' } // операторы
Рекомендуемый минимум операторов: $gt, $gte, $lt, $lte, $ne, $in.
Выход _select — IModelRes:
// успех: записи нашлись
{ status: true, data: [{ id: 1, name: 'Alice' }] }
// запись не найдена (пустая выборка)
{ status: false }
// ошибка
{ status: false, error: 'Connection timeout' }
Важно: status: true — только когда данные реально есть; при отсутствии записи возвращайте
{ status: false }. Так работают встроенные FileAdapter и MongoAdapter, а Model.save()
решает insert-vs-update по selectOne().status — ложный status: true на пустой выборке
сломает сохранение (update вместо insert).
При сбое обязательно заполняйте error: по нему ядро отличает «не найдено» от «БД не ответила» и во втором
случае не сохраняет userData запроса. Без error сбой выглядит как новый пользователь, и ядро вставит новую
запись.
Важно: не выбрасывайте исключения из _select/_insert/_update/_remove. Обрабатывайте ошибки внутри и возвращайте status: false / false.
Все нужные типы доступны из корня umbot и umbot/plugins:
import {
BaseDbAdapter, // базовый класс
} from 'umbot/plugins';
import type {
IQuery, // структура запроса
IQueryData, // условия/данные
IModelRes, // результат _select
IDbResult, // данные результата
IAppDB, // опции подключения (host/user/pass/database/options)
IDatabaseInfo, // что хранить в appContext.database.databaseInfo
AppContext, // контекст приложения
} from 'umbot';
umbot-<db>-adapter/
├── src/
│ └── index.ts # экспорт класса адаптера
├── tests/
│ └── adapter.test.ts # unit-тесты (jest)
├── package.json
├── tsconfig.json
└── README.md
{
"name": "umbot-<db>-adapter",
"version": "1.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist"],
"peerDependencies": {
"umbot": ">=3.1.0"
},
"dependencies": {
"<db-driver>": "^x.y.z"
}
}
Ключевые моменты:
umbot — peerDependency, а не dependency. Пользователь уже имеет umbot в проекте; адаптер не должен ставить свою копию.ioredis, pg, better-sqlite3, ...) — обычный dependency этого пакета. Так драйвер устанавливается только тем, кому адаптер реально нужен, и ядро umbot остаётся лёгким.dist (files: ["dist"]).umbot-<db>-adapter (например, umbot-redis-adapter, umbot-postgres-adapter).<Db>Adapter (например, RedisAdapter, PostgresAdapter).dbFormat: уникальный идентификатор формата, например 'redis', 'postgres'.import { BaseDbAdapter } from 'umbot/plugins';
import type { IQuery, IQueryData, IModelRes, IAppDB, IDatabaseInfo } from 'umbot';
// import драйвера вашей БД
interface IRedisDbInfo extends IDatabaseInfo {
client: unknown | null; // здесь храните живое подключение
}
export class RedisAdapter extends BaseDbAdapter<IRedisDbInfo> {
dbFormat = 'redis';
constructor(options?: IAppDB) {
super(options);
}
connect(): Promise<boolean> {
// 1. создать клиент из this._dbOptions (host/user/pass/database/options)
// 2. сохранить в this._appContext.database.databaseInfo
// (базовый класс уже создал пустой объект в init(); вы заполняете его
// своим клиентом/пулом — это конвенция, а не обязанность интеграции)
// 3. вернуть true при успехе, false при ошибке (не бросать)
return Promise.resolve(true);
}
isConnected(): boolean {
// ping / проверка статуса клиента
return Boolean(this._appContext.database.databaseInfo?.client);
}
async _select(
selectData: IQuery,
where: IQueryData | null,
isOne: boolean,
): Promise<IModelRes> {
try {
// транслировать where (с операторами) в запрос вашей БД
const rows: Record<string, unknown>[] = [];
if (!rows.length) {
return { status: false }; // не найдено — без error
}
return { status: true, data: isOne ? rows[0] : rows };
} catch (e) {
return { status: false, error: (e as Error).message };
}
}
async _insert(insertData: IQuery): Promise<boolean> {
try {
return true;
} catch {
return false;
}
}
async _update(updateData: IQuery): Promise<boolean> {
try {
return true;
} catch {
return false;
}
}
async _remove(removeData: IQuery): Promise<boolean> {
try {
return true;
} catch {
return false;
}
}
async destroy(): Promise<void> {
// закрыть клиент/пул, обнулить databaseInfo
}
close(_tableName: string): void {
// освободить ресурсы таблицы (если применимо)
}
}
import { Bot } from 'umbot';
import { TelegramAdapter } from 'umbot/plugins';
import { RedisAdapter } from 'umbot-redis-adapter';
const bot = new Bot()
.use(new TelegramAdapter())
.use(new RedisAdapter({ host: 'localhost', database: 'bot_db' }))
.setAppConfig({/* ... */});
⚠️ В приложении может быть активен только один DB-адаптер. При подключении второго
BaseDbAdapter.init()автоматически вызоветdestroy()у предыдущего.
_select/_insert/_update/_remove, connect, isConnected, destroy. Внешние соединения мокать (jest.fn() / jest.mock()), реальных запросов в тестах быть не должно. Ориентир — tests/DbModel/ в репозитории umbot.status: false / false.escapeString. Не конкатенировать пользовательские значения в запрос — использовать параметризованные запросы.this._appContext.logError/logWarn (они маскируют секреты).strict: true, без any (использовать unknown + сужение). JSDoc на русском для публичных методов.BaseDbAdapter, переопределены только _-методы.connect/isConnected/destroy/close реализованы и безопасны к повторному вызову.ensureSchema создаёт недостающие таблицы и индексы и безопасен к повторному вызову._select возвращает IModelRes (status: true только при найденных записях; отсутствие записи — status: false; ошибка — status: false с заполненным error).$gt/$gte/$lt/$lte/$ne/$in (минимум).umbot в peerDependencies, драйвер БД в dependencies.npm run build и npm run lint чистые.ensureSchema в serverless, ключ в UPDATE, типы условий).Выявлены при разработке umbot-knex-adapter и umbot-ydb-adapter.
ensureSchema на каждом холодном старте. Фреймворк вызывает метод после каждого connect(), а в serverless
(Cloud Functions) это каждый новый экземпляр функции. Сначала проверьте схему одним дешёвым запросом (например,
SELECT <все колонки> FROM <таблица> LIMIT 0) и выполняйте DDL, только если он упал.UPDATE. Model.update() и save() убирают из data только
primaryKeyName, поля uniqueKeys (platform у UsersData) остаются и в data, и в query. Если база не
разрешает менять колонки первичного ключа (YDB), уберите их из SET.getQueryData превращает числовые строки в числа (`userId`=123 → 123),
а userId хранится строкой. Строго типизированной базе нужно приводить значение к типу колонки, иначе запрос
упадёт на несовпадении типов.require() на Node.js ≥ 20.19
(минимальная версия umbot): соберите его с "module": "Node20" (TypeScript ≥ 5.9). Jest в режиме CommonJS такие
модули не загружает — подменяйте драйвер фабриками jest.mock().host и database в IAppDB обязательны. Если база умеет подключаться по переменным окружения, примите в
конструкторе свой тип параметров с необязательными полями и передайте в super() пустые строки.VIEW). Поиск
ImageTokens/SoundTokens идёт по (platform, path), а не по ключу — без индекса он читает таблицу целиком.| Адаптер | Пакет | Статус |
|---|---|---|
| PostgreSQL, MySQL, SQLite | umbot-knex-adapter | готов (через Knex.js) |
| YDB (Yandex Database) | umbot-ydb-adapter | готов |
| Redis | umbot-redis-adapter (ioredis) |
нужен (кэш/сессии) |
| DynamoDB | umbot-dynamodb-adapter (@aws-sdk/client-dynamodb) |
низкий приоритет |
Полный справочник — API v-3.1 · все версии.