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 é orp_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.luae continua escrevendoMySQL.query.await(...), sem saber que trocou de motor.
Arranque e conexão
- O resource sobe e lê a convar
mysql_connection_string, que mora nosecrets.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. - A string precisa ser URI:
mysql://usuario:senha@host/banco. O formato antigo dechave=valornão é aceito — formato desconhecido estoura alto no arranque em vez de virar uma conexão que nunca conecta. - 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
pinglogo 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. - 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
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()
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étodo | Devolve | Quando não achou nada |
|---|---|---|
| query | Lista de linhas (tabela de tabelas) | Lista vazia |
| single | A primeira linha | nil |
| scalar | A primeira coluna da primeira linha, seja qual for o nome dela | nil |
| insert | O insertId — o id auto-incremento criado | nil |
| update | O affectedRows — quantas linhas mudaram | 0 |
| prepare | Depende do verbo da query — ver abaixo | nil |
| transaction | true se tudo passou | Exceçã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á.
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.
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:
| Verbo | O que o prepare devolve |
|---|---|
| INSERT | O id criado |
| UPDATE / DELETE | Quantas 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.
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.
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ção | Valor | Por quê |
|---|---|---|
| connectionLimit | 12 | 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. |
| waitForConnections | true | Fila em vez de erro quando as doze estão ocupadas. Recusar transformaria um pico de dois segundos em "falha ao salvar inventário". |
| dateStrings | true | 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. |
| decimalNumbers | true | Sem isto, uma coluna de dinheiro volta '1500.00' e a primeira conta feita com ela vira concatenação em vez de soma. |
| namedPlaceholders | false | 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. |
| enableKeepAlive | true | 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. |
| charset | utf8mb4 | 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:
preparenumSELECTdeve devolver a linha, não o número de linhas afetadas.preparede 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.
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.
| Sintoma | Causa 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. |