rp_mysql()

O driver de banco do servidor. Um pool próprio sobre o mysql2, com uma ponte para o Lua e uma tomada de compatibilidade para o ESX. É a raiz da árvore de resources: tudo depende dele, ele não depende de nada.

Resource
[base]/rp_mysql
Runtime
node 22 (FXServer)
Dependências
nenhuma
Conexões
pool de 12

O que ele é

O rp_mysql substituiu o oxmysql e foi o último resource de terceiros a sair do servidor. Ele faz duas coisas: mantém um pool de conexões com o MariaDB e oferece uma ponte para o Lua chamar SQL.

O que continua vindo de fora é uma biblioteca, o mysql2 do npm, e essa distinção é o coração do projeto. A camada que o mysql2 resolve é o protocolo do MySQL na fiação: handshake, autenticação caching_sha2, tipos binários, prepared statements, TLS, reconexão. Reimplementar isso não seria independência, seria reescrever um dialeto binário de vinte anos — e o jeito de errar ali não é tela feia, é número errado gravado no personagem de alguém.

O que é nosso é o que afeta o servidor todo dia: quantas conexões abrir, o que fazer quando o banco cai, que formato de resultado atravessa para o Lua, e o que acontece quando uma query quebra.

Quem fala com ele

  • O nosso código nunca chama o rp_mysql direto. Ele escreve SQL.single(...), que é o rp_db — a porta que permitiu trocar o motor sem encostar nas 61 chamadas espalhadas por seis resources.
  • O ESX (es_extended, esx_skin) carrega @rp_mysql/lib/MySQL.lua e continua escrevendo MySQL.query.await(...), sem saber que trocou de motor.

Arranque e conexão

  1. O resource sobe e lê a convar mysql_connection_string, que mora no secrets.cfg (fora do git). É a mesma convar que o oxmysql lia — inventar um nome novo seria uma senha a mais para alguém esquecer de configurar no dia de subir um servidor novo.
  2. A string precisa ser URI: mysql://usuario:senha@host/banco. O formato antigo de chave=valor não é aceito — formato desconhecido estoura alto no arranque em vez de virar uma conexão que nunca conecta.
  3. O pool é criado. Criar o pool não conecta: o mysql2 só abre a conexão na primeira query. Por isso o driver faz um ping logo em seguida — sem ele, um servidor com a senha errada subiria com tudo verde e só quebraria quando o primeiro jogador entrasse, longe da causa.
  4. Se o ping falhar, ele tenta de novo a cada 5 segundos, para sempre. Banco e servidor de jogo sobem juntos e nem sempre nessa ordem; desistir na primeira tentativa significaria reiniciar o FXServer na mão toda vez que o MariaDB demorasse cinco segundos a mais.

No console, o arranque bem-sucedido são duas linhas:

[rp_mysql] conectando em 127.0.0.1:3306/essentialmode
[rp_mysql] banco de pe
Armadilha — já aconteceu

localhost não é 127.0.0.1 para o Node. Do Node 18 em diante ele resolve localhost para ::1 (IPv6) primeiro, e o MariaDB desta máquina escuta só em IPv4. O erro que aparece é ECONNREFUSED ::1:3306, que parece banco fora do ar e é apenas um nome resolvido para o endereço errado.

O driver reescreve localhost para 127.0.0.1 por conta própria. A correção fica aqui e não no secrets.cfg porque a mesma string é lida por outros lugares.

Como se usa no dia a dia

Pelo rp_db, que é o caminho normal

Todo código nosso passa por aqui. O global chama-se SQL e existe só no servidor.

-- Uma lista de linhas
local jobs = SQL.query('SELECT name, label FROM jobs WHERE grade = ?', { 0 })

-- A primeira linha, ou nil
local medico = SQL.single('SELECT * FROM jobs WHERE name = ?', { 'ambulance' })

-- Um valor só
local quantos = SQL.scalar('SELECT COUNT(*) FROM users')

-- O id da linha criada
local id = SQL.insert('INSERT INTO rp_documents (owner, tipo) VALUES (?, ?)', { lic, 'rg' })

-- Quantas linhas mudaram
local mudou = SQL.update('UPDATE users SET job = ? WHERE identifier = ?', { 'ambulance', id })

-- Espera o banco estar de pé (use antes de consultar no primeiro frame)
SQL.pronto()
Regra da casa

O SQL.* só existe na forma que espera. O driver aceita callback, mas o rp_db não oferece: ter os dois jeitos significa duas maneiras de escrever a mesma coisa, e a de callback traz junto o erro que ninguém trata. Escreva dentro de uma thread e deixe a chamada esperar.

Pela ponte crua

Só para quem estiver escrevendo uma camada nova. É um export só, e o método vai como argumento — sete exports quase iguais seriam sete lugares para consertar a mesma coisa.

exports.rp_mysql:executar(metodo, sql, parametros, function(ok, valor)
    -- ok = false significa que a query quebrou; valor é a mensagem
end)

-- Síncrono de propósito: o Lua pergunta num laço enquanto espera
local vivo = exports.rp_mysql:conectado()

Os sete métodos

O método não muda a query — muda o que volta dela. Escolher errado aqui é a fonte mais comum de attempt to index a nil value algumas linhas depois.

MétodoDevolveQuando não achou nada
queryLista de linhas (tabela de tabelas)Lista vazia
singleA primeira linhanil
scalarA primeira coluna da primeira linha, seja qual for o nome delanil
insertO insertId — o id auto-incremento criadonil
updateO affectedRows — quantas linhas mudaram0
prepareDepende do verbo da query — ver abaixonil
transactiontrue se tudo passouExceção, e nada é gravado

update serve para DELETE também: o nome é do método, não do verbo SQL. E update devolvendo 0 não quer dizer que falhou — quer dizer que nenhuma linha mudou de valor, o que também acontece quando você grava o mesmo dado que já estava lá.

Detalhe de implementação

Todos os métodos usam pool.query (texto), menos o prepare, que usa pool.execute (statement preparado no servidor). O motivo é prático: DDL como CREATE TABLE não pode ser preparada em todo MySQL, e é justamente o que roda no arranque de metade dos nossos resources.

Parâmetros

Nunca monte SQL com concatenação. Os parâmetros vão numa tabela e o driver os entrega separados da query — é o que impede que um nome de personagem com aspas vire um DROP TABLE.

As três formas aceitas

-- 1. Posicional, que é o que usamos
SQL.single('SELECT * FROM users WHERE identifier = ?', { lic })

-- 2. Nomeado com @ ou : (é assim que o esx_skin escreve)
SQL.single('SELECT skin FROM users WHERE identifier = @identifier', { ['@identifier'] = lic })

-- 3. Vários conjuntos, só no prepare — a mesma query rodada n vezes
SQL.prepare('INSERT INTO log (texto) VALUES (?)', { { 'a' }, { 'b' }, { 'c' } })

O prefixo @ e o : são tratados igual, e o driver só troca o que foi realmente passado como parâmetro. Sem essa guarda, um e-mail dentro de uma string na query viraria ? e a lista de valores sairia com um item a mais.

Armadilha — já aconteceu

Tabela com buraco. { a, nil, c } em Lua não é uma lista de três itens: é uma tabela com as chaves 1 e 3. Ela atravessa a ponte como objeto, não como array, e mandar isso direto para o mysql2 envia os parâmetros na ordem errada — ou em nenhuma ordem. O sintoma é dado gravado na coluna do vizinho.

O driver reconstrói a lista na ordem, com null onde o Lua tinha nil — que é exatamente o que a coluna vai receber.

prepare, o caso especial

O prepare não é "um update preparado", e foi essa suposição que quebrou o login do servidor inteiro.

A primeira versão do driver devolvia linhas afetadas para tudo. Só que o es_extended usa MySQL.prepare.await(SELECT ...) para carregar o personagem e depois faz result.accounts no retorno — indexar um número foi o erro que deixou todo mundo preso na tela de escolha de personagem, com a câmera girando.

A regra abaixo foi copiada de propósito do oxmysql, para o servidor não ter duas semânticas:

VerboO que o prepare devolve
INSERTO id criado
UPDATE / DELETEQuantas linhas mudaram
Qualquer outro (SELECT)A primeira linha — e se ela tiver uma coluna só, o valor dela, não a linha

Com vários conjuntos de parâmetros, o retorno é a lista de resultados, um por conjunto.

Diferença deliberada do oxmysql

O verbo é lido depois de um trim. O original olhava o começo cru da string, e com um [[ de Lua a query começa por quebra de linha: a primeira palavra dava vazia e um INSERT indentado era tratado como SELECT. Nenhuma chamada nossa dependia do comportamento antigo.

Transação

Tudo ou nada. O rollback é o motivo de a transação existir: sem ele, metade de uma transferência de dinheiro fica gravada.

SQL.transacao({
    { 'UPDATE users SET money = money - ? WHERE identifier = ?', { 500, de } },
    { 'UPDATE users SET money = money + ? WHERE identifier = ?', { 500, para } },
})

Cada item pode ser { sql, params }, { query = ..., values = ... } ou { sql = ..., params = ... } — as três formas são naturais de escrever em Lua e o ESX usa mais de uma. Se qualquer passo falhar, nada é gravado e o erro sobe como exceção.

A tomada do ESX

O arquivo lib/MySQL.lua existe para o ESX, não para nós. O es_extended e o esx_skin são código de terceiros que chamam MySQL.query.await(...) em algumas dezenas de lugares.

Havia duas saídas: reescrever as chamadas dentro do ESX — e herdar um fork que briga com toda atualização dele — ou dar a eles a mesma tomada ligada no nosso motor. Eles trocam uma linha do próprio manifesto e continuam falando a língua que já falavam:

-- es_extended/fxmanifest.lua
server_scripts {
    '@rp_mysql/lib/MySQL.lua',
    ...
}

dependency 'rp_mysql'

A tomada oferece a forma que espera (MySQL.query.await), a forma com callback (MySQL.query(sql, params, cb), aceitando também o callback no segundo argumento), o MySQL.ready, e os apelidos MySQL.Sync.fetchAll / MySQL.Async.fetchScalar de um ESX de 2018. Nenhum resource ligado hoje usa os apelidos; eles estão ali porque a ausência deles não daria erro claro, daria attempt to index a nil value — a pior forma de descobrir que uma tomada tem um pino a menos.

Regra da casa

Código nosso não usa MySQL.*. Do lado de casa a porta é o SQL.* do rp_db, que é mais limpo justamente por não carregar a história de compatibilidade que mora nesse arquivo.

O pool, opção por opção

Estas são as decisões que o driver tomou por você. Todas têm um porquê que já custou alguma coisa em algum servidor.

OpçãoValorPor quê
connectionLimit12 Com uma conexão só, o inventário de alguém espera o SELECT de documento de outro terminar. Com 48 jogadores isso não é lentidão ocasional, é engarrafamento permanente. O número que não serve é 1.
waitForConnectionstrue Fila em vez de erro quando as doze estão ocupadas. Recusar transformaria um pico de dois segundos em "falha ao salvar inventário".
dateStringstrue O mysql2 devolve TIMESTAMP como objeto Date, que não tem representação boa do outro lado da ponte: chega como tabela ou como coisa nenhuma, dependendo do caminho. Como texto, chega como texto sempre.
decimalNumberstrue Sem isto, uma coluna de dinheiro volta '1500.00' e a primeira conta feita com ela vira concatenação em vez de soma.
namedPlaceholdersfalse Quem traduz @nome é o driver, que precisa fazer isso de qualquer jeito por causa das tabelas com buraco. Ligar o tradutor do mysql2 também seria ter dois, e o segundo receberia uma query que o primeiro já converteu.
enableKeepAlivetrue O servidor fica horas sem ninguém online de madrugada. Sem keep-alive, o MySQL derruba a conexão ociosa por timeout e o primeiro jogador a entrar leva o erro.
charsetutf8mb4 Padrão, sobrescrevível por ?charset= na string de conexão. É o que faz emoji e acento não virarem interrogação.

Erros e diagnóstico

A query vai junto do erro

"You have an error in your SQL syntax" sozinho manda a pessoa procurar em seis resources. O driver imprime a query junto, normalizada em uma linha:

[rp_mysql] erro: Unknown column 'nome' in 'field list'
   SELECT nome FROM jobs WHERE name = ?

Query lenta aparece sozinha

O rp_db cronometra toda query — é o único lugar por onde todas passam. Acima de 150 ms ela vai para o console com o resource que a fez e o SQL inteiro, e dispara o evento rp_db:lenta.

Um SELECT indexado responde em menos de 5 ms; qualquer coisa acima de 150 ou varre tabela ou está esperando lock, e as duas viram travada de servidor quando a tabela crescer. O número existe para descobrirmos isso com trinta jogadores, e não com trezentos.

A prova de vida

O comando /mysqlteste (console, ou jogador com ace command) roda dez casos contra o banco de verdade e imprime o que voltou. Três deles são os que quebraram o login na primeira virada de motor — nenhum aparece em código nosso, os três vêm de dentro do ESX:

  • prepare num SELECT deve devolver a linha, não o número de linhas afetadas.
  • prepare de uma coluna só deve devolver o valor.
  • Placeholder nomeado (@nome) precisa funcionar, do jeito que o esx_skin escreve.

O último caso é uma query numa tabela que não existe, e ele tem que falhar — é a prova de que o erro chega até o Lua em vez de sumir no caminho.

Como editar e publicar

O que roda no servidor é dist/build.js, um arquivo com o mysql2 empacotado dentro. É por isso que a VPS não precisa de node_modules — e por isso que mexer no src sem compilar não muda nada no jogo.

cd resources/[base]/rp_mysql/oficina
npm install        # só na primeira vez
npm run build      # gera ../dist/build.js

Depois disso, refresh e ensure rp_mysql no console do servidor — lembrando que reiniciar o rp_mysql para todos os resources que dependem dele e não os traz de volta. Na VPS, o caminho é o subir.sh.

Armadilha — já aconteceu

A oficina fica numa subpasta, e não na raiz do resource, por um motivo específico: o FXServer tem "build tasks" e, se encontra um package.json na raiz de um resource, roda yarn install sozinho antes de iniciar. Com o package.json em cima, a primeira subida na VPS baixou a árvore inteira de dependências — inclusive os binários do esbuild para aix, sunos e win32 — e terminou em Couldn't start resource rp_mysql.

Empacotar existe justamente para a VPS não precisar instalar nada. Uma pasta abaixo, o FXServer não vê.

Armadilhas conhecidas

Tudo nesta lista já derrubou este servidor pelo menos uma vez.

SintomaCausa real
ECONNREFUSED ::1:3306, parece banco fora do ar Node resolve localhost para IPv6 e o MariaDB só escuta em IPv4.
Todo mundo preso na tela de escolha de personagem prepare devolvendo número em vez da linha; o ESX faz result.accounts em cima.
Dado gravado na coluna errada Tabela Lua com nil no meio chegando como objeto, não como lista.
Couldn't start resource rp_mysql package.json na raiz do resource dispara o yarn install do FXServer.
Linhas duplicadas a cada restart, apesar do INSERT IGNORE Em MySQL dois NULL nunca colidem, então o índice único não protege. Consulte antes, insira só se faltar.
Seis resources caem junto depois de um restart rp_mysql Reiniciar um resource para os dependentes e não os sobe de volta. Rode o subir.sh depois.
Query some sem erro no primeiro frame do resource Consultou antes de haver conexão. Chame SQL.pronto() antes.