Данные

База данных

Плагин хранит данные в 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Что значит
preferredTLS, если сервер его предлагает; по умолчанию
disabledникогда не TLS
requiredTLS или никакого соединения; сертификат сервера не проверяется
verify-caTLS с сертификатом, за который ручается доверенный центр
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()INTnumber
bigint()BIGINTnumber; bigint({ mode: "string" }) — string, все цифры целы
double()DOUBLEnumber
decimal(precision, scale)DECIMALnumber
varchar(length)VARCHAR(length)string
text()TEXTstring
boolean()BOOLEANboolean
datetime()DATETIMEstring, как его пишет MySQL: "2026-10-12 18:30:00"
json<T>()JSONT
binary(length)BINARY(length)Uint8Array, ровно length байт
varbinary(length)VARBINARY(length)Uint8Array, не больше length байт
blob(), mediumblob(), longblob()BLOB (до 64 КБ), MEDIUMBLOB (16 МБ), LONGBLOBUint8Array

Колонка может быть 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
NULLnull

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, в котором перечислены схемы, что есть.