Dia 11 — Regras de negócio: o CRUD não é o sistema
Erro de negócio contra erro de técnica
Dois erros que chegam no mesmo catch
Toda requisição que falha termina no mesmo lugar: um catch. E o que se escreve ali decide se quem está usando o sistema consegue resolver o problema sozinho ou não consegue nem saber o que aconteceu.
Existem dois tipos, e a diferença entre eles não é o tamanho da mensagem: é quem tem o conserto na mão.
- Erro de negócio é a resposta do domínio a um pedido. "O estoque acabou", "o cliente não existe", "o e-mail já está cadastrado", "esse pedido já foi enviado". O sistema está funcionando; ele é que recusa.
- Erro técnico é uma falha. O banco está fora do ar, a tabela não existe, há um bug no código, a consulta travou. Ninguém pode corrigir isso a partir do outro lado da conexão.
| Erro | Status | Quem pode corrigir | O que o sistema faz |
|---|---|---|---|
| e-mail já cadastrado | 409 | quem chamou a API | responde e segue |
| estoque insuficiente | 409 | quem chamou a API | responde e segue |
| cliente inexistente | 404 | quem chamou a API | responde e segue |
| saldo ficaria negativo | 422 | quem chamou a API | responde e segue |
| campo obrigatório faltando | 400 | quem chamou a API | responde e segue |
| banco fora do ar | 500 | ninguém do outro lado | registra no log |
| tabela não existe | 500 | ninguém do outro lado | registra no log |
| bug no código | 500 | quem vai corrigir o código | registra no log |
A regra que separa as duas linhas do primeiro bloco das três do segundo é uma só: o erro de negócio não vai para o log de erro. Não é falha — é a resposta a um pedido. Logar "estoque insuficiente" a cada tentativa vira o mesmo ruído que o log de erro já tem, e o alerta que existe para avisar de problema real passa a não falar de nada.
As quatro primeiras linhas compartilham um status, o 409, que é o status de conflito: o pedido é legítimo e não pode ser atendido do jeito que foi escrito. Conflito é o nome técnico do que existe uma providência do lado de quem chamou — trocar o e-mail, escolher outro produto, pagar o que falta — e não uma providência do lado do servidor. O mesmo 409 recusa e-mail repetido, estoque insuficiente e saldo negativo, e o que distingue um do outro não é o status: é o codigo.
A classe de erro que carrega o status
Um throw new Error('estoque insuficiente') não diz qual resposta dar. A rota teria de adivinhar pelo texto da mensagem, e o catch acabaria cheio de if comparando string.
A solução é uma subclasse de Error com o status e o código dentro:
class ErroDeNegocio extends Error { constructor(mensagem, status, codigo) { super(mensagem); this.name = 'ErroDeNegocio'; this.status = status; this.codigo = codigo; } } throw new ErroDeNegocio('estoque insuficiente de mouse: restam 0', 409, 'ESTOQUE_INSUFICIENTE');
O status é o que a rota escreve no writeHead. O codigo é separado do status porque responde a uma pergunta diferente: o status diz a família do problema (4xx), e o codigo diz qual regra foi quebrada. É o codigo que o painel usa para oferecer o que oferecer — recarregar a página de estoque, sugerir outro produto — sem interpretar texto em português.
O catch inteiro cabe numa função, e é assim que a decisão fica num lugar só:
function decidirResposta(erro) { if (erro instanceof ErroDeNegocio) { return { status: erro.status, corpo: { erro: erro.message, codigo: erro.codigo }, registra: false }; } return { status: 500, corpo: { erro: 'erro interno' }, registra: true }; }
instanceof é a pergunta que separa as famílias, e ela funciona porque um erro do MySQL é um Error comum, nunca um ErroDeNegocio. O catch chama decidirResposta, e o registra diz se a linha entra na tabela de log.
A mensagem do erro de negócio vai para o cliente, porque foi escrita para ser lida por quem está usando a API. A do erro técnico não vai: o console.error guarda, e a resposta devolve cinco palavras. O stack inteiro é o que serve para consertar, e ele é do programador, não de quem está com o pedido travado na tela.
A regra que depende do dado, e a que o banco prova
Uma regra de negócio não é um if sobre o corpo da requisição. Ela precisa consultar algo para decidir, e é essa consulta que separa a regra do domínio da validação de campo.
A diferença entre os dois é o que muda quando a resposta muda. Campo obrigatório faltando é o mesmo 400 em qualquer dia, com qualquer dado na tabela — é validação de forma. Estoque insuficiente dá 409 hoje e talvez dê 201 amanhã, quando um novo lote de mercadoria entrar, sem que uma linha de código tenha mudado. Isso é validação de regra: ela não pergunta se o pedido está bem escrito, pergunta se ele pode ser atendido agora.
Por isso uma regra de negócio nunca vira validação de campo com nome mais difícil. Campo faltando se resolve com 400 e mensagem fixa; validação de regra se resolve consultando, e a consulta é o que faz o status mudar sozinho com o dado.
A validação de regra mora no service, e a palavra é literal: é o objeto que a rota chama, e é o único lugar do arquivo que sabe o que é estoque, saldo e cliente. A rota sabe HTTP — método, caminho, writeHead — e não sabe nada do domínio. Regra de negócio no service significa exatamente isso: a decisão fica no objeto de negócio, e a rota apenas traduz a exceção em status. criarPedido faz três consultas antes de recusar qualquer coisa. O cliente existe? O produto tem estoque? O saldo cobre o total? Cada resposta vira um status diferente:
const [clientes] = await c.execute( 'SELECT id, nm_cliente, vl_saldo FROM tb_d11a1_cliente WHERE id = ?', [idCliente]); if (clientes.length === 0) { throw new ErroDeNegocio('cliente nao existe', 404, 'CLIENTE_INEXISTENTE'); }
O 422 é o caso que mais se confunde. "O saldo ficaria negativo" não é 400: o corpo está correto, o valor pedido faz sentido, e o que impede é o estado da conta. O 400 é para o pedido que está escrito errado; o 422 é para o pedido bem formado que não pode ser atendido.
O DECIMAL volta como texto do driver. Number(cliente.vl_saldo) é o que transforma "100.00" em 100 — e sem essa conversão a comparação saldo - total < 0 compara string com número, e a conta sai errada.
Uma regra pode ser provada pelo banco em vez do código. A regra de negócio "não pode ter email duplicado" não precisa de SELECT antes do INSERT: a UNIQUE KEY recusa, e o serviço traduz a recusa. Isso é erro 409 na resposta, e o status vem da tradução — o banco devolve ER_DUP_ENTRY, que é código de motor, e o 409 é decisão da aplicação:
try { const [r] = await c.execute('INSERT INTO tb_d11a1_cliente (...) VALUES (?, ?, ?)', [...]); return { id: r.insertId }; } catch (erro) { if (erro.code === 'ER_DUP_ENTRY') { throw new ErroDeNegocio('ja existe cliente com este email', 409, 'EMAIL_DUPLICADO'); } throw erro; // qualquer outro erro do banco e tecnico: sobe cru, sem status }
O último throw erro é o que mantém a separação honesta. Sem ele, todo erro do banco viraria 409 de "e-mail duplicado" — inclusive uma falha de conexão, que não tem nada a ver com e-mail.
E o detalhe do catch do exemplo: ele compara erro.code, que no erro do MySQL é ER_DUP_ENTRY, ER_NO_SUCH_TABLE ou ECONNREFUSED. Em TypeError de bug, erro.code nem existe — é por isso que a coluna st_codigo da tabela de log tem o valor (sem code).
Os dois erros cráticos
O erro mais comum é tratar tudo como 500. O segundo é o oposto: devolver 400 para uma falha do banco. O exemplo monta os dois com o mesmo serviço, e a diferença aparece no que o cliente consegue fazer.
Tratar o erro de negócio como 500. O cliente recebe "erro interno". Repetir não resolve — o id 999 não existe e continua não existindo. Não há campo para corrigir. O log está vazio, porque o catch que falhou não registrou nada. O usuário abre um chamado e o suporte não tem onde olhar.
Tratar a falha do banco como 400. O banco respondeu ER_NO_SUCH_TABLE porque o código procura uma tabela que não existe. O cliente recebe "pedido inválido", corrige um pedido que estava perfeito, reenvia, e o erro vem igual. O 400 afirma que o problema é do lado de quem pediu, e o problema era de uma tabela.
O 400 também é mentira do outro jeito: ele diz que algo pode ser corrigido. Uma falha de banco não tem o que corrigir do lado do cliente — só se reiniciar o processo, e aí o log precisa existir.
O log do exemplo separa as duas famílias, e a medição confirma: o SELECT na tabela de log devolve três linhas, todas com status 500 — uma Error com ER_NO_SUCH_TABLE, uma TypeError com o código (sem code), e uma Error com ECONNREFUSED. Nenhuma das cinco regras de negócio entrou ali. O ER_DUP_ENTRY do e-mail repetido não está entre elas, e essa ausência é o resultado correto.
O
messagedo MySQL vem em inglês e cita o nome interno da tabela (Table 'materiais_teste.tb_d11a1_que_nao_existe' doesn't exist). Ele é perfeito para oconsole.errore para o log, e é justamente por isso que ele não pode ir para a resposta: quem está usando a API não pode ler o nome do banco alheio nem a mensagem do servidor de outra pessoa.
catchque engole o erro e devolve200é pior que os dois cráticos. O cliente recebe sucesso, o pedido não foi gravado, e a diferença aparece no relatório do dia seguinte, quando ninguém consegue ligar os dois fatos.
Nem todo
4xxé culpa de quem chamou.404em rota que existe com outro método (GETnum recurso que só fazPOST) é do servidor, não do cliente. A distinção continua sendo a mesma: existe alguma ação que a pessoa do outro lado consiga tomar para mudar o resultado? Se não existe, não é4xx.
Exemplo
'use strict'; // Exemplo da aula 1 do dia 11: erro de negocio contra erro de tecnica. // // Dois erros chegam no mesmo `catch` e nao podem ser tratados do mesmo jeito: // // erro de NEGOCIO a regra do dominio proveu o pedido: "o estoque acabou", // "o cliente nao existe", "o email ja esta cadastrado". // Status 4xx, a mensagem vai para o cliente e o log NAO // recebe nada — nao houve falha, houve resposta a um pedido // que nao podia ser atendido. // // erro de TECNICA o banco esta fora, a tabela nao existe, ha um bug no // codigo. Status 500, a mensagem nao vai para o cliente e o // log recebe classe, codigo e stack. // // O exemplo mede as duas familias com o MESMO servico e com o MESMO catch, e // depois mede os dois erros craticos: o `catch` que traduz tudo como 500, e o // `catch` que devolve 400 para uma falha do banco. // // A porta muda a cada execucao: e o `listen(0)` pedindo uma livre ao sistema. const http = require('node:http'); const { createConnection } = require('mysql2/promise'); // ====================================================== 1. a classe de erro // Subclass de `Error` com DUAS informacoes a mais: o `status` HTTP que a // resposta deve ter e o `codigo`, que e o nome da regra quebrada. O `codigo` e // o que o cliente do sistema (o painel, o app, o suporte) le para decidir o // que oferecer a quem took o erro, sem ler a mensagem em portugues. class ErroDeNegocio extends Error { constructor(mensagem, status, codigo) { super(mensagem); this.name = 'ErroDeNegocio'; this.status = status; this.codigo = codigo; } } // ============================================== 2. o log so de erro tecnico // Uma tabela so, e ela recebe SOMENTE falha de tecnica. O que o exemplo // mede no fim e quantas linhas entraram nela: se um erro de negocio aparecesse // aqui, o log viraria ruido e o alerta de erro real deixaria de servir para // nada. async function registraNoLog(c, erro, status) { // `erro.code` vem do MySQL (`ER_NO_SUCH_TABLE`, `ER_DUP_ENTRY`, // `ECONNREFUSED`) e NAO vem no erro de JavaScript: um `TypeError` tem // `name` e nao tem `code`. Por isso as duas colunas existem, e por isso o // `catch` compara `erro.code` sem assumir que ele esteja presente. const codigo = erro.code ? String(erro.code) : '(sem code)'; await c.execute( 'INSERT INTO tb_d11a1_log (st_origem, st_status_http, st_classe, st_codigo, nm_mensagem)' + ' VALUES (?, ?, ?, ?, ?)', ['tecnica', status, erro.name || 'Error', codigo, String(erro.message).slice(0, 180)]); } // ================================================== 3. a decisao do `catch` // E uma funcao, e nao um `if` espalhado, porque e ela que decide as tres // coisas ao mesmo tempo: o status, o que o cliente le, e se o log recebe. // // `instanceof` e a unica pergunta que separa as familias: um erro do MySQL e // um `Error` comum, nunca um `ErroDeNegocio`. function decidirResposta(erro) { if (erro instanceof ErroDeNegocio) { return { status: erro.status, corpo: { erro: erro.message, codigo: erro.codigo }, registra: false, // erro de negocio NAO e erro: nao vai para o log }; } // O detalhe do erro tecnico nao sai daqui. Quem precisa ver o porque esta no // log, com a stack; quem esta usando a API recebe cinco palavras. return { status: 500, corpo: { erro: 'erro interno' }, registra: true }; } // ================================================ 4. a camada de regra // A regra mora no servico, e o servico nao sabe que existe HTTP. Ele lanca // `ErroDeNegocio` quando o dominio recusa, e deixa subir qualquer outra coisa. // `criarServico(c)` devolve as funcoes que usam a conexao `c`. function criarServico(c) { return { // Regra que o BANCO prova: `uk_email` e a constraint. O servico traduz a // recusa do banco em erro de negocio com 409, porque quem sofre a // consequencia e o cliente da API, nao o motor do banco. async criarCliente(dados) { const nmCliente = String(dados.nm_cliente ?? '').trim(); const nmEmail = String(dados.nm_email ?? '').trim(); if (!nmCliente) { throw new ErroDeNegocio('nm_cliente e obrigatorio', 400, 'CAMPO_OBRIGATORIO'); } if (!nmEmail.includes('@')) { throw new ErroDeNegocio('nm_email invalido', 400, 'EMAIL_INVALIDO'); } try { const [r] = await c.execute( 'INSERT INTO tb_d11a1_cliente (nm_cliente, nm_email, vl_saldo) VALUES (?, ?, ?)', [nmCliente, nmEmail, 0]); return { id: r.insertId, nm_cliente: nmCliente, nm_email: nmEmail }; } catch (erro) { if (erro.code === 'ER_DUP_ENTRY') { throw new ErroDeNegocio('ja existe cliente com este email', 409, 'EMAIL_DUPLICADO'); } // Qualquer outro erro do banco e tecnico: sobe cru, sem status, sem // traducao. O `catch` do servidor vai decide-lo como 500. throw erro; } }, // Quatro regras que dependem do dado do momento, e nenhuma delas se // resolve com `if` sobre o corpo da requisicao: cada uma precisa de uma // consulta antes de responder. async criarPedido(dados) { const idCliente = Number(dados.id_cliente); const itens = Array.isArray(dados.itens) ? dados.itens : []; if (!Number.isInteger(idCliente) || idCliente < 1) { throw new ErroDeNegocio('id_cliente e obrigatorio', 400, 'CAMPO_OBRIGATORIO'); } if (itens.length === 0) { throw new ErroDeNegocio('pedido sem item', 400, 'PEDIDO_VAZIO'); } const [clientes] = await c.execute( 'SELECT id, nm_cliente, vl_saldo FROM tb_d11a1_cliente WHERE id = ?', [idCliente]); if (clientes.length === 0) { throw new ErroDeNegocio('cliente nao existe', 404, 'CLIENTE_INEXISTENTE'); } const cliente = clientes[0]; const saldo = Number(cliente.vl_saldo); // o DECIMAL volta como texto do driver let total = 0; for (const item of itens) { const [produtos] = await c.execute( 'SELECT id, nm_produto, qtd_estoque, vl_unitario' + ' FROM tb_d11a1_produto WHERE id = ?', [Number(item.id_produto)]); if (produtos.length === 0) { throw new ErroDeNegocio('produto ' + item.id_produto + ' nao existe', 404, 'PRODUTO_INEXISTENTE'); } const produto = produtos[0]; const qtd = Number(item.qtd); if (!Number.isInteger(qtd) || qtd < 1) { throw new ErroDeNegocio('qtd precisa ser um inteiro maior que zero', 400, 'QTD_INVALIDA'); } // `ESTOQUE_INSUFICIENTE` e 409 e nao 400: o pedido esta bem formado, // o que falha e a confrontacao dele com o estado atual do estoque. if (produto.qtd_estoque < qtd) { throw new ErroDeNegocio('estoque insuficiente de ' + produto.nm_produto + ': restam ' + produto.qtd_estoque, 409, 'ESTOQUE_INSUFICIENTE'); } total += qtd * Number(produto.vl_unitario); } // `SALDO_NEGATIVO` e 422: o corpo e valido, o valor faz sentido, e o // pedido nao pode ser atendido por causa do estado da conta. if (saldo - total < 0) { throw new ErroDeNegocio('saldo insuficiente: o saldo ficaria negativo (' + saldo + ' - ' + total + ')', 422, 'SALDO_NEGATIVO'); } const [pedido] = await c.execute( 'INSERT INTO tb_d11a1_pedido (id_cliente, vl_total) VALUES (?, ?)', [idCliente, total]); for (const item of itens) { const qtd = Number(item.qtd); await c.execute( 'INSERT INTO tb_d11a1_item_pedido (id_pedido, id_produto, qtd) VALUES (?, ?, ?)', [pedido.insertId, Number(item.id_produto), qtd]); await c.execute( 'UPDATE tb_d11a1_produto SET qtd_estoque = qtd_estoque - ? WHERE id = ?', [qtd, Number(item.id_produto)]); } await c.execute( 'UPDATE tb_d11a1_cliente SET vl_saldo = vl_saldo - ? WHERE id = ?', [total, idCliente]); // O saldo novo sai com `Number`: `DECIMAL` volta como texto do driver, // e um JSON com saldo em texto quebra a conta de quem consome a API. return { id: pedido.insertId, id_cliente: idCliente, vl_total: total, vl_saldo: Number((saldo - total).toFixed(2)), }; }, }; } async function main() { const c = await createConnection({ host: process.env.DB_HOST, port: Number(process.env.DB_PORT), user: process.env.DB_USER, password: process.env.DB_PASS, // so em memoria: o valor esta no .env database: process.env.DB_NAME, }); // A versao do banco e capturada, nunca escrita: a maquina de quem roda pode // ter outra, e a pagina precisa falar da maquina dela. const [versao] = await c.query('SELECT VERSION() AS versao'); console.log('banco em uso:', versao[0].versao); // O prefixo `tb_d11a1_` isola a tabela das outras aulas: `IF NOT EXISTS` nao // corrige esquema, e o banco de teste ja tem `tb_pedido` e `tb_cliente` de // outros dias com outra forma. await c.query(` CREATE TABLE IF NOT EXISTS tb_d11a1_cliente ( id INT AUTO_INCREMENT PRIMARY KEY, nm_cliente VARCHAR(40) NOT NULL, nm_email VARCHAR(80) NOT NULL, vl_saldo DECIMAL(10,2) NOT NULL DEFAULT 0, UNIQUE KEY uk_email (nm_email) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `); await c.query(` CREATE TABLE IF NOT EXISTS tb_d11a1_produto ( id INT AUTO_INCREMENT PRIMARY KEY, nm_produto VARCHAR(40) NOT NULL, qtd_estoque INT NOT NULL, vl_unitario DECIMAL(10,2) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `); await c.query(` CREATE TABLE IF NOT EXISTS tb_d11a1_pedido ( id INT AUTO_INCREMENT PRIMARY KEY, id_cliente INT NOT NULL, vl_total DECIMAL(10,2) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `); await c.query(` CREATE TABLE IF NOT EXISTS tb_d11a1_item_pedido ( id INT AUTO_INCREMENT PRIMARY KEY, id_pedido INT NOT NULL, id_produto INT NOT NULL, qtd INT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `); await c.query(` CREATE TABLE IF NOT EXISTS tb_d11a1_log ( id INT AUTO_INCREMENT PRIMARY KEY, dt_registro DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, st_origem VARCHAR(40) NOT NULL, st_status_http INT NOT NULL, st_classe VARCHAR(30) NOT NULL, st_codigo VARCHAR(40) NOT NULL, nm_mensagem VARCHAR(200) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `); // O `TRUNCATE` vem ANTES de qualquer insert: o exemplo roda varias vezes e // precisa dar o mesmo resultado na segunda como na primeira. await c.query('TRUNCATE TABLE tb_d11a1_log'); await c.query('TRUNCATE TABLE tb_d11a1_item_pedido'); await c.query('TRUNCATE TABLE tb_d11a1_pedido'); await c.query('TRUNCATE TABLE tb_d11a1_produto'); await c.query('TRUNCATE TABLE tb_d11a1_cliente'); await c.execute( 'INSERT INTO tb_d11a1_cliente (nm_cliente, nm_email, vl_saldo) VALUES (?, ?, ?), (?, ?, ?)', ['ana', '[email protected]', 1000, 'bruno', '[email protected]', 20]); await c.execute( 'INSERT INTO tb_d11a1_produto (nm_produto, qtd_estoque, vl_unitario) VALUES (?, ?, ?), (?, ?, ?)', ['teclado', 5, 300, 'mouse', 0, 80]); console.log('ana: saldo 1000, bruno: saldo 20 | teclado: 5 em estoque, mouse: 0 em estoque'); console.log('o mouse com zero e o que produz ESTOQUE_INSUFICIENTE; o bruno com 20 e o'); console.log('que produz SALDO_NEGATIVO no pedido de um teclado de 300.'); const servico = criarServico(c); // ------------------------------------------------------- o servidor const servidor = http.createServer(async (req, res) => { const responder = (status, corpo) => { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(corpo)); }; const partes = []; for await (const p of req) partes.push(p); const bruto = Buffer.concat(partes).toString('utf8'); const dados = bruto ? JSON.parse(bruto) : {}; const caminho = req.url.split('?')[0]; // A linha de contexto ANTES de qualquer erro: sem ela a pagina mostraria // uma linha de erro solta e nao diria o que ela prova. console.log(req.method + ' ' + caminho + ' corpo: ' + (bruto || '(vazio)')); // --- as duas rotas "cruas", que sao os erros craticos medidos no fim if (caminho.startsWith('/cru/')) { try { if (caminho === '/cru/quebrada') { const [lidas] = await c.query('SELECT * FROM tb_d11a1_que_nao_existe'); return responder(200, { total: lidas.length }); } return responder(201, await servico.criarPedido(dados)); } catch (erro) { // Um `catch` sem `instanceof`: as duas familias saem pelo mesmo // caminho, cada uma com o status que o autor escolheu no chute. const status = caminho === '/cru/quebrada' ? 400 : 500; console.error('rota cru: ' + (erro.code || erro.name) + ' - ' + erro.message); console.log(' rota cru respondeu ' + status + ', sem olhar a classe do erro'); return responder(status, { erro: status === 400 ? 'pedido invalido' : 'erro interno', }); } } try { if (req.method === 'POST' && caminho === '/cliente') { return responder(201, await servico.criarCliente(dados)); } if (req.method === 'POST' && caminho === '/pedido') { return responder(201, await servico.criarPedido(dados)); } if (req.method === 'GET' && caminho === '/quebrada') { // Bug de digitacao: o nome da tabela esta errado. O banco responde, e // a resposta e `ER_NO_SUCH_TABLE` — erro de TECNICA, ainda que a // consulta tenha sintaxe perfeitamente valida. const [lidas] = await c.query('SELECT * FROM tb_d11a1_que_nao_existe'); return responder(200, { total: lidas.length }); } if (req.method === 'POST' && caminho === '/cliente-sem-validacao') { // O outro bug de tecnica: a rota NAO validou e o codigo de baixo // assumiu que o campo existe. Um `TypeError` nao tem `erro.code`. const [lidas] = await c.execute( 'SELECT id FROM tb_d11a1_cliente WHERE nm_cliente = ?', [dados.nm_cliente.toUpperCase()]); return responder(200, { encontrados: lidas.length }); } return responder(404, { erro: 'rota nao encontrada' }); } catch (erro) { const d = decidirResposta(erro); if (d.registra) { // O par obrigatorio: o `console.error` vai para o stderr, que o // terminal mostra, e o `console.log` abaixo vai para a pagina. console.error('erro tecnico: ' + (erro.code || erro.name) + ' - ' + erro.message); console.log(' erro tecnico:', erro.code || erro.name, '-', erro.message); await registraNoLog(c, erro, d.status); } else { console.log(' erro de negocio: ' + erro.codigo + ' -> status ' + d.status); console.log(' para o cliente: ' + d.corpo.erro); console.log(' no log: NADA — nao e falha, e a resposta a um pedido recusado'); } return responder(d.status, d.corpo); } }); await new Promise((r) => servidor.listen(0, '127.0.0.1', r)); const base = 'http://127.0.0.1:' + servidor.address().port; console.log('\nservidor no ar em ' + base); console.log('a porta muda a cada execucao: e o listen(0) pedindo uma livre\n'); const pedir = async (metodo, caminho, corpo) => { const init = { method: metodo }; if (corpo !== undefined) { init.headers = { 'Content-Type': 'application/json' }; init.body = JSON.stringify(corpo); } const r = await fetch(base + caminho, init); return { status: r.status, corpo: JSON.parse(await r.text()) }; }; try { console.log('--- 1. as cinco regras de negocio, cinco status ---'); const criado = await pedir('POST', '/cliente', { nm_cliente: 'carla', nm_email: '[email protected]' }); console.log(' cliente novo -> ' + criado.status + ' ' + JSON.stringify(criado.corpo)); const repetido = await pedir('POST', '/cliente', { nm_cliente: 'carla outra', nm_email: '[email protected]' }); console.log(' email repetido -> ' + repetido.status + ' ' + JSON.stringify(repetido.corpo) + ' (o 409 saiu de ER_DUP_ENTRY, traduzido no servico)'); const semCliente = await pedir('POST', '/pedido', { id_cliente: 999, itens: [{ id_produto: 1, qtd: 1 }] }); console.log(' cliente inexistente -> ' + semCliente.status + ' ' + JSON.stringify(semCliente.corpo)); const semEstoque = await pedir('POST', '/pedido', { id_cliente: 1, itens: [{ id_produto: 2, qtd: 1 }] }); console.log(' estoque insuficiente -> ' + semEstoque.status + ' ' + JSON.stringify(semEstoque.corpo)); const semSaldo = await pedir('POST', '/pedido', { id_cliente: 2, itens: [{ id_produto: 1, qtd: 1 }] }); console.log(' saldo negativo -> ' + semSaldo.status + ' ' + JSON.stringify(semSaldo.corpo)); const valido = await pedir('POST', '/pedido', { id_cliente: 1, itens: [{ id_produto: 1, qtd: 1 }] }); console.log(' pedido valido -> ' + valido.status + ' ' + JSON.stringify(valido.corpo)); console.log('\n--- 2. erro de TECNICA: tres casos, todos 500 ---'); const tabelaQuebrada = await pedir('GET', '/quebrada'); console.log(' para o cliente -> ' + tabelaQuebrada.status + ' ' + JSON.stringify(tabelaQuebrada.corpo) + ' (cinco palavras, e o maximo)'); const bugDeCodigo = await pedir('POST', '/cliente-sem-validacao', {}); console.log(' para o cliente -> ' + bugDeCodigo.status + ' ' + JSON.stringify(bugDeCodigo.corpo)); console.log(' agora o banco fora do ar, sem passar por HTTP:'); try { // Porta 1 nao tem servidor MySQL. O erro que sai daqui e o mesmo que // sai quando o banco da maquina esta parado, e e o `ECONNREFUSED` que // o `catch` precisa comparar. await createConnection({ host: '127.0.0.1', port: 1, user: 'ninguem', password: '', database: 'nada', }); } catch (erro) { console.error('banco fora do ar: ' + erro.code + ' - ' + erro.message); console.log(' erro:', erro.code, '-', erro.message); const d = decidirResposta(erro); console.log(' para o cliente -> ' + d.status + ' ' + JSON.stringify(d.corpo)); await registraNoLog(c, erro, d.status); } console.log('\n--- 3. o que o log recebeu ---'); const [registros] = await c.query( 'SELECT st_status_http, st_classe, st_codigo FROM tb_d11a1_log ORDER BY id'); for (const l of registros) { console.log(' ' + l.st_status_http + ' ' + l.st_classe.padEnd(9) + l.st_codigo); } console.log(' linhas no log: ' + registros.length + ' — as tres de tecnica e NENHUMA das cinco de negocio'); console.log(' o `ER_DUP_ENTRY` do email repetido NAO esta aqui: ele virou 409'); console.log(' na resposta e parou ali, porque o comportamento foi o esperado'); console.log('\n--- 4. os dois erros CRATICOS, com o MESMO servico ---'); const negocioCru = await pedir('POST', '/cru/pedido', { id_cliente: 999, itens: [{ id_produto: 1, qtd: 1 }] }); console.log(' CRATICO 1, erro de negocio num catch que traduz tudo como 500:'); console.log(' resposta: ' + negocioCru.status + ' ' + JSON.stringify(negocioCru.corpo)); console.log(' o cliente leu "erro interno" e nao sabe o que fazer: repetir nao'); console.log(' resolve, o id 999 nao existe e continua nao existindo. Ele abre o'); console.log(' chamado, e o suporte so tem o log para olhar, que esta vazio.'); const tecnicoCru = await pedir('GET', '/cru/quebrada'); console.log(' CRATICO 2, falha do banco num catch que devolve 400:'); console.log(' resposta: ' + tecnicoCru.status + ' ' + JSON.stringify(tecnicoCru.corpo)); console.log(' o cliente leu "pedido invalido" e corrigiu um pedido que estava'); console.log(' perfeito, reenviou, e o erro veio igual. O 400 diz que o problema'); console.log(' e dele, e o problema era da tabela que o codigo procura.'); const [porStatus] = await c.query( 'SELECT st_status_http, COUNT(*) AS n FROM tb_d11a1_log GROUP BY st_status_http'); console.log('\n o log registrou so isto: ' + JSON.stringify(porStatus.map((l) => l.st_status_http + ' x' + l.n))); console.log(' e as duas rotas cruas nao registram NADA — o erro sumiu inteiro.'); } finally { await new Promise((r) => servidor.close(r)); console.log('\nservidor encerrado com close().'); await c.end(); } } main().catch((erro) => { console.error('falhou:', erro.code || erro.name, '-', erro.message); process.exit(1); });
Saída real
banco em uso: 10.11.14-MariaDB-0ubuntu0.24.04.1
ana: saldo 1000, bruno: saldo 20 | teclado: 5 em estoque, mouse: 0 em estoque
o mouse com zero e o que produz ESTOQUE_INSUFICIENTE; o bruno com 20 e o
que produz SALDO_NEGATIVO no pedido de um teclado de 300.
servidor no ar em http://127.0.0.1:33279
a porta muda a cada execucao: e o listen(0) pedindo uma livre
--- 1. as cinco regras de negocio, cinco status ---
POST /cliente corpo: {"nm_cliente":"carla","nm_email":"[email protected]"}
cliente novo -> 201 {"id":3,"nm_cliente":"carla","nm_email":"[email protected]"}
POST /cliente corpo: {"nm_cliente":"carla outra","nm_email":"[email protected]"}
erro de negocio: EMAIL_DUPLICADO -> status 409
para o cliente: ja existe cliente com este email
no log: NADA — nao e falha, e a resposta a um pedido recusado
email repetido -> 409 {"erro":"ja existe cliente com este email","codigo":"EMAIL_DUPLICADO"} (o 409 saiu de ER_DUP_ENTRY, traduzido no servico)
POST /pedido corpo: {"id_cliente":999,"itens":[{"id_produto":1,"qtd":1}]}
erro de negocio: CLIENTE_INEXISTENTE -> status 404
para o cliente: cliente nao existe
no log: NADA — nao e falha, e a resposta a um pedido recusado
cliente inexistente -> 404 {"erro":"cliente nao existe","codigo":"CLIENTE_INEXISTENTE"}
POST /pedido corpo: {"id_cliente":1,"itens":[{"id_produto":2,"qtd":1}]}
erro de negocio: ESTOQUE_INSUFICIENTE -> status 409
para o cliente: estoque insuficiente de mouse: restam 0
no log: NADA — nao e falha, e a resposta a um pedido recusado
estoque insuficiente -> 409 {"erro":"estoque insuficiente de mouse: restam 0","codigo":"ESTOQUE_INSUFICIENTE"}
POST /pedido corpo: {"id_cliente":2,"itens":[{"id_produto":1,"qtd":1}]}
erro de negocio: SALDO_NEGATIVO -> status 422
para o cliente: saldo insuficiente: o saldo ficaria negativo (20 - 300)
no log: NADA — nao e falha, e a resposta a um pedido recusado
saldo negativo -> 422 {"erro":"saldo insuficiente: o saldo ficaria negativo (20 - 300)","codigo":"SALDO_NEGATIVO"}
POST /pedido corpo: {"id_cliente":1,"itens":[{"id_produto":1,"qtd":1}]}
pedido valido -> 201 {"id":1,"id_cliente":1,"vl_total":300,"vl_saldo":700}
--- 2. erro de TECNICA: tres casos, todos 500 ---
GET /quebrada corpo: (vazio)
erro tecnico: ER_NO_SUCH_TABLE - Table 'materiais_teste.tb_d11a1_que_nao_existe' doesn't exist
para o cliente -> 500 {"erro":"erro interno"} (cinco palavras, e o maximo)
POST /cliente-sem-validacao corpo: {}
erro tecnico: TypeError - Cannot read properties of undefined (reading 'toUpperCase')
para o cliente -> 500 {"erro":"erro interno"}
agora o banco fora do ar, sem passar por HTTP:
erro: ECONNREFUSED - connect ECONNREFUSED 127.0.0.1:1
para o cliente -> 500 {"erro":"erro interno"}
--- 3. o que o log recebeu ---
500 Error ER_NO_SUCH_TABLE
500 TypeError(sem code)
500 Error ECONNREFUSED
linhas no log: 3 — as tres de tecnica e NENHUMA das cinco de negocio
o `ER_DUP_ENTRY` do email repetido NAO esta aqui: ele virou 409
na resposta e parou ali, porque o comportamento foi o esperado
--- 4. os dois erros CRATICOS, com o MESMO servico ---
POST /cru/pedido corpo: {"id_cliente":999,"itens":[{"id_produto":1,"qtd":1}]}
rota cru respondeu 500, sem olhar a classe do erro
CRATICO 1, erro de negocio num catch que traduz tudo como 500:
resposta: 500 {"erro":"erro interno"}
o cliente leu "erro interno" e nao sabe o que fazer: repetir nao
resolve, o id 999 nao existe e continua nao existindo. Ele abre o
chamado, e o suporte so tem o log para olhar, que esta vazio.
GET /cru/quebrada corpo: (vazio)
rota cru respondeu 400, sem olhar a classe do erro
CRATICO 2, falha do banco num catch que devolve 400:
resposta: 400 {"erro":"pedido invalido"}
o cliente leu "pedido invalido" e corrigiu um pedido que estava
perfeito, reenviou, e o erro veio igual. O 400 diz que o problema
e dele, e o problema era da tabela que o codigo procura.
o log registrou so isto: ["500 x3"]
e as duas rotas cruas nao registram NADA — o erro sumiu inteiro.
servidor encerrado com close().
Máquina de estados: quando o dado tem estágios
O dado que tem estágios
Uma coluna VARCHAR chamada st_status aceita qualquer texto. Nada impede que o pedido 12 passe de "entregue" para "pago" e para "sendo processado" e para três valores inventados na mesma tarde, porque o banco só sabe dizer que a coluna existe, não o que cada valor significa.
A coluna muda de forma quando o nome dela passa a ser uma lista fechada:
st_status ENUM('novo','pago','enviado','entregue','cancelado') NOT NULL DEFAULT 'novo'
O nome da coluna status é st_status, com o prefixo do curso, e é essa coluna status que guarda o estado do pedido: um pedido aberto é o que está em novo, e ela diz em que estágio o pedido está sem precisar de outra tabela. O ENUM é a última linha de defesa, e ela funciona: um estado fora da lista é recusado e o pedido continua como estava. Mas o ENUM sozinho não impede entregue → pago — os dois estão na lista. Ele impede o valor que não existe, não o caminho que não faz sentido.
As duas coisas são necessárias e nenhuma substitui a outra. A tabela de transições decide qual caminho é válido; o ENUM recusa o valor que não existe. O exemplo mede as duas, com o SHOW COLUMNS do banco devolvendo a definição que ele está usando.
O ENUM volta como texto, e é por isso que dá para comparar com === a string que veio do formulário. Ele também aceita a posição do valor na lista: o número 2 grava 'pago', porque 'pago' é o segundo da lista. É uma herança do tipo, e o jeito de não cair nela é mandar sempre a string.
A tabela de transições como dado
A regra do fluxo escrita como objeto, e não como if espalhado pelas rotas:
const TRANSICOES = { novo: ['pago', 'cancelado'], pago: ['enviado', 'cancelado'], enviado: ['entregue'], entregue: [], cancelado: [], }; function podeIr(de, para) { return Array.isArray(TRANSICOES[de]) && TRANSICOES[de].includes(para); }
podeIr(de, para) devolve true quando existe transição válida de um estágio para o outro, e false em todo o resto. É essa função que responde "esse caminho é permitido?", e ela é a única coisa que separa uma transição válida de uma transição inválida — nem a rota, nem o ENUM, nem o WHERE do UPDATE.
Os quatro estágios do objeto são os quatro pedidos do exemplo, lidos pelo nome que a tela mostra. Pedido aberto é novo: existe, ninguém pagou. Pedido pago é pago: o dinheiro entrou e o estoque já foi baixado. Pedido enviado é enviado: saiu para o transporte e já não pode mais ser cancelado. Pedido cancelado é cancelado, e é o único que tem duas entradas, vindo de novo e de pago, e nunca mais sai.
Estágio final é lista vazia, e lista vazia é o que faz a proibição de graça: entregue não tem para onde ir, e não existe código extra dizendo que não pode. Um if (st === 'entregue') return erro seria uma linha a mais para lembrar sempre que alguém acrescentar um estágio novo.
O diagrama de estados é exatamente este desenho:
novo --pagar--> pago --enviar--> enviado --entregar--> entregue | | cancelar cancelar v v cancelado
Esse desenho é o fluxo do pedido, e ele é a resposta para "quando o pedido pode virar o quê?". Lido da esquerda para a direita, diz que a única sequência possível é abrir, pagar, enviar e entregar; lido de baixo, diz que cancelar é a única saída lateral, e só existe antes do envio. A tabela TRANSICOES é esse fluxo do pedido escrito como dado, e é por isso que o código e o desenho não divergem: não há dois lugares para manter a regra.
O cancelamento existe em novo e em pago, e não em enviado. Essa é uma decisão de negócio, e a tabela é o lugar onde ela está escrita: quem precisa mudar a política de cancelamento edita o objeto, e não as rotas.
O UPDATE guardado
A parte que o UPDATE do dia 10 não cobre é a mudança de estado condicional. Um UPDATE que só filtra por id aceita qualquer valor novo vindo de qualquer lugar:
UPDATE tb_d11a2_pedido SET st_status = 'pago' WHERE id = 12
O WHERE recebe o estágio que o chamador leu e espera encontrar:
UPDATE tb_d11a2_pedido SET st_status = ?, dt_atualizacao = CURRENT_TIMESTAMP WHERE id = ? AND st_status = ?
Isso transforma a escrita em condição. A linha só é gravada se o pedido ainda estiver no estágio que o chamador achou que estava. A função que usa isso lê o número que o banco devolveu:
const [r] = await c.execute( 'UPDATE tb_d11a2_pedido SET st_status = ?, dt_atualizacao = CURRENT_TIMESTAMP' + ' WHERE id = ? AND st_status = ?', [para, id, de]); if (r.affectedRows === 0) { // o pedido existe mas nao esta no estagio esperado }
O affectedRows é a verificação inteira da aula, e o que ele conta é o número de linhas casadas com o WHERE, não o número de valores que mudaram. As duas coisas são diferentes, e a distinção importa:
| Consulta | affectedRows | changedRows |
|---|---|---|
st_status já é 'entregue', grava 'entregue' | 1 | 0 |
WHERE pede st_status = 'pago' e a linha é 'entregue' | 0 | 0 |
O primeiro caso é o que salva: gravar o mesmo valor é uma operação válida que casou a linha, e por isso devolve 1. Se o código usasse changedRows, recusaria toda operação que não mudasse o valor — inclusive a transição legítima para onde o pedido já estava.
O 409 da transição inválida
affectedRows === 0 não é erro de SQL. É o banco respondendo que não havia linha naquele estágio, e essa informação é boa: ela prova que o pedido existe e que está em outro lugar.
A tradução do 0 em resposta é do código, e o exemplo monta os dois casos que costumam ser confundidos:
- o
WHEREnão casou →409 ESTAGIO_NAO_CONFERE, com o estágio real na mensagem. O pedido está em"pago", e não em"novo". - o pedido não existe →
404 PEDIDO_INEXISTENTE.
A distinção importa porque a ação é outra. No 404 não há nada a fazer. No 409 o pedido está em algum estágio: quem chamou pode recarregar a tela e ver o estado verdadeiro, ou decidir o que fazer a partir dele. Devolver 404 para os dois é o mesmo defeito da aula 1 — afirmar que não existe algo que existe.
A transição que não está na tabela nem chega a consultar o banco: podeIr responde antes, e o 409 TRANSICAO_INVALIDA sai com uma fração de milissegundo e sem tocar em nenhuma linha. O exemplo mede as duas coisas: as quatro recusas não escreveram uma linha, e os pedidos continuaram como estavam.
A ordem das checagens tem um detalhe. pago → pago não está na tabela de transições — um estágio não transiciona para si mesmo — mas a resposta que o cliente precisa nesse caso é "o pedido já está pago", que é o duplo clique no botão. Por isso de === para é testado antes da tabela. Na ordem invertida, o caso mais comum de todos receberia a mensagem errada.
A corrida, e por que o affectedRows precisa ser lido
O bloco mais importante do exemplo dispara duas confirmações do mesmo pedido ao mesmo tempo, com Promise.all, e mede o estoque nas duas pontas.
Para a corrida existir de verdade são necessárias duas conexões. Com uma conexão só, o mysql2 coloca a segunda consulta na fila e ela espera a primeira terminar — não há corrida, e o exemplo estaria medindo o driver e não a guarda. Por isso o bloco usa createPool com connectionLimit: 2.
A corrida real, com a conferência do affectedRows: uma das duas confirmações recebeu 409 e o corpo pedido esta em "pago", e nao em "novo", a outra recebeu 200 com o pedido em pago, e o estoque foi de 10 para 9.
Uma baixa só. A transação que leu affectedRows = 0 abortou antes de tocar no estoque, e a que leu 1 seguiu.
E o mesmo cenário, com a mesma guarda no WHERE e o mesmo FOR UPDATE, só que sem ler o número: as duas confirmações imprimiram baixou o estoque, e o estoque foi de 10 para 8 — duas unidades baixadas por um pedido.
A guarda no WHERE funcionou nas duas transações: nenhuma delas escreveu 'pago' num pedido que já estava 'pago'. O que faltou foi o código olhar o que ela devolveu.
Essa é a razão de o bloco do affectedRows existir: ele não é um detalhe da resposta do banco, é a única prova de que a mudança aconteceu. Ignorar o número é o defeito inteiro — e ele passa despercebido em teste, porque um pedido por vez nunca faz duas requisições ao mesmo tempo.
O FOR UPDATE no estoque é a outra metade. Ele trava a linha do produto até o fim da transação, e é o que faz a segunda confirmação ler o número já atualizado em vez do número antigo que a primeira ainda vai trocar.
st_statuscomoVARCHARcomCHECKfunciona nas versões que implementam a constraint, e o erro que sai é o do motor. OENUMé a forma que funciona em toda máquina e devolve a lista inteira noSHOW COLUMNS, o que serve de documentação viva do domínio.
Gravar o
dt_atualizacaosó comON UPDATE CURRENT_TIMESTAMPesconde a hora da transição que não aconteceu. A coluna muda quando oUPDATEgrava, e oUPDATEguardado nem chega a gravar quando o estágio não confere — a data continua sendo a da última mudança real, que é o que a coluna promete.
Máquina de estados não é só para pedido. Assinatura, nota fiscal e cobrança têm o mesmo formato: um conjunto de estágios e um conjunto de caminhos permitidos. O que muda é o nome dos estágios, e a estrutura é a mesma.
O
409recusa sem gravar, e isso é o que permite repetir a operação com segurança depois de corrigir o que causava a recusa. Uma regra que devolve500no mesmo caso obriga quem chamou a abrir chamado em vez de corrigir e tentar de novo.
Exemplo
'use strict'; // Exemplo da aula 2 do dia 11: maquina de estados — quando o dado tem estagios. // // Um pedido nao e um registro com um campo livre: ele passa por estagios, e so // pode mudar de estagio de forma permitida. Sem essa regra, qualquer `UPDATE` // escreve qualquer coisa e um pedido entregue volta a "pago" porque alguem // digitou errado a rota. // // A parte que o exemplo mede e a que a aula do dia 10 nao cobre: o `UPDATE` // guardado, com o estagio esperado no proprio `WHERE`. // // UPDATE tb_d11a2_pedido SET st_status = ? // WHERE id = ? AND st_status = ? // // O `affectedRows` e a verificacao. Vale 1 quando o pedido estava no estagio // esperado e a mudanca aconteceu; vale 0 quando nao estava — e 0 nao e falha // do banco, e a RESPOSTA: "esse pedido nao estava onde voce achava que estava". // // O estado invalido e RECUSADO, nunca aceito: o `ENUM` da coluna e a ultima // linha de defesa, e o erro `WARN_DATA_TRUNCATED` e o que ela devolve. const http = require('node:http'); const mysql = require('mysql2/promise'); // ============================================ 1. a tabela de transicoes // A regra do dominio escrita como dado, e nao como `if` espalhado pelo // codigo: de qual estagio pode ir para qual, e qual condicao cada transicao // exige. Quem muda a regra muda este objeto, e nao cinco rotas. const TRANSICOES = { novo: ['pago', 'cancelado'], pago: ['enviado', 'cancelado'], enviado: ['entregue'], entregue: [], cancelado: [], }; // `entregue` e `cancelado` nao tem linha aqui de proposito: sao estagios // finais. A ausencia da chave no objeto ja e a proibicao — nao existe para // onde ir, e nao ha como pedir. function podeIr(de, para) { return Array.isArray(TRANSICOES[de]) && TRANSICOES[de].includes(para); } class ErroDeNegocio extends Error { constructor(mensagem, status, codigo) { super(mensagem); this.name = 'ErroDeNegocio'; this.status = status; this.codigo = codigo; } } // ============================================ 2. a funcao que muda o estado // `mudarEstado(c, id, de, para)` devolve o registro atualizado ou lanca. // // O detalhe que faz a funcao segura e o `de` no `WHERE`. Sem ele, duas // requisicoes que chegam ao mesmo tempo leem o mesmo `st_status`, as duas // escrevem, e a segunda sobrescreve a primeira sem saber o que perdeu. async function mudarEstado(c, id, de, para) { // A primeira barreira e a tabela de transicoes, e ela e de dominio: nao // existe `ENTREGUE -> PAGO` em loja nenhuma. Um `409` aqui diz que o // pedido esta num estagio que nao tem essa saida. // // A ordem das duas primeiras checagens importa: `pago -> pago` nao esta na // tabela de transicoes (uma estagio nao transiciona para si mesmo), e por // isso o caso "ja esta no estagio" precisa ser testado ANTES da tabela. Na // ordem invertida, o cliente receberia "transicao invalida" para um pedido // que ele simplesmente ja tinha pago — mensagem errada para o caso comum // de duplo clique no botao. if (de === para) { throw new ErroDeNegocio( 'o pedido ja esta em "' + de + '"', 409, 'JA_ESTA_NO_ESTAGIO'); } if (!podeIr(de, para)) { throw new ErroDeNegocio( 'transicao invalida: de "' + de + '" para "' + para + '"', 409, 'TRANSICAO_INVALIDA'); } // A segunda barreira e o banco. O `st_status = ?` no `WHERE` transforma a // mudanca em condicional: so grava se o pedido ainda estiver no estagio que // o chamador leu. O `dt_atualizacao` vai junto no mesmo `SET`. const [r] = await c.execute( 'UPDATE tb_d11a2_pedido SET st_status = ?, dt_atualizacao = CURRENT_TIMESTAMP' + ' WHERE id = ? AND st_status = ?', [para, id, de]); // `affectedRows === 0` e a resposta do banco dizendo que nao havia linha // nesse estagio. Nao e erro de SQL, e informacao: o pedido existe mas mudou // de estagio entre a leitura e a escrita, ou nunca esteve onde o chamador // achou que estava. Um `404` aqui mente — o pedido existe. if (r.affectedRows === 0) { const [linhas] = await c.execute( 'SELECT st_status FROM tb_d11a2_pedido WHERE id = ?', [id]); if (linhas.length === 0) { throw new ErroDeNegocio('pedido nao encontrado', 404, 'PEDIDO_INEXISTENTE'); } throw new ErroDeNegocio( 'pedido esta em "' + linhas[0].st_status + '", e nao em "' + de + '"', 409, 'ESTAGIO_NAO_CONFERE'); } const [atualizado] = await c.execute( 'SELECT id, id_cliente, st_status, dt_atualizacao' + ' FROM tb_d11a2_pedido WHERE id = ?', [id]); return atualizado[0]; } // ============================================== 3. a reserva de estoque // Regra que depende de estado, e por isso vai na MESMA transacao da mudanca // de estado. Separar as duas e o caminho para o estoque que nunca baixa. // // A mudanca de estado e a leitura do estoque nao sao a mesma consulta, e por // isso precisam estar na MESMA transacao: uma confirma e a outra desfaz. // // O `rollback` DEVOLVE os dados. E ele que desiste do `UPDATE` de estoque // quando o pedido nao consegue mais sair do estagio — e e por isso que o // `affectedRows` do estoque volta ao valor anterior sem ninguem ter escrito // nada nele. async function aplicarPagamento(c, idPedido) { await c.beginTransaction(); try { const pedido = await mudarEstado(c, idPedido, 'novo', 'pago'); const [itens] = await c.execute( 'SELECT id_produto, qtd FROM tb_d11a2_item_pedido WHERE id_pedido = ?', [idPedido]); for (const item of itens) { // `SELECT ... FOR UPDATE` trava a linha do produto ate o fim da // transacao. Sem ele, duas confirmacoes leem o mesmo estoque e as duas // enxergam que ha unidades — o `FOR UPDATE` e o que faz a segunda // esperar a primeira terminar para entao ler o numero ja atualizado. const [produtos] = await c.execute( 'SELECT qtd_estoque FROM tb_d11a2_produto WHERE id = ? FOR UPDATE', [item.id_produto]); if (produtos[0].qtd_estoque < item.qtd) { throw new ErroDeNegocio('estoque insuficiente para o produto ' + item.id_produto, 409, 'ESTOQUE_INSUFICIENTE'); } await c.execute( 'UPDATE tb_d11a2_produto SET qtd_estoque = qtd_estoque - ? WHERE id = ?', [item.qtd, item.id_produto]); } await c.commit(); return pedido; } catch (erro) { // O `rollback` volta os dados: o `UPDATE` de estoque que rodou dentro da // transacao e desfeito, e o pedido volta a "novo". E por isso que a soma // do estoque medida depois de um pagamento recusado volta ao numero de // antes, sem nenhuma consulta de correcao. await c.rollback(); throw erro; } } async function main() { const c = await mysql.createConnection({ host: process.env.DB_HOST, port: Number(process.env.DB_PORT), user: process.env.DB_USER, password: process.env.DB_PASS, // so em memoria: o valor esta no `.env` database: process.env.DB_NAME, }); const [versao] = await c.query('SELECT VERSION() AS versao'); console.log('banco em uso:', versao[0].versao); // O `ENUM` no `st_status` e a barreira do banco: ele recusa um estagio que // nao esta na lista, e o erro que sai e `WARN_DATA_TRUNCATED`. A tabela de // transicoes acima e a barreira do dominio; as duas juntas sao o que impede // o `UPDATE` de escrever qualquer coisa. await c.query(` CREATE TABLE IF NOT EXISTS tb_d11a2_produto ( id INT AUTO_INCREMENT PRIMARY KEY, nm_produto VARCHAR(40) NOT NULL, qtd_estoque INT NOT NULL, vl_unitario DECIMAL(10,2) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `); await c.query(` CREATE TABLE IF NOT EXISTS tb_d11a2_pedido ( id INT AUTO_INCREMENT PRIMARY KEY, id_cliente INT NOT NULL, st_status ENUM('novo','pago','enviado','entregue','cancelado') NOT NULL DEFAULT 'novo', vl_total DECIMAL(10,2) NOT NULL, dt_criacao DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, dt_atualizacao DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `); await c.query(` CREATE TABLE IF NOT EXISTS tb_d11a2_item_pedido ( id INT AUTO_INCREMENT PRIMARY KEY, id_pedido INT NOT NULL, id_produto INT NOT NULL, qtd INT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `); // No comeco, nunca no fim: rodar duas vezes tem de dar o mesmo resultado. await c.query('TRUNCATE TABLE tb_d11a2_item_pedido'); await c.query('TRUNCATE TABLE tb_d11a2_pedido'); await c.query('TRUNCATE TABLE tb_d11a2_produto'); await c.execute( 'INSERT INTO tb_d11a2_produto (nm_produto, qtd_estoque, vl_unitario) VALUES (?, ?, ?)', ['teclado', 10, 150]); await c.execute( 'INSERT INTO tb_d11a2_pedido (id_cliente, st_status, vl_total) VALUES (?, ?, ?), (?, ?, ?)', [1, 'novo', 150, 2, 'novo', 90]); await c.execute( 'INSERT INTO tb_d11a2_item_pedido (id_pedido, id_produto, qtd) VALUES (?, ?, ?)', [1, 1, 1]); const [itensGravados] = await c.execute( 'SELECT id_pedido, id_produto, qtd FROM tb_d11a2_item_pedido'); console.log('pedido 1: cliente 1, novo, 150 (1 teclado)'); console.log('pedido 2: cliente 2, novo, 90 (1 teclado)'); console.log('itens do pedido: ' + itensGravados.length + ' (so o pedido 1 tem item gravado; o pedido 2 fica sem item de proposito)'); console.log('o pedido 2 sem item e o que faz o pagamento dele passar reto, sem tocar'); console.log('em estoque nenhum — o cancelamento dele e medido no bloco 6.'); // ------------------------------------------------------- a tabela de transicoes console.log('\n--- 1. a tabela de transicoes, e o que ela proibe ---'); for (const [de, para] of Object.entries(TRANSICOES)) { const saida = para.length === 0 ? '(estagio final, sem saida)' : para.join(', '); console.log(' ' + de.padEnd(10) + '-> ' + saida); } console.log(' entradas:' ); console.log(' novo -> pago ' + podeIr('novo', 'pago')); console.log(' novo -> entregue ' + podeIr('novo', 'entregue') + ' (pula o pagamento)'); console.log(' pago -> enviado ' + podeIr('pago', 'enviado')); console.log(' enviado -> entregue ' + podeIr('enviado', 'entregue')); console.log(' entregue -> pago ' + podeIr('entregue', 'pago') + ' (reabrir um pedido entregue)'); console.log(' cancelado -> pago ' + podeIr('cancelado', 'pago')); // ------------------------------------------------------- a maquina rodando const rotas = { 'POST /pedido': async (corpo) => { const pedido = await aplicarPagamento(c, Number(corpo.id)); return { status: 200, corpo: pedido }; }, 'POST /avancar': async (corpo) => { const pedido = await mudarEstado(c, Number(corpo.id), String(corpo.de), String(corpo.para)); return { status: 200, corpo: pedido }; }, // A rota que mostra o `ENUM` recusando. Nao ha transicao para este // estagio na tabela acima, e o dominio nem chega a ser consultado: o banco // recusa o `INSERT`. 'POST /estado-invalido': async () => { try { await c.execute( 'UPDATE tb_d11a2_pedido SET st_status = ? WHERE id = ?', ['deixado', 1]); } catch (erro) { console.error('estado invalido recusado: ' + erro.code + ' - ' + erro.message); console.log(' erro:', erro.code, 'errno', erro.errno, '-', erro.message); const [colunas] = await c.query('SHOW COLUMNS FROM tb_d11a2_pedido'); const st = colunas.find((col) => col.Field === 'st_status'); console.log(' a coluna: ' + st.Type); console.log(' o estado recusado nao foi gravado, e o pedido segue como estava'); return { status: 400, corpo: { erro: 'estado invalido' } }; } return { status: 500, corpo: { erro: 'inesperado' } }; }, }; const servidor = http.createServer(async (req, res) => { const responder = (status, corpo) => { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(corpo)); }; const partes = []; for await (const p of req) partes.push(p); const bruto = Buffer.concat(partes).toString('utf8'); const dados = bruto ? JSON.parse(bruto) : {}; const chave = req.method + ' ' + req.url.split('?')[0]; const rota = rotas[chave]; console.log(chave + ' corpo: ' + (bruto || '(vazio)')); if (!rota) return responder(404, { erro: 'rota nao encontrada' }); try { const r = await rota(dados); return responder(r.status, r.corpo); } catch (erro) { // O `catch` traduz a classe em resposta, e em log. `ER_DUP_ENTRY` nao // entra aqui nesta aula, mas a traducao e a mesma: `instanceof` decide. if (erro instanceof ErroDeNegocio) { console.log(' erro de negocio: ' + erro.codigo + ' -> status ' + erro.status); console.log(' para o cliente: ' + erro.message); return responder(erro.status, { erro: erro.message, codigo: erro.codigo }); } console.error('erro tecnico: ' + (erro.code || erro.name) + ' - ' + erro.message); console.log(' erro tecnico:', erro.code || erro.name, '-', erro.message); return responder(500, { erro: 'erro interno' }); } }); await new Promise((r) => servidor.listen(0, '127.0.0.1', r)); const base = 'http://127.0.0.1:' + servidor.address().port; console.log('\nservidor no ar em ' + base); console.log('a porta muda a cada execucao: e o listen(0) pedindo uma livre\n'); const pedir = async (caminho, corpo) => { const r = await fetch(base + caminho, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(corpo), }); return { status: r.status, corpo: JSON.parse(await r.text()) }; }; const estoqueAgora = async () => { const [linhas] = await c.execute('SELECT qtd_estoque FROM tb_d11a2_produto WHERE id = 1'); return linhas[0].qtd_estoque; }; let pool = null; // declarado aqui para o `finally` poder fechar o pool try { console.log('--- 2. o fluxo inteiro, estagio por estagio ---'); console.log('estoque antes de tudo: ' + await estoqueAgora()); const pago = await pedir('/pedido', { id: 1 }); console.log(' pagar o pedido 1 -> ' + pago.status + ' ' + JSON.stringify(pago.corpo)); console.log(' estoque depois : ' + await estoqueAgora() + ' (10 -> 9: a baixa de estoque e a MESMA transacao da mudanca de estado)'); const enviado = await pedir('/avancar', { id: 1, de: 'pago', para: 'enviado' }); console.log(' enviar o pedido 1 -> ' + enviado.status + ' ' + JSON.stringify(enviado.corpo)); const entregue = await pedir('/avancar', { id: 1, de: 'enviado', para: 'entregue' }); console.log(' entregar o pedido 1 -> ' + entregue.status + ' ' + JSON.stringify(entregue.corpo)); console.log('\n--- 3. transicoes recusadas: 409, e o pedido nao se mexe ---'); const reabrir = await pedir('/avancar', { id: 1, de: 'entregue', para: 'pago' }); console.log(' entregue -> pago -> ' + reabrir.status + ' ' + JSON.stringify(reabrir.corpo)); const pular = await pedir('/avancar', { id: 1, de: 'novo', para: 'entregue' }); console.log(' novo -> entregue -> ' + pular.status + ' ' + JSON.stringify(pular.corpo)); const voltar = await pedir('/avancar', { id: 1, de: 'pago', para: 'pago' }); console.log(' pago -> pago -> ' + voltar.status + ' ' + JSON.stringify(voltar.corpo)); const inexistente = await pedir('/avancar', { id: 999, de: 'novo', para: 'pago' }); console.log(' pedido 999 -> ' + inexistente.status + ' ' + JSON.stringify(inexistente.corpo)); const [estadoFinal] = await c.execute( 'SELECT id, st_status FROM tb_d11a2_pedido ORDER BY id'); console.log(' os dois pedidos continuam: ' + JSON.stringify(estadoFinal.map((p) => p.id + '=' + p.st_status))); console.log(' nenhuma das quatro recusadas escreveu uma linha no banco'); console.log('\n--- 4. o `ENUM` do banco recusando o estado invalido ---'); const invalido = await pedir('/estado-invalido', {}); console.log(' para o cliente -> ' + invalido.status + ' ' + JSON.stringify(invalido.corpo)); const [depois] = await c.execute('SELECT st_status FROM tb_d11a2_pedido WHERE id = 1'); console.log(' pedido 1 continua: ' + depois[0].st_status); console.log('\n--- 5. o `affectedRows` como verificador, fora do HTTP ---'); console.log(' o pedido 1 esta em "' + depois[0].st_status + '". Dois UPDATE guardado:'); const [certo] = await c.execute( 'UPDATE tb_d11a2_pedido SET st_status = ? WHERE id = ? AND st_status = ?', ['entregue', 1, 'entregue']); console.log(' de "' + depois[0].st_status + '" para "entregue" (o estagio real): affectedRows=' + certo.affectedRows + ' <- 1 linha, gravou'); const [errado] = await c.execute( 'UPDATE tb_d11a2_pedido SET st_status = ? WHERE id = ? AND st_status = ?', ['pago', 1, 'pago']); console.log(' de "pago" para "pago" (o estagio que o pedido nao tem): affectedRows=' + errado.affectedRows + ' <- 0 linhas, e a resposta'); console.log(' o 0 nao e erro de SQL: e o banco dizendo que nao havia linha naquele'); console.log(' estagio. Quem traduz o 0 em 409 e o codigo, nao o banco.'); console.log('\n--- 6. cancelamento: a unica saida que o `novo` tem ---'); const cancelado = await pedir('/avancar', { id: 2, de: 'novo', para: 'cancelado' }); console.log(' cancelar o pedido 2 -> ' + cancelado.status + ' ' + JSON.stringify(cancelado.corpo)); const cancelaEntregue = await pedir('/avancar', { id: 1, de: 'entregue', para: 'cancelado' }); console.log(' cancelar o pedido 1 (entregue) -> ' + cancelaEntregue.status + ' ' + JSON.stringify(cancelaEntregue.corpo)); console.log(' estoque depois do cancelamento: ' + await estoqueAgora() + ' (cancelar antes de pagar nao devolve e nao consome: o pedido 2 nunca pagou)'); console.log('\n--- 7. a corrida de verdade, e por que o `affectedRows` e lido ---'); console.log(' Duas confirmacoes do MESMO pedido 3, disparadas juntas com `Promise.all`.'); console.log(' Para que a corrida exista de verdade sao necessarias DUAS conexoes:'); console.log(' com uma conexao so, o mysql2 coloca a segunda consulta na fila e ela'); console.log(' espera a primeira terminar, e nao ha corrida para medir. O exemplo usa'); console.log(' um `createPool` com `connectionLimit: 2` por esse motivo.'); await c.execute( 'INSERT INTO tb_d11a2_pedido (id_cliente, st_status, vl_total) VALUES (?, ?, ?)', [3, 'novo', 150]); await c.execute( 'INSERT INTO tb_d11a2_item_pedido (id_pedido, id_produto, qtd) VALUES (?, ?, ?)', [3, 1, 1]); await c.execute('UPDATE tb_d11a2_produto SET qtd_estoque = ? WHERE id = 1', [10]); const [antesDisputa] = await c.execute( 'SELECT qtd_estoque FROM tb_d11a2_produto WHERE id = 1'); console.log(' estoque antes: ' + antesDisputa[0].qtd_estoque); // `paginaDoPool` e o mesmo `aplicarPagamento`, so que na conexao que o pool // entregou. A funcao e a mesma para as duas confirmacoes: o que muda e // que agora elas correm em transacoes separadas. pool = mysql.createPool({ host: process.env.DB_HOST, port: Number(process.env.DB_PORT), user: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME, connectionLimit: 2, }); const paginaDoPool = async (corpo) => { const conn = await pool.getConnection(); try { const pedido = await aplicarPagamento(conn, Number(corpo.id)); return { status: 200, corpo: pedido }; } finally { conn.release(); } }; // O `catch` traduz o erro que a funcao acima deixa subir, igual ao do // servidor: a corrida nao muda como o erro vira resposta. const confirmarPeloPool = (corpo) => paginaDoPool(corpo).catch((erro) => { if (erro instanceof ErroDeNegocio) { return { status: erro.status, corpo: { erro: erro.message, codigo: erro.codigo } }; } throw erro; }); const [a, b] = await Promise.all([ confirmarPeloPool({ id: 3 }), confirmarPeloPool({ id: 3 }), ]); console.log(' confirmacao A -> ' + a.status + ' ' + JSON.stringify(a.corpo)); console.log(' confirmacao B -> ' + b.status + ' ' + JSON.stringify(b.corpo)); const [depoisDisputa] = await c.execute( 'SELECT qtd_estoque FROM tb_d11a2_produto WHERE id = 1'); console.log(' estoque depois: ' + depoisDisputa[0].qtd_estoque + ' (10 -> 9: baixou uma vez so, e nao duas)'); console.log(' uma das duas recebeu 409, e foi a que leu `affectedRows = 0` e'); console.log(' ABORTOU antes de tocar no estoque. A outra leu 1 e seguiu.'); // --- o mesmo cenario, sem a conferida no `affectedRows` --- // // Aqui o `st_status = ?` continua no `WHERE`, e o estoque continua com // `FOR UPDATE`. O que falta e ler o numero. A segunda transacao le // `affectedRows = 0`, ignora, e baixa o estoque assim mesmo. console.log('\n agora o MESMO cenario sem a conferida no `affectedRows`:'); await c.execute('TRUNCATE TABLE tb_d11a2_item_pedido'); await c.execute('TRUNCATE TABLE tb_d11a2_pedido'); await c.execute('UPDATE tb_d11a2_produto SET qtd_estoque = ? WHERE id = 1', [10]); await c.execute( 'INSERT INTO tb_d11a2_pedido (id_cliente, st_status, vl_total) VALUES (?, ?, ?)', [4, 'novo', 150]); await c.execute( 'INSERT INTO tb_d11a2_item_pedido (id_pedido, id_produto, qtd) VALUES (?, ?, ?)', [1, 1, 1]); const pagamentoSemConferir = async (conn) => { await conn.beginTransaction(); try { // O `UPDATE` guardado e IDENTICO ao de `mudarEstado`, e a unica // diferenca e que o numero devolvido e jogado fora. E esse o defeito // inteiro: a guarda funcionou, e ninguem leu o que ela devolveu. await conn.execute( 'UPDATE tb_d11a2_pedido SET st_status = ?, dt_atualizacao = CURRENT_TIMESTAMP' + ' WHERE id = ? AND st_status = ?', ['pago', 1, 'novo']); const [produtos] = await conn.execute( 'SELECT qtd_estoque FROM tb_d11a2_produto WHERE id = 1 FOR UPDATE'); if (produtos[0].qtd_estoque >= 1) { await conn.execute( 'UPDATE tb_d11a2_produto SET qtd_estoque = qtd_estoque - ? WHERE id = 1', [1]); } await conn.commit(); return 'baixou o estoque'; } catch (erro) { await conn.rollback(); return 'recusou: ' + (erro.codigo || erro.name); } }; const correrSemConferir = async () => { const conn = await pool.getConnection(); try { return await pagamentoSemConferir(conn); } finally { conn.release(); } }; const [c1, c2] = await Promise.all([correrSemConferir(), correrSemConferir()]); console.log(' A: ' + c1); console.log(' B: ' + c2); const [depoisDefeito] = await c.execute( 'SELECT qtd_estoque FROM tb_d11a2_produto WHERE id = 1'); console.log(' estoque depois: ' + depoisDefeito[0].qtd_estoque + ' (10 -> 8: DUAS unidades baixadas por UM pedido)'); console.log(' as duas transacoes escreveram no estoque, e so uma delas tinha o'); console.log(' direito de escrever. A guarda no `WHERE` funcionou nas duas — o que'); console.log(' faltou foi o codigo OLHAR o que ela devolveu.'); console.log('\n e essa e a razao de o bloco 5 existir: o `affectedRows` nao e um'); console.log(' detalhe da resposta do banco, e a unica prova de que a mudanca'); console.log(' aconteceu. Ignorar o numero e o defeito inteiro desta aula.'); console.log('\n--- 8. o diagrama de estados em texto ---'); console.log(' novo --pagar--> pago --enviar--> enviado --entregar--> entregue'); console.log(' | |'); console.log(' | |'); console.log(' cancelar cancelar'); console.log(' | |'); console.log(' v v'); console.log(' cancelado <-+ (o cancelamento e so possivel antes do envio)'); console.log(' a tabela `TRANSICOES` acima e este desenho em forma de dado, e e ela'); console.log(' que decide. Trocar o fluxo e editar o objeto, nao as rotas.'); const [conferencia] = await c.execute( 'SELECT st_status, COUNT(*) AS n FROM tb_d11a2_pedido GROUP BY st_status ORDER BY st_status'); console.log('\n os pedidos no fim: ' + JSON.stringify( Object.fromEntries(conferencia.map((l) => [l.st_status, l.n])))); } finally { await new Promise((r) => servidor.close(r)); console.log('\nservidor encerrado com close().'); // O pool tambem fecha: sem o `end()`, as duas conexoes dele continuam // segurando o event loop e o processo nao termina. if (pool) await pool.end(); await c.end(); } } main().catch((erro) => { console.error('falhou:', erro.code || erro.name, '-', erro.message); process.exit(1); });
Saída real
banco em uso: 10.11.14-MariaDB-0ubuntu0.24.04.1
pedido 1: cliente 1, novo, 150 (1 teclado)
pedido 2: cliente 2, novo, 90 (1 teclado)
itens do pedido: 1 (so o pedido 1 tem item gravado; o pedido 2 fica sem item de proposito)
o pedido 2 sem item e o que faz o pagamento dele passar reto, sem tocar
em estoque nenhum — o cancelamento dele e medido no bloco 6.
--- 1. a tabela de transicoes, e o que ela proibe ---
novo -> pago, cancelado
pago -> enviado, cancelado
enviado -> entregue
entregue -> (estagio final, sem saida)
cancelado -> (estagio final, sem saida)
entradas:
novo -> pago true
novo -> entregue false (pula o pagamento)
pago -> enviado true
enviado -> entregue true
entregue -> pago false (reabrir um pedido entregue)
cancelado -> pago false
servidor no ar em http://127.0.0.1:36275
a porta muda a cada execucao: e o listen(0) pedindo uma livre
--- 2. o fluxo inteiro, estagio por estagio ---
estoque antes de tudo: 10
POST /pedido corpo: {"id":1}
pagar o pedido 1 -> 200 {"id":1,"id_cliente":1,"st_status":"pago","dt_atualizacao":"2026-09-30T15:16:17.000Z"}
estoque depois : 9 (10 -> 9: a baixa de estoque e a MESMA transacao da mudanca de estado)
POST /avancar corpo: {"id":1,"de":"pago","para":"enviado"}
enviar o pedido 1 -> 200 {"id":1,"id_cliente":1,"st_status":"enviado","dt_atualizacao":"2026-09-30T15:16:17.000Z"}
POST /avancar corpo: {"id":1,"de":"enviado","para":"entregue"}
entregar o pedido 1 -> 200 {"id":1,"id_cliente":1,"st_status":"entregue","dt_atualizacao":"2026-09-30T15:16:17.000Z"}
--- 3. transicoes recusadas: 409, e o pedido nao se mexe ---
POST /avancar corpo: {"id":1,"de":"entregue","para":"pago"}
erro de negocio: TRANSICAO_INVALIDA -> status 409
para o cliente: transicao invalida: de "entregue" para "pago"
entregue -> pago -> 409 {"erro":"transicao invalida: de \"entregue\" para \"pago\"","codigo":"TRANSICAO_INVALIDA"}
POST /avancar corpo: {"id":1,"de":"novo","para":"entregue"}
erro de negocio: TRANSICAO_INVALIDA -> status 409
para o cliente: transicao invalida: de "novo" para "entregue"
novo -> entregue -> 409 {"erro":"transicao invalida: de \"novo\" para \"entregue\"","codigo":"TRANSICAO_INVALIDA"}
POST /avancar corpo: {"id":1,"de":"pago","para":"pago"}
erro de negocio: JA_ESTA_NO_ESTAGIO -> status 409
para o cliente: o pedido ja esta em "pago"
pago -> pago -> 409 {"erro":"o pedido ja esta em \"pago\"","codigo":"JA_ESTA_NO_ESTAGIO"}
POST /avancar corpo: {"id":999,"de":"novo","para":"pago"}
erro de negocio: PEDIDO_INEXISTENTE -> status 404
para o cliente: pedido nao encontrado
pedido 999 -> 404 {"erro":"pedido nao encontrado","codigo":"PEDIDO_INEXISTENTE"}
os dois pedidos continuam: ["1=entregue","2=novo"]
nenhuma das quatro recusadas escreveu uma linha no banco
--- 4. o `ENUM` do banco recusando o estado invalido ---
POST /estado-invalido corpo: {}
erro: WARN_DATA_TRUNCATED errno 1265 - Data truncated for column 'st_status' at row 1
a coluna: enum('novo','pago','enviado','entregue','cancelado')
o estado recusado nao foi gravado, e o pedido segue como estava
para o cliente -> 400 {"erro":"estado invalido"}
pedido 1 continua: entregue
--- 5. o `affectedRows` como verificador, fora do HTTP ---
o pedido 1 esta em "entregue". Dois UPDATE guardado:
de "entregue" para "entregue" (o estagio real): affectedRows=1 <- 1 linha, gravou
de "pago" para "pago" (o estagio que o pedido nao tem): affectedRows=0 <- 0 linhas, e a resposta
o 0 nao e erro de SQL: e o banco dizendo que nao havia linha naquele
estagio. Quem traduz o 0 em 409 e o codigo, nao o banco.
--- 6. cancelamento: a unica saida que o `novo` tem ---
POST /avancar corpo: {"id":2,"de":"novo","para":"cancelado"}
cancelar o pedido 2 -> 200 {"id":2,"id_cliente":2,"st_status":"cancelado","dt_atualizacao":"2026-09-30T15:16:17.000Z"}
POST /avancar corpo: {"id":1,"de":"entregue","para":"cancelado"}
erro de negocio: TRANSICAO_INVALIDA -> status 409
para o cliente: transicao invalida: de "entregue" para "cancelado"
cancelar o pedido 1 (entregue) -> 409 {"erro":"transicao invalida: de \"entregue\" para \"cancelado\"","codigo":"TRANSICAO_INVALIDA"}
estoque depois do cancelamento: 9 (cancelar antes de pagar nao devolve e nao consome: o pedido 2 nunca pagou)
--- 7. a corrida de verdade, e por que o `affectedRows` e lido ---
Duas confirmacoes do MESMO pedido 3, disparadas juntas com `Promise.all`.
Para que a corrida exista de verdade sao necessarias DUAS conexoes:
com uma conexao so, o mysql2 coloca a segunda consulta na fila e ela
espera a primeira terminar, e nao ha corrida para medir. O exemplo usa
um `createPool` com `connectionLimit: 2` por esse motivo.
estoque antes: 10
confirmacao A -> 200 {"id":3,"id_cliente":3,"st_status":"pago","dt_atualizacao":"2026-09-30T15:16:17.000Z"}
confirmacao B -> 409 {"erro":"pedido esta em \"pago\", e nao em \"novo\"","codigo":"ESTAGIO_NAO_CONFERE"}
estoque depois: 9 (10 -> 9: baixou uma vez so, e nao duas)
uma das duas recebeu 409, e foi a que leu `affectedRows = 0` e
ABORTOU antes de tocar no estoque. A outra leu 1 e seguiu.
agora o MESMO cenario sem a conferida no `affectedRows`:
A: baixou o estoque
B: baixou o estoque
estoque depois: 8 (10 -> 8: DUAS unidades baixadas por UM pedido)
as duas transacoes escreveram no estoque, e so uma delas tinha o
direito de escrever. A guarda no `WHERE` funcionou nas duas — o que
faltou foi o codigo OLHAR o que ela devolveu.
e essa e a razao de o bloco 5 existir: o `affectedRows` nao e um
detalhe da resposta do banco, e a unica prova de que a mudanca
aconteceu. Ignorar o numero e o defeito inteiro desta aula.
--- 8. o diagrama de estados em texto ---
novo --pagar--> pago --enviar--> enviado --entregar--> entregue
| |
| |
cancelar cancelar
| |
v v
cancelado <-+ (o cancelamento e so possivel antes do envio)
a tabela `TRANSICOES` acima e este desenho em forma de dado, e e ela
que decide. Trocar o fluxo e editar o objeto, nao as rotas.
os pedidos no fim: {"pago":1}
servidor encerrado com close().