This page is machine-translated from the Russian original. If something reads oddly, the Russian version is the source of truth — open an issue.
Status: a specification for the community / contributors. Goal: extend the set of supported DBMSs beyond the
umbotcore so that any developer can publish their own adapter to npm and connect it with one line — without changing the framework sources.
Out of the box, umbot ships two DB adapters: FileAdapter (a JSON file, for development) and MongoAdapter (production). For production loads the community often needs Redis, PostgreSQL, SQLite, MySQL, DynamoDB and so on.
Instead of bloating the core and pulling the drivers of every DBMS into dependencies, the right model is external adapter packages. The framework is already designed for this: the database layer is fully abstracted by interfaces, and connecting happens through bot.use().
This specification describes how to write such an external package so that it works correctly with umbot and is compatible with its metrics system, lifecycle and types.
BaseDbAdapter base classExported from umbot/plugins:
import { BaseDbAdapter } from 'umbot/plugins';
This is the abstract class Base<TDbInfo extends IDatabaseInfo> (src/plugins/db/Base/Base.ts) implementing IDatabaseAdapter. It already contains:
AppContext (storing the connection in appContext.database);EMetric.DB_SELECT/INSERT/UPDATE/REMOVE) — the public select/insert/update/remove methods wrap your _select/_insert/_update/_remove;save() (insert-or-update) and selectOne() logic;init, connect, destroy, close).Rule: extend BaseDbAdapter and override only the underscore methods (_select, _insert, ...). Do not override the public select/insert/update/remove — otherwise you break metrics and reconnection.
| Method | Signature | What it does |
|---|---|---|
connect |
(): Promise<boolean> | boolean |
Establish the connection. Return true on success. |
isConnected |
(): Promise<boolean> | boolean |
Check whether the connection is alive (ping). |
_select |
(selectData: IQuery, where: IQueryData | null, isOne: boolean) => IModelRes | Promise<IModelRes> |
Find records. |
_insert |
(insertData: IQuery) => boolean | Promise<boolean> |
Insert. true/false. |
_update |
(updateData: IQuery) => boolean | Promise<boolean> |
Update. true/false. |
_remove |
(removeData: IQuery) => boolean | Promise<boolean> |
Delete. true/false. |
destroy |
(): void | Promise<void> |
Close the pool/connection on shutdown. |
close |
(tableName: string) => void | Promise<void> |
Release the resources of a specific table. |
Optional:
| Method | When to override |
|---|---|
_query |
If you want to support model.query(callback) — an arbitrary query. Returns null by default. |
escapeString |
Must be overridden for SQL databases: the base implementation just converts the value to a string. |
ensureSchema |
Required for databases with a schema (SQL): create tables and indexes. See the section below. |
ensureSchemaThe framework stores data in three tables — UsersData, ImageTokens, SoundTokens. Who creates them:
FileAdapter, MongoDB) — themselves: the file or collection appears on the first write.For this the adapter has an ensureSchema(tables) method. The framework calls it after every successful
connect() and before the first query to the database (concurrent queries wait for it to finish) and passes the table description —
DB_TABLES_SCHEMA (exported from umbot): the table name, the primary key, uniqueKeys, typed fields
(string with maxLength / text) and the field sets that need indexes. The base implementation does nothing and
returns true.
Requirements:
CREATE TABLE IF NOT EXISTS,
CREATE INDEX IF NOT EXISTS);false or throw an exception — the framework logs the error and keeps working;DB_TABLES_SCHEMA — add the missing ones (ALTER TABLE ... ADD COLUMN), not just create the table.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 creates the indexes from indexes in ensureSchema (MongoDB creates the collections itself).
Input — IQuery (what the framework passes to your methods):
{
tableName: 'UsersData', // table/collection name
primaryKeyName: 'userId', // primary key (string | number | null)
query: { userId: '123', platform: 'telegram' }, // WHERE conditions (can be null)
uniqueKeys: ['platform'], // composite key fields (optional, since 3.1.4)
data: { name: 'John' }, // data for SET/INSERT (can be null)
rules: [{ name: ['name'], type: 'string', max: 50 }] // validation rules
}
uniqueKeys — a composite key. If the primary key value is unique only together with other fields,
the model passes these fields in uniqueKeys and adds them to query for select/update/remove. This is how UsersData works:
userId is unique within a platform (Telegram user 42 and VK user 42 are different people), so
the query looks like { userId: '42', platform: 'telegram' } with uniqueKeys: ['platform']. An adapter that
filters by all query fields (SQL WHERE, a MongoDB filter) supports this without changes. An adapter that
looks up a record only by primaryKeyName (for example, by an object key, like FileAdapter) must also take the
uniqueKeys fields into account — otherwise records of different users get merged. FileAdapter stores such rows under the
<platform>:<userId> key and migrates rows of the old format (keyed by userId) on first access.
Conditions — IQueryData. Values can be primitives or objects with operators. The framework does not impose a dialect — the adapter decides how to interpret the operators ($gt, $in, etc.):
{ userId: '123', platform: 'alisa' } // equality
{ age: { $gt: 18 }, status: 'active' } // operators
The recommended minimum set of operators: $gt, $gte, $lt, $lte, $ne, $in.
_select output — IModelRes:
// success: records found
{ status: true, data: [{ id: 1, name: 'Alice' }] }
// record not found (empty result)
{ status: false }
// error
{ status: false, error: 'Connection timeout' }
Important: status: true only when there really is data; when there is no record, return
{ status: false }. This is how the built-in FileAdapter and MongoAdapter work, and Model.save()
chooses insert vs update by selectOne().status — a false status: true on an empty result
breaks saving (update instead of insert).
On failure always fill in error: this is how the core tells "not found" from "the database did not respond", and in the second
case it does not save the request's userData. Without error, a failure looks like a new user, and the core inserts a new
record.
Important: do not throw exceptions from _select/_insert/_update/_remove. Handle errors inside and return status: false / false.
All the types you need are available from the umbot root and umbot/plugins:
import {
BaseDbAdapter, // base class
} from 'umbot/plugins';
import type {
IQuery, // query structure
IQueryData, // conditions/data
IModelRes, // _select result
IDbResult, // result data
IAppDB, // connection options (host/user/pass/database/options)
IDatabaseInfo, // what to store in appContext.database.databaseInfo
AppContext, // application context
} from 'umbot';
umbot-<db>-adapter/
├── src/
│ └── index.ts # adapter class export
├── tests/
│ └── adapter.test.ts # unit tests (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"
}
}
Key points:
umbot is a peerDependency, not a dependency. The user already has umbot in the project; the adapter must not install its own copy.ioredis, pg, better-sqlite3, ...) is a regular dependency of this package. This way the driver is installed only by those who actually need the adapter, and the umbot core stays lightweight.dist (files: ["dist"]).umbot-<db>-adapter (for example, umbot-redis-adapter, umbot-postgres-adapter).<Db>Adapter (for example, RedisAdapter, PostgresAdapter).dbFormat field: a unique format identifier, for example 'redis', 'postgres'.import { BaseDbAdapter } from 'umbot/plugins';
import type { IQuery, IQueryData, IModelRes, IAppDB, IDatabaseInfo } from 'umbot';
// import your database driver
interface IRedisDbInfo extends IDatabaseInfo {
client: unknown | null; // keep the live connection here
}
export class RedisAdapter extends BaseDbAdapter<IRedisDbInfo> {
dbFormat = 'redis';
constructor(options?: IAppDB) {
super(options);
}
connect(): Promise<boolean> {
// 1. create a client from this._dbOptions (host/user/pass/database/options)
// 2. save it to this._appContext.database.databaseInfo
// (the base class has already created an empty object in init(); you fill it
// with your client/pool — this is a convention, not an integration requirement)
// 3. return true on success, false on error (do not throw)
return Promise.resolve(true);
}
isConnected(): boolean {
// ping / check the client status
return Boolean(this._appContext.database.databaseInfo?.client);
}
async _select(
selectData: IQuery,
where: IQueryData | null,
isOne: boolean,
): Promise<IModelRes> {
try {
// translate where (with operators) into a query for your database
const rows: Record<string, unknown>[] = [];
if (!rows.length) {
return { status: false }; // not found — without 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> {
// close the client/pool, reset databaseInfo
}
close(_tableName: string): void {
// release the table's resources (if applicable)
}
}
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({/* ... */});
⚠️ Only one DB adapter can be active in an application. When a second one is connected,
BaseDbAdapter.init()automatically callsdestroy()on the previous one.
_select/_insert/_update/_remove, connect, isConnected, destroy. Mock external connections (jest.fn() / jest.mock()); tests must make no real queries. Use tests/DbModel/ in the umbot repository as a reference.status: false / false.escapeString for SQL databases. Do not concatenate user values into a query — use parameterized queries.this._appContext.logError/logWarn (they mask secrets).strict: true, no any (use unknown + narrowing). JSDoc in Russian for public methods.BaseDbAdapter, only the _ methods are overridden.connect/isConnected/destroy/close are implemented and safe to call repeatedly.ensureSchema creates the missing tables and indexes and is safe to call repeatedly._select returns IModelRes (status: true only when records are found; no record — status: false; error — status: false with error filled in).$gt/$gte/$lt/$lte/$ne/$in operators are supported (minimum).umbot in peerDependencies, the database driver in dependencies.npm run build and npm run lint are clean.ensureSchema in serverless, the key in UPDATE, condition types).Found while developing umbot-knex-adapter and umbot-ydb-adapter.
ensureSchema on every cold start. The framework calls the method after every connect(), and in serverless
(Cloud Functions) that means every new function instance. Check the schema first with one cheap query (for example,
SELECT <all columns> FROM <table> LIMIT 0) and run DDL only if it fails.UPDATE data. Model.update() and save() remove only
primaryKeyName from data; the uniqueKeys fields (platform in UsersData) stay in both data and query. If the database
does not allow changing primary key columns (YDB), remove them from SET.getQueryData turns numeric strings into numbers (`userId`=123 → 123),
while userId is stored as a string. A strictly typed database has to cast the value to the column type, otherwise the query
fails on a type mismatch.require() on Node.js ≥ 20.19
(the minimum umbot version): build it with "module": "Node20" (TypeScript ≥ 5.9). Jest in CommonJS mode cannot load such
modules — replace the driver with jest.mock() factories.host and database in IAppDB are required. If the database can connect using environment variables, accept
your own parameter type with optional fields in the constructor and pass empty strings to super().VIEW). The lookup of
ImageTokens/SoundTokens goes by (platform, path), not by the key — without an index it reads the whole table.| Adapter | Package | Status |
|---|---|---|
| PostgreSQL, MySQL, SQLite | umbot-knex-adapter | ready (via Knex.js) |
| YDB (Yandex Database) | umbot-ydb-adapter | ready |
| Redis | umbot-redis-adapter (ioredis) |
wanted (cache/sessions) |
| DynamoDB | umbot-dynamodb-adapter (@aws-sdk/client-dynamodb) |
low priority |
Full reference — API v-3.1 · all versions.