База данных
Плагин хранит данные в MySQL или MariaDB через Database с v0.3.1 —
глобальный класс, без строки импорта. Таблица описывается один раз, а
запросы к ней пишутся на TypeScript:
import { asc, double, eq, int, table, varchar } from "@amxts/core/database";
export const records = table("records", {
id: int().primaryKey().autoincrement(),
steamId: varchar(32).notNull(),
map: varchar(64).notNull(),
time: double().notNull(),
});
const db = new Database(env("MYPLUGIN_DB", ""));
server.addCommand("/top", async ({ player }) => {
const top = await db.select().from(records).where(eq(records.map, server.map)).orderBy(asc(records.time)).limit(10);
top.forEach((record, i) => player.print(`${i + 1}. ${record.steamId} ${record.time.toFixed(2)}`));
});
Запрос идёт в фоне. Игра его не ждёт: результат приходит на одном из следующих кадров сервера, никогда — внутри вызова, который отправил запрос.
Адрес
new Database(url) принимает адрес базы:
mysql://user:password@host:3306/database?ssl-mode=preferred
Без порта берётся 3306. Пользователь или пароль с @, : или / пишется
через %40, %3A, %2F. Адрес с jdbc: впереди, как его показывает
страница баз данных игровой панели (jdbc:mysql://...), подходит как есть.
Держите адрес вне кода: читайте его через
env из
addons/amxts/.env сервера, чтобы пароль оставался на сервере:
MYPLUGIN_DB=mysql://myplugin:s3cret@127.0.0.1:3306/myplugin
ssl-mode говорит, шифруется ли соединение, — названия те же, что у
клиентов самого MySQL:
ssl-mode | Что значит |
|---|---|
preferred | TLS, если сервер его предлагает; по умолчанию |
disabled | никогда не TLS |
required | TLS или никакого соединения; сертификат сервера не проверяется |
verify-ca | TLS с сертификатом, за который ручается доверенный центр |
verify-identity | как verify-ca, и сертификат выдан на хост из адреса |
Входят и пользователи MySQL 8 (caching_sha2_password), и старые
(mysql_native_password), с TLS и без.
Таблицы
table(name, columns) из @amxts/core/database описывает таблицу базы:
каждое поле — колонка, её построитель говорит, что в ней лежит, а тип строки
выводится из колонок — интерфейс рядом держать не нужно. Опечатка в колонке —
ошибка в редакторе.
import { boolean, int, json, table, text, unique, varchar } from "@amxts/core/database";
interface Settings {
sound: boolean;
}
export const players = table("players", {
id: int().primaryKey().autoincrement(),
steamId: varchar(32).notNull().unique(),
name: varchar(64).notNull(),
points: int().notNull().default(0),
vip: boolean(),
title: text(),
settings: json<Settings>(),
});
type PlayerRow = typeof players.$inferSelect; // { id: number; steamId: string; ...; vip: boolean | null; ... }
type NewPlayer = typeof players.$inferInsert; // steamId и name обязательны, остальное можно не давать
| Построитель | Колонка | Поле |
|---|---|---|
int() | INT | number |
bigint() | BIGINT | number; bigint({ mode: "string" }) — string, все цифры целы |
double() | DOUBLE | number |
decimal(precision, scale) | DECIMAL | number |
varchar(length) | VARCHAR(length) | string |
text() | TEXT | string |
boolean() | BOOLEAN | boolean |
datetime() | DATETIME | string, как его пишет MySQL: "2026-10-12 18:30:00" |
json<T>() | JSON | T |
binary(length) | BINARY(length) | Uint8Array, ровно length байт |
varbinary(length) | VARBINARY(length) | Uint8Array, не больше length байт |
blob(), mediumblob(), longblob() | BLOB (до 64 КБ), MEDIUMBLOB (16 МБ), LONGBLOB | Uint8Array |
Колонка может быть null, если она не notNull(), — тогда её поле
T | null. Дальше, в любом порядке:
primaryKey()— первичный ключ таблицы, заодноNOT NULL;autoincrement()— уint()иbigint(): новые строки нумерует база;notNull(),unique();default(value)— значение для строки, в которой вставка эту колонку не дала; у колонки байтов умолчание толькоnull.
Вставка обязана дать колонки notNull() без умолчания и без
autoincrement(); остальные можно не давать.
В базе колонка называется в snake case — steamId это steam_id. Колонке с
другим именем оно передаётся первым: int("PlayerID"), varchar("Name", 32).
Ключ из нескольких колонок — третий аргумент:
import { double, index, table, unique, varchar } from "@amxts/core/database";
export const records = table("records", {
steamId: varchar(32).notNull(),
map: varchar(64).notNull(),
time: double().notNull(),
}, (t) => [unique().on(t.steamId, t.map), index().on(t.map, t.time)]);
await db.create(players) создаёт таблицу с её ключами, если в базе её ещё
нет (CREATE TABLE IF NOT EXISTS), — достаточно один раз при запуске
плагина. Существующую таблицу он не меняет.
Чтение строк
db.select().from(table) читает строки со всеми колонками. Дальше запрос
можно сузить — всё необязательно и в любом порядке:
const top = await db.select().from(records)
.where(eq(records.map, server.map))
.orderBy(asc(records.time))
.limit(10)
.offset(20);
where(condition)— только строки, для которых выполняется условие;orderBy(...)— по колонкам, сначала наименьшие, илиasc(column)иdesc(column);limit(n)иoffset(n)— не большеnстрок, пропустив сначалаn;groupBy(...columns)— одна строка на каждое значение колонок.
db.select({ ... }) читает выбранные колонки — под именами, которые даёт им
строка, — и агрегаты по ним:
import { count, max, min } from "@amxts/core/database";
const maps = await db.select({ map: records.map, runs: count(), best: min(records.time) })
.from(records)
.groupBy(records.map);
// maps[0].map: string, maps[0].runs: number, maps[0].best: number | null
count() считает строки, count(column) — строки, где в колонке есть
значение; sum, avg, min и max колонки равны null, если строк для
них нет.
Условия
| Условие | SQL |
|---|---|
eq(column, value), ne(...) | =, <> |
gt, gte, lt, lte | >, >=, <, <= |
like(column, "kz_%") | LIKE |
inArray(column, [1, 2, 3]) | IN (...) — при пустом списке никогда |
between(column, low, high) | BETWEEN |
isNull(column), isNotNull(column) | IS NULL, IS NOT NULL |
and(...), or(...), not(condition) | AND, OR, NOT |
Значение проверяется по типу колонки; на его месте может стоять другая
колонка: lt(records.time, records.best).
Изменение строк
const { insertId } = await db.insert(records).values({ steamId: player.steamId, map: server.map, time });
await db.insert(records).values([first, second]); // несколько строк сразу
const { affectedRows } = await db.update(records)
.set({ time, title: null })
.where(and(eq(records.steamId, player.steamId), eq(records.map, server.map)));
await db.delete(records).where(eq(records.id, id));
Каждый отдаёт { insertId, affectedRows }: AUTO_INCREMENT-номер
вставленной строки, 0, если вставки не было, и сколько строк запрос
вставил, изменил или удалил, а для изменения — сколько подошло под условие.
set меняет только названные колонки; null делает колонку NULL.
Изменение или удаление без where касается всех строк.
Строка, чей уникальный ключ уже занят, — upsert:
await db.insert(records)
.values({ steamId: player.steamId, map: server.map, time })
.onConflictDoUpdate({ target: [records.steamId, records.map], set: { time } });
await db.insert(players).values({ steamId: player.steamId, name: player.name }).onConflictDoNothing({ target: players.steamId });
target — колонка или колонки уникального ключа; set — что изменить в
той строке.
- Запрос уходит, когда его ждут —
await,.then(...),Promise.all([...]). Запрос, которого никто не ждёт, не отправляется. - Каждое значение —
?, а не часть текста SQL: база получает значения отдельно от запроса, так что имя с кавычкой — просто имя. Единственное место, где значения вписываются в текст, — умолчанияdefault(...)вcreate: это значения из вашего кода.
SQL как есть
db.query<T>(sql, params) и db.execute(sql, params) выполняют SQL так, как
он написан, рядом с построителем — для того, чего построитель не говорит:
join'ов, функций, своих запросов. Каждый ? берёт следующее значение из
params:
interface Rank {
place: number;
}
const found = await db.query<Rank>("SELECT COUNT(*) + 1 AS place FROM records WHERE map = ? AND time < ?", [server.map, time]);
const { insertId } = await db.execute("INSERT INTO records (steam_id, map, time) VALUES (?, ?, ?)", [player.steamId, server.map, time]);
query отдаёт строки как T[]: T — интерфейс колонок, объявленный рядом с
вашим кодом. Колонка, которой нет в интерфейсе, пропускается. Поле, которого
нет в строке, сохраняет значение по умолчанию из интерфейса, остаётся
undefined, если оно необязательное, а иначе это TypeError с его именем;
колонка со значением NULL даёт null. Значение — текст, число, логическое,
Uint8Array или null, и в одном списке могут быть все сразу:
[player.name, time, true, null].
Во что превращается колонка:
| Колонка | Значение |
|---|---|
INT, BIGINT, TINYINT, FLOAT, DOUBLE, DECIMAL, YEAR, BIT | число |
CHAR, VARCHAR, TEXT, ENUM, SET, JSON | текст |
DATE, DATETIME, TIMESTAMP, TIME | текст, как его пишет MySQL: "2026-10-12 18:30:00" |
BINARY, VARBINARY, BLOB всех размеров | Uint8Array |
NULL | null |
execute отдаёт { insertId, affectedRows }, как изменения построителя.
string, — её байты текстом base64.
Текст, который хранится в бинарной колонке, читайте через
CAST(column AS CHAR) или читайте колонку в Uint8Array.Байты
Файл, запись забега, картинка хранятся целиком в колонке байтов —
longblob() в таблице, поле Uint8Array в SQL как есть — и возвращаются
байт в байт:
import * as fs from "@amxts/core/fs";
import { eq, int, longblob, table, varchar } from "@amxts/core/database";
export const recordings = table("recordings", {
id: int().primaryKey().autoincrement(),
map: varchar(64).notNull(),
frames: longblob().notNull(),
});
async function keep(path: string): Promise<number> {
const frames = await fs.readBytes(path);
const { insertId } = await db.insert(recordings).values({ map: server.map, frames });
return insertId;
}
async function restore(id: number, path: string) {
const found = await db.select().from(recordings).where(eq(recordings.id, id));
if (found.length > 0) fs.writeFileSync(path, found[0].frames);
}
Uint8Array — тоже значение запроса: eq(recordings.frames, bytes),
[map, bytes] в SQL как есть, — и уходит на сервер байтами.
- Значение ограничено
max_allowed_packetсервера: MySQL принимает в одном запросе 64 МБ, MariaDB — 16 МБ, если конфигурация не разрешает больше. - В результате байты идут внутри как base64, на треть больше, чем они есть: в результат в 64 МБ помещается около 48 МБ байтов.
Ошибки
Неудачный запрос отклоняет промис с DatabaseError: message — текст
сервера, code — номер ошибки (1062 — повтор ключа), sqlState — её
SQLSTATE ("23000"). Если запрос не дошёл до сервера — нет соединения,
неверный адрес, — code равен 0, а message объясняет почему.
try {
await db.insert(players).values({ steamId: player.steamId, name: player.name });
} catch (error) {
if ((error as DatabaseError).code == 1062) return; // уже есть
console.error(`не удалось сохранить ${player.name}: ${error.message}`);
}
Транзакции
db.transaction(fn) выполняет fn с транзакцией tx: то, что fn
отправила через tx — tx.select(), tx.insert(), tx.query() и
остальное, — фиксируется, когда fn вернулась, и откатывается, когда она
бросила исключение, — ошибка уходит дальше вызывающему. Промис отдаёт то, что
вернула fn:
const runId = await db.transaction(async (tx) => {
const { insertId } = await tx.insert(runs).values({ steamId: player.steamId, time });
await tx.execute("UPDATE players SET runs = runs + 1 WHERE steam_id = ?", [player.steamId]);
return insertId;
});
У транзакции, пока она идёт, своё соединение; на один адрес одновременно идут до четырёх, пятая ждёт, пока одна из них закончится.
- Одно соединение на адрес. Все плагины, открывшие один адрес, делят
одно соединение, и их запросы выполняются по одному в порядке отправки;
соединение остаётся открытым от карты к карте. Соединение, закрытое
сервером, открывается заново для следующего запроса. Запрос, во время
которого соединение оборвалось, завершается ошибкой и не отправляется
повторно:
INSERT, который сервер мог выполнить, не выполнится дважды. - Запрос может идти минуту. Если сервер молчит 60 секунд посреди запроса, запрос завершается ошибкой.
- Результат читается целиком, до 64 МБ: запрашивайте нужные строки.
TINYINT(1)в SQL как есть — число,0или1, так MySQL хранитBOOLEAN: читайте его в полеnumber. Колонка таблицыboolean()читает его какtrueилиfalse.- Целое больше 2^53 (
9007199254740993, 64-битный id) теряет последние цифры вnumber: колонкаbigint({ mode: "string" })их сохраняет, а в SQL как есть выбирайте его текстом,CAST(id AS CHAR), в полеstring.
Другие базы
Схема адреса выбирает, кто на него отвечает. mysql:// встроен; другая
база — файл, веб-сервис, нативы Pawn-модуля — это провайдер, который
библиотека или сам плагин регистрирует через Database.register, и плагин
открывает её адреса так же, как адреса MySQL, — таблицы и запросы пишутся
одинаково для любой базы.
Провайдер — функция, которая даёт соединение для адреса:
import { SqlDialect } from "@amxts/core/database";
class SqliteDialect extends SqlDialect {
get name(): string {
return "sqlite";
}
}
const sqlite = new SqliteDialect();
class LogConnection implements DatabaseConnection {
constructor(public url: string) {}
get dialect(): SqlDialect {
return sqlite;
}
query(sql: string, params: DatabaseValue[]): Promise<string> {
return Promise.resolve<string>("[]"); // строки как JSON-текст: [{"name":"a","time":1.5}]
}
execute(sql: string, params: DatabaseValue[]): Promise<ExecuteResult> {
console.log(`${sql} ${JSON.stringify(params)}`);
return Promise.resolve<ExecuteResult>({ insertId: 0, affectedRows: 0 });
}
begin(): Promise<DatabaseTransaction> {
return Promise.reject<DatabaseTransaction>(new DatabaseError("у лога нет транзакций"));
}
close(): void {}
}
Database.register("log", (url: string): DatabaseConnection => new LogConnection(url));
const log = new Database("log://anything");
dialect— как база пишет SQL; на нём построитель пишет свои запросы.SqlDialectпишет стандартный SQL — имена в"...", значения как?,ON CONFLICTдля upsert; диалект переопределяет то, что его база пишет иначе (quote,placeholder—$1для PostgreSQL, —onConflict,limit,columnTypeдляcreate), а то, чего база не умеет, отклоняет черезthis.unsupported(what, instead)— с этой ошибкой и отклоняется запрос.mysqlиз@amxts/core/database— диалект MySQL.queryотдаёт строки JSON-текстом — массивом объектов, значение JSON-колонки — текстом, байты — текстом base64, какJSON.stringifyпишетUint8Array.executeотдаёт{ insertId, affectedRows }.beginотдаётDatabaseTransaction—dialect,query,execute,commit()иrollback()— или отклоняется, если транзакций у базы нет.- Соединение выполняет запросы в том порядке, в каком их получило.
JSON.stringify(params) пишет значения JSON-массивом — готовым телом
запроса к веб-сервису.
Провайдер из библиотеки регистрирует себя, когда библиотеку импортируют, так что плагин, который им пользуется, пишет одну строку:
import "@myorg/sqlite";
const db = new Database("sqlite://data/myplugin.db");
Адрес, для схемы которого провайдера нет, отклоняет каждый запрос с
DatabaseError, в котором перечислены схемы, что есть.
HTTP
Плагин обращается к веб-сервисам через две функции, обе глобальные — без строки импорта:
Общие модули
Модуль — пакет на TypeScript, которым пользуются плагины: официальные menu-core и config-core или ваш собственный. У модуля один экземпляр на сервер, общий для всех плагинов, которые им пользуются: