Notícias19 min de leitura

Trigger no PostgreSQL: como disparar uma API direto do banco

Passo a passo para disparar uma API com trigger no PostgreSQL usando pg_net: como testar, prós, contras e quando o webhook direto do banco não basta.

  • postgresql
  • trigger
  • webhook
  • pg_net
  • api
  • outbox

Você gravou um pedido e precisa avisar um sistema de fora. A tentação é um trigger no PostgreSQL que dá um POST e acabou. Dá para disparar uma API direto do banco. O Postgres, sozinho, não fala HTTP: quem faz a chamada é uma extensão, ou um processo que lê uma fila que o trigger só enfileirou.

Os três caminhos não são equivalentes. Um espera a API e segura o INSERT. Outro dispara depois do COMMIT e pode perder o evento. O terceiro, o outbox, é o único que eu usaria quando o webhook não pode sumir. Este texto é o passo a passo do laboratório com pg_net, os testes que evitam falsa confiança, e o ponto em que vale parar e tirar o HTTP do banco.

O que um trigger faz, e o que ele não faz

Um trigger é uma função que o PostgreSQL executa por causa de um INSERT, UPDATE, DELETE ou TRUNCATE. Em AFTER INSERT ... FOR EACH ROW, a função vê a linha nova em NEW. Em UPDATE, OLD é o valor anterior e NEW é o novo. Em DELETE, só existe OLD.

Isso resolve consistência dentro do banco: preencher coluna, impedir um estado inválido, gravar auditoria na mesma transação. Não resolve rede. Uma chamada HTTP não entra no COMMIT. Se a API responder 201 e a transação der rollback em seguida, o sistema de fora já acha que o pedido existe. Se a API cair e você engolir o erro, o pedido existe e ninguém ficou sabendo.

LISTEN/NOTIFY também não é webhook. pg_notify acorda quem estiver escutando. Se ninguém escuta, o aviso se perde. Não há retry, não há fila, e o payload para em 8 KB. Serve para acordar um worker. Não serve para entregar um evento.

Três jeitos de reagir a uma mudança

Caminho O INSERT espera a API? Se der rollback Se a API cair Retry RDS / Aurora
Extensão http (pgsql-http) Sim A chamada pode já ter saído Pode desfazer o INSERT, ou perder o evento Não Quase nunca
pg_net Não. Só sai depois do COMMIT Não dispara O INSERT fica. O evento se perde Não Não
Outbox + worker Não A linha da fila também desfaz O evento continua na tabela Sim Sim

A extensão http, síncrona

A pgsql-http (CREATE EXTENSION http) chama a API na hora, com cURL, e devolve status e corpo. A sessão fica parada até o timeout. Dentro de um trigger, o INSERT fica parado junto, segurando lock.

Há um detalhe que quebra o primeiro teste: http_post(url, jsonb) não manda JSON. Manda application/x-www-form-urlencoded. JSON é a sobrecarga de três argumentos:

select status, content
from http_post(
  'https://httpbin.org/post',
  '{"ping": true}',
  'application/json'
);

Ela aceita GET, POST, PUT, PATCH, DELETE e HEAD. Útil em script, migração e operação rara, quando você precisa da resposta para decidir o que fazer. Em tabela quente, a latência da API vira latência do banco. Um 500 não tratado desfaz o INSERT. Um EXCEPTION que engole o erro grava o dado e perde o aviso. Os dois resultados são ruins, e os dois são fáceis de escrever.

Se a resposta da API precisa decidir a linha, faça isso na aplicação, com timeout seu. Trigger síncrono é o lugar errado para esperar rede.

pg_net, assíncrona

O pg_net coloca o pedido numa fila e um worker em background faz o HTTP. A função devolve na hora um bigint (o id do pedido), não a resposta. A documentação é explícita: a requisição não começa antes do COMMIT. Rollback desfaz a linha da fila, e o webhook não sai. É o comportamento certo, e é o que a maioria das pessoas quer dizer com "disparar no trigger".

O worker cobre net.http_get, net.http_post (só JSON) e net.http_delete. Não há PUT nem PATCH. O timeout padrão é 2000 ms. A resposta cai em net._http_response e, por padrão, some em 6 horas (pg_net.ttl). Fila e resposta são tabelas unlogged: crash ou desligamento sujo apaga o que ainda não foi entregue, e isso não vai para réplica.

Os limites que importam em produção, da própria extensão:

  • entrega no máximo uma vez, sem retry automático
  • cerca de 200 requisições por segundo; passar disso instabiliza
  • um banco por cluster, o padrão é postgres (pg_net.database_name)
  • a API ainda está em beta, e a assinatura das funções pode mudar

Os Database Webhooks do Supabase são um atalho em cima disso: um trigger que chama pg_net (no dashboard, costuma aparecer supabase_functions.http_request). O SQL deste artigo é o mesmo mecanismo, sem a tela.

Outbox transacional

O trigger não chama ninguém. Na mesma transação do pedido, ele insere um evento numa tabela outbox. Ou os dois commitaram, ou nenhum. Um worker (a aplicação, ou pg_cron lendo a tabela) entrega, tenta de novo com backoff e, depois de N falhas, manda para dead letter.

A entrega é pelo menos uma vez. O receptor precisa ser idempotente. NOTIFY só acorda o worker; um poll de poucos segundos cobre o que o notify perde. Funciona em qualquer PostgreSQL, inclusive onde não existe extensão HTTP.

Onde isso roda

Self-hosted: Postgres 12 ou mais (na prática, 14+), libcurl 7.83 ou mais, shared_preload_libraries = 'pg_net' e um restart. Depois, CREATE EXTENSION pg_net. A extensão cria o schema net. Se a aplicação não usa o banco postgres, ajuste pg_net.database_name e reinicie o worker (select net.worker_restart()). Sem isso, o trigger enfileira num banco e o worker lê outro. O evento nunca sai, e o INSERT parece sucesso.

No Supabase, a extensão já vem no preload. Habilite em Database, Extensions, ou:

create extension if not exists pg_net with schema extensions;

As funções continuam em net.http_post. O WITH SCHEMA extensions só evita instalar objeto em public.

Amazon RDS e Aurora não oferecem pg_net. Lá, o equivalente proprietário é invocar Lambda a partir do SQL (aws_lambda), que é outro produto, com outra conta e outro timeout. No Cloud SQL e em qualquer Postgres gerenciado, olhe a lista de extensões antes de desenhar a feature. Se CREATE EXTENSION pg_net falhar, o desenho é outbox, não trigger HTTP.

Laboratório: trigger com pg_net

A ordem evita o erro clássico: primeiro a chamada solta, depois o trigger. Suba o receptor antes do banco.

1. Um receptor mínimo

Na máquina host, não dentro do container do Postgres. Porta 8787 para não brigar com um next dev na 3000.

const http = require("node:http");

const TOKEN = "troque-isto";
const seen = new Set();

http
  .createServer((req, res) => {
    if (req.method !== "POST" || req.url !== "/webhooks/pedidos") {
      res.writeHead(404);
      res.end();
      return;
    }

    const chunks = [];
    req.on("data", (chunk) => chunks.push(chunk));
    req.on("end", () => {
      const raw = Buffer.concat(chunks).toString("utf8");
      if (req.headers["x-webhook-token"] !== TOKEN) {
        res.writeHead(401);
        res.end("token invalido");
        return;
      }

      const event = JSON.parse(raw);
      if (seen.has(event.event_id)) {
        console.log("duplicado", event.event_id);
        res.writeHead(200);
        res.end("duplicate");
        return;
      }

      seen.add(event.event_id);
      console.log("pedido", event.pedido.id, event.event_id);
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ ok: true }));
    });
  })
  .listen(8787, () => console.log("ouvindo em :8787"));
node receptor.js

Se o Postgres está no host, a URL do trigger é http://127.0.0.1:8787/webhooks/pedidos. Se está num container (Supabase local inclusive), localhost é o container, não a sua máquina. Use http://host.docker.internal:8787/webhooks/pedidos. No Docker do Linux isso só resolve se o compose tiver extra_hosts: ["host.docker.internal:host-gateway"].

2. Confirme a extensão, fora do trigger

create extension if not exists pg_net;

select net.http_post(
  url := 'https://httpbin.org/post',
  body := '{"ping": true}'::jsonb,
  headers := '{"Content-Type": "application/json"}'::jsonb,
  timeout_milliseconds := 5000
) as request_id;

Espere um ou dois segundos. O worker é assíncrono; consultar na mesma fração de segundo devolve vazio e parece bug.

select id, status_code, timed_out, error_msg, left(content, 180) as content, created
from net._http_response
order by created desc
limit 5;

status_code 200 e um corpo de eco: a extensão fala com a internet. Se a linha ficou em net.http_request_queue e não anda, o worker não está lendo este banco. Confira shared_preload_libraries e pg_net.database_name:

select name, setting
from pg_settings
where name like 'pg_net%';

select id, method, url
from net.http_request_queue;

3. Tabela, segredo e função

Não grave o token no corpo da função. pg_proc mostra o fonte para quem puder ler o catálogo, e um dump leva o segredo junto. No laboratório, um parâmetro de sessão. Em produção, o Vault (abaixo).

set app.webhook_secret = 'troque-isto';

Isso vale para a sessão atual. Para toda conexão nova: alter database postgres set app.webhook_secret = 'troque-isto'; e reconecte. Não é cofre. Qualquer papel que leia a configuração vê o valor. Serve para o teste não nascer com segredo no SQL do artigo.

A URL fica fixa na função. Nunca monte o endpoint com dado da linha. Um cliente que grave https://169.254.169.254/ num campo, e um trigger que use esse campo, é SSRF a partir do banco, com a rede do servidor.

search_path vazio e nomes qualificados. Sem isso, o trigger quebra quando o papel que fez o INSERT não enxerga o schema net — comum no Supabase, em que o papel muda conforme a origem da escrita.

create table public.pedidos (
  id bigint generated always as identity primary key,
  cliente text not null,
  total numeric(12, 2) not null check (total >= 0),
  status text not null default 'novo',
  criado_em timestamptz not null default now()
);

create or replace function public.notificar_pedido()
returns trigger
language plpgsql
set search_path = ''
as $$
declare
  secret text := current_setting('app.webhook_secret', true);
  event_id uuid := gen_random_uuid();
  request_id bigint;
begin
  if secret is null or secret = '' then
    raise exception 'app.webhook_secret não configurado';
  end if;

  request_id := net.http_post(
    url := 'http://host.docker.internal:8787/webhooks/pedidos',
    body := jsonb_build_object(
      'event_id', event_id,
      'event', 'pedido.criado',
      'occurred_at', now(),
      'pedido', jsonb_build_object(
        'id', new.id,
        'cliente', new.cliente,
        'total', new.total,
        'status', new.status
      )
    ),
    headers := jsonb_build_object(
      'Content-Type', 'application/json',
      'X-Webhook-Token', secret
    ),
    timeout_milliseconds := 5000
  );

  raise log 'webhook enfileirado request_id=% event_id=% pedido=%',
    request_id, event_id, new.id;

  return new;
end;
$$;

Troque a URL se o Postgres não está em Docker. gen_random_uuid() está no core desde o PostgreSQL 13; no 12, instale pgcrypto.

O payload manda os campos que o receptor precisa, não to_jsonb(new). Atalho de to_jsonb parece prático até alguém adicionar cpf ou token na tabela e o webhook passar a vazar.

Não envolva o net.http_post num EXCEPTION WHEN OTHERS que retorna NEW em silêncio. Essa exceção não é o 500 da API. A API ainda nem foi chamada. É falha de enfileirar: extensão ausente, permissão, URL nula. Engolir isso grava o pedido e perde o evento sem rastro. Deixe a transação falhar.

4. O trigger

AFTER, não BEFORE. Em BEFORE, a linha ainda pode ser recusada por outro trigger ou por uma constraint adiada, e você não quer notificar o que não entrou.

create trigger pedidos_notificar
after insert on public.pedidos
for each row
execute function public.notificar_pedido();

Em servidor anterior ao PostgreSQL 14, se o comando reclamar da sintaxe, use execute procedure. O efeito é o mesmo.

UPDATE e DELETE são o mesmo desenho, com tg_op no payload e old só quando existir. Em trigger AFTER, o valor de retorno é ignorado; mesmo assim, retorne NEW em insert/update e OLD em delete, para o dia em que alguém mudar para BEFORE.

FOR EACH ROW em carga grande vira uma requisição por linha. Mil inserts, mil POSTs, e o teto do pg_net é cerca de 200 por segundo. Para lote, FOR EACH STATEMENT com transition tables (referencing new table as novas) junta as linhas num JSON e faz um POST. Limite o tamanho do lote. Um body enorme também estoura o receptor.

Como testar sem se enganar

Cinco testes, nesta ordem. O primeiro sozinho só prova que o caminho feliz existe.

1. Commit entrega. Com o receptor ligado e o segredo configurado nesta sessão:

insert into public.pedidos (cliente, total)
values ('Ada', 42.50)
returning id;

O RETURNING volta na hora. O log do Node chega um instante depois. Se você consultar net._http_response no mesmo piscar de olhos e não achar a linha, espere e consulte de novo. Não é falha do trigger.

O corpo que o receptor vê:

{
  "event_id": "0c2f1a3e-7b8d-4e1f-9a6c-2d5e8f0a1b34",
  "event": "pedido.criado",
  "occurred_at": "2026-09-16T15:00:00+00:00",
  "pedido": { "id": 1, "cliente": "Ada", "total": 42.5, "status": "novo" }
}

2. A resposta fica numa tabela, por pouco tempo.

select id, status_code, timed_out, error_msg, created
from net._http_response
order by created desc
limit 10;

status_code >= 400, error_msg preenchido ou timed_out são falha. Essa tabela não é log de auditoria: unlogged, 6 horas, some no crash. Se você precisa do histórico, grave fora dela.

3. Rollback não dispara. Este é o teste que o pg_net acerta e a extensão http erra, se a chamada síncrona já tiver saído.

begin;
insert into public.pedidos (cliente, total) values ('não deve sair', 1);
select id, url from net.http_request_queue;
rollback;

Dentro da transação, a fila pode mostrar a linha. Depois do rollback, some. O receptor fica quieto. Confirme:

select count(*) from public.pedidos where cliente = 'não deve sair';
select id, url from net.http_request_queue;

Zero e vazio. Se o Node imprimiu esse cliente, você não está no pg_net: está numa chamada síncrona, ou commitou sem querer.

4. API fora do ar não desfaz o pedido, e ninguém tenta de novo. Aponte a URL da função para uma porta fechada (http://127.0.0.1:9/webhooks/pedidos), ou desligue o Node, e insira outra linha. O INSERT commita. Segundos depois:

select status_code, timed_out, error_msg, created
from net._http_response
order by created desc
limit 5;

Você vê error_msg ou timed_out. Não há segunda tentativa. O pedido está no banco e o webhook morreu. Se isso for inaceitável, este não é o desenho. É o teste que decide.

5. O mesmo event_id duas vezes não faz o efeito duas vezes. Um segundo INSERT é outro evento, com outro uuid. O teste de idempotência é reentregar o mesmo JSON. Pegue o event_id do log e mande de novo:

curl -s -X POST http://127.0.0.1:8787/webhooks/pedidos \
  -H 'Content-Type: application/json' \
  -H 'X-Webhook-Token: troque-isto' \
  -d '{"event_id":"COLE-O-UUID","event":"pedido.criado","pedido":{"id":1}}'

A primeira resposta processa. A segunda imprime duplicado e devolve duplicate, sem segundo efeito. O pg_net não faz esse retry sozinho, mas timeout mente: a API pode ter processado e a resposta ter estourado os 5 segundos. Quem reenvia na mão, e qualquer outbox, cai neste caso. Idempotência não é opcional só porque a extensão "não retenta".

Segredo, assinatura e o que não colocar no trigger

Token estático no header vaza em log de proxy e em net.http_request_queue, que o schema net libera USAGE para PUBLIC. No Supabase isso não aparece na Data API, e os papéis anon / authenticated são NOLOGIN. Em Postgres aberto, quem consegue SELECT nessa fila lê o token. Restrinja.

No Supabase, o caminho de produção é o Vault, com função security definer, dono que possa ler o segredo, e search_path vazio:

create or replace function public.notificar_pedido()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
declare
  secret text;
begin
  select decrypted_secret into secret
  from vault.decrypted_secrets
  where name = 'webhook_secret';

  if secret is null then
    raise exception 'webhook_secret ausente no vault';
  end if;

  perform net.http_post(
    url := 'https://api.exemplo.com/webhooks/pedidos',
    headers := jsonb_build_object(
      'Content-Type', 'application/json',
      'X-Webhook-Token', secret
    ),
    body := jsonb_build_object('pedido_id', new.id)
  );

  return new;
end;
$$;

security definer executa como o dono da função. Se o dono for superusuário e o search_path não estiver travado, a função vira vetor. Trave o path, revogue EXECUTE de PUBLIC e conceda só aos papéis que escrevem na tabela.

Assinar o body byte a byte é armadilha com pg_net: a função recebe jsonb, e jsonb não preserva a ordem das chaves. O texto que você assinou pode não ser o texto que o worker enviou. Assine uma string que você controla, e que o receptor reconstrói dos campos, não dos bytes crus:

event_id || '.' || pedido_id || '.' || occurred_at

No banco, hmac() vem do pgcrypto (extensions.hmac no Supabase). No Node, crypto.createHmac('sha256', secret) e timingSafeEqual. Token no header protege o laboratório. HMAC dessa string canônica é o que eu deixaria em produção.

Esboço do outbox

Isto não é um framework. É o mínimo para o evento sobreviver a um crash do worker. O trigger só insere. Quem fala HTTP é outro processo.

create table public.outbox (
  id bigint generated always as identity primary key,
  event_id uuid not null unique,
  event_type text not null,
  payload jsonb not null,
  status text not null default 'pending'
    check (status in ('pending', 'processing', 'sent', 'dead')),
  attempts int not null default 0,
  next_attempt_at timestamptz not null default now(),
  locked_at timestamptz,
  last_error text,
  created_at timestamptz not null default now(),
  sent_at timestamptz
);

create index outbox_pendentes_idx
  on public.outbox (next_attempt_at)
  where status in ('pending', 'processing');

create or replace function public.enfileirar_pedido()
returns trigger
language plpgsql
set search_path = ''
as $$
declare
  event_id uuid := gen_random_uuid();
begin
  insert into public.outbox (event_id, event_type, payload)
  values (
    event_id,
    'pedido.criado',
    jsonb_build_object(
      'event_id', event_id,
      'event', 'pedido.criado',
      'occurred_at', now(),
      'pedido', jsonb_build_object(
        'id', new.id,
        'cliente', new.cliente,
        'total', new.total,
        'status', new.status
      )
    )
  );

  perform pg_notify('outbox', '');
  return new;
end;
$$;

create trigger pedidos_outbox
after insert on public.pedidos
for each row
execute function public.enfileirar_pedido();

O worker, em transação curta, reivindica linhas e solta o lock antes da rede. Segurar FOR UPDATE durante o POST é como a extensão http: a latência de fora vira lock no banco.

update public.outbox
set status = 'processing',
    locked_at = now()
where id in (
  select id
  from public.outbox
  where (
    status = 'pending'
    and next_attempt_at <= now()
  ) or (
    status = 'processing'
    and locked_at < now() - interval '2 minutes'
  )
  order by id
  for update skip locked
  limit 10
)
returning id, event_id, payload;

O or do lease devolve linha cujo worker morreu no meio. Dois workers não pegam a mesma linha por causa do skip locked.

Depois do HTTP, em outra transação: 2xx marca sent e sent_at. Qualquer outra coisa incrementa attempts, grava last_error e empurra next_attempt_at (interval '1 second' * power(2, attempts), com teto). Na oitava falha, status = 'dead'. Alerta em dead letter. Não apague a linha para "limpar": ela é o recibo de que você tentou.

O pg_notify no trigger acorda quem está em LISTEN outbox. Se o worker estava fora, o notify morreu. Um poll de 5 segundos pega o que ficou. Use os dois. Não use o notify como fila.

O receptor é o mesmo do laboratório. A garantia de "pelo menos uma vez" significa que o POST pode sair duas vezes se o processo cair depois do 200 e antes do UPDATE. Sem o event_id gravado na tabela, o retry não teria o que repetir. Por isso o uuid nasce na linha, não só no JSON descartável do pg_net.

Prós, contras e quando não usar

Facilidades do trigger com pg_net. Não há serviço extra para um aviso simples. O payload já tem a linha, no dialeto do banco, sem um ORM no meio. O POST não sai em rollback. Dá para testar a chamada no SQL antes de amarrar o trigger. No Supabase, o dashboard cria o mesmo fluxo. Combinado com pg_cron, o banco também chama uma API de minuto em minuto, sem trigger nenhum.

Contras. O banco vira cliente HTTP: timeout, DNS, certificado e fila passam a ser problema de DBA. O segredo mora perto dos dados. Debugar é olhar tabela unlogged que some, não um log de aplicação. Crash entre o commit e o worker perde o evento, e não há retry. O teto de 200 req/s e o limite de um banco por cluster aparecem tarde, em produção. Cada coluna nova no payload é contrato quebrado com quem consome o webhook. A API em beta pode mudar de assinatura.

Regra prática. Notificação que pode falhar e ser refeita na mão, volume baixo, pg_net disponível: o trigger assíncrono serve. Dinheiro, estoque, e-mail transacional, ou qualquer efeito que o usuário acredita que aconteceu porque a tela disse "salvo": outbox ou fila fora do banco. Se a extensão não existe no provedor, nem comece pelo pg_net.

Checklist de produção

  • A extensão existe no Postgres de produção, não só no Docker local.
  • pg_net.database_name é o banco em que o trigger roda, e o worker foi reiniciado depois da mudança.
  • A URL é constante no código. Nenhum campo da linha escolhe o host.
  • O segredo não está no fonte da função. Vault, ou equivalente. search_path vazio, nomes qualificados.
  • EXECUTE não está aberto para PUBLIC se a função for security definer.
  • O payload lista campos. Não manda a linha inteira.
  • O receptor trata event_id repetido como sucesso idempotente.
  • Timeout acima do tempo real da API. Dois segundos é o padrão, e gera timed_out com a API tendo processado.
  • Alerta em status_code >= 400, error_msg ou timed_out. Não conte com net._http_response daqui a um dia.
  • Carga estimada folgada em relação a 200 requisições por segundo, ou o desenho já é outbox.
  • No Supabase local, a URL usa host.docker.internal, não localhost.

Perguntas frequentes

O trigger espera a API responder?

Com pg_net, não. O INSERT espera só a gravação na fila, e a requisição começa depois do COMMIT. Com a extensão http, sim: a sessão bloqueia até o timeout, e um erro não tratado desfaz a transação.

Rollback dispara o webhook?

Com pg_net, não. A linha da fila participa da transação. Confirme com BEGIN / INSERT / ROLLBACK e o receptor em silêncio. Com HTTP síncrono dentro do trigger, a chamada pode já ter saído antes do rollback. Aí o sistema de fora viu um pedido que o banco não tem.

Dá para usar no RDS?

pg_net não está no RDS nem no Aurora. O padrão outbox funciona, porque é tabela e worker. Invocar Lambda a partir do SQL é outro recurso, específico da AWS, e não substitui retry nem idempotência.

Como ver o erro da chamada?

select id, status_code, error_msg, timed_out, created
from net._http_response
where status_code >= 400
   or error_msg is not null
   or timed_out
order by created desc;

Se não há linha nem na fila nem na resposta, ou o worker ainda não pegou (espere), ou você está no banco errado, ou a transação não commitou.

Posso mandar PUT ou PATCH?

Não pelo pg_net. Só GET, POST com JSON e DELETE. PUT, PATCH e outros content-types pedem a extensão http (síncrona) ou um worker no outbox, que usa o cliente HTTP que você quiser.

LISTEN/NOTIFY substitui o webhook?

Não. É um sinal, não uma entrega. Use para acordar o worker do outbox, com poll por baixo.

Referências

  • pg_net: documentação e código. Assinaturas, o fato de o HTTP só sair após o commit, TTL, tabelas unlogged e o teto de vazão estão lá. A API é beta.
  • Database Webhooks do Supabase, o atalho de tela sobre o mesmo trigger.
  • pgsql-http, o cliente síncrono. Leia a diferença entre http_post com jsonb e http_post com content-type JSON antes de usar.
  • Transactional outbox, o padrão por trás da tabela deste artigo: gravar o evento na mesma transação do dado, e entregar fora.

Adelson de Bittencourt Junior

Desenvolvedor full-stack em Criciúma, SC, com passagem por suporte de telefonia VoIP e redes. Escreve sobre redes, segurança, infraestrutura e bancos de dados. Sobre o site · Enviar correção