Table of Contents
Integrador¶
O Integrador dá acesso móvel — online e offline — aos dados que hoje só existem dentro do ERP de mesa X-Adm (usado via Área de Trabalho Remota). A equipe comercial e de campo passa a consultar vendas e saldos de BL no celular, em tempo quase real quando há internet e de forma totalmente funcional quando não há.
Na prática: cada alteração no X-Adm (nota de venda, saldo de BL, cadastro) é exportada como um arquivo JSON; um agente on-premises (X-Adm Integração Java) observa a pasta 24×7 e envia a mudança por REST/HTTPS (com retry) para o Integrador na nuvem — uma instância por cliente. O Integrador valida e grava num PostgreSQL por empresa, e o PowerSync replica o banco para o SQLite local do aplicativo (offline-first, sincroniza sozinho ao reconectar). Em notas de venda e entrada, o Integrador ainda publica um evento que dispara push (FCM).
Por que importa¶
- Tempo quase real com internet; 100% funcional offline — o campo não fica refém de conexão.
- Separação física por empresa — cada cliente tem app, instância e banco próprios; dados nunca se misturam. Um mesmo app pode exibir duas empresas lado a lado (mescla no cliente), sempre respeitando as permissões do usuário.
- Nomes curtos definidos pelo usuário — apelidos de produto/cliente/veículo que vivem só no app e nunca tocam o ERP.
Para o desenho do sistema, veja a Documentação Completa; os termos do domínio estão no Glossário.
Projeto
Documentação Completa — Integrador¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-10
O desenho do sistema no estado atual: o que é, como os dados fluem do ERP até o celular, quais são as entidades e como se autentica. Runbooks de operação estão em Operação; detalhes de código, em Dev; o histórico das decisões, em Decisões.
Sem manual de usuário — por quê
O Integrador é um serviço de integração backend (API REST + telas administrativas
internas). Não há docs/manual/ (manual de usuário final) de propósito: o produto voltado
ao usuário é o app móvel (repositório próprio), que consome este serviço via PowerSync/API.
A experiência de usuário final é documentada lá, não aqui.
Componentes¶
- X-Adm — ERP de mesa (base Zim) onde todos os dados nascem: o sistema-fonte da integração.
- X-Adm Integração Java — agente on-premises que lê os arquivos JSON exportados pelo X-Adm e os encaminha via REST/HTTPS (com retry) ao Integrador.
- Integrador — este repositório: servidor Micronaut / Java 25, uma instância por cliente (subdomínio por tenant atrás do Coolify/Traefik, porta 8080). Recebe REST, persiste no Postgres, emite push (FCM) e alimenta o PowerSync.
- PostgreSQL por cliente — banco dedicado por tenant (Postgres 18+,
uuidv7). - PowerSync (self-hosted) — Docker Compose por cliente
(
powersync/<cliente>/), cada um com seu MongoDB; lê o WAL do Postgres (replicação lógica, publicaçãopowersync) e replica para o SQLite do app. - central-backend (
central-backend.xadm.biz;auth.xadm.bizé o nome antigo, mantido como issuer/JWKS) — serviço central da casa: autenticação + JWKS (o Integrador não emite JWT próprio — ver decisão 0001) e destino do heartbeat do integrador-client que esta instância repassa (§ Heartbeat). - integrador-client (repositório próprio) — jar on-premise do cliente que faz o write-back pelo PowerSync e manda o heartbeat a esta instância (docs).
- FCM (Firebase Admin Java) — push para o aplicativo.
- Aplicativo Flutter (repositório separado) — SQLite/Drift local via PowerSync; mescla empresas no cliente.
Fluxo de dados¶
- O usuário age no X-Adm → o X-Adm gera um arquivo JSON, marcado com um código
de Origem (
PPEDAMX,PPEDIDO,PBLDI,PNOTAI,PNOTAD,PCSTPROD,PREQUIS). - O agente X-Adm Integração Java envia a
PUT/DELETE /api/v1/xadmcomAuthorization: Bearer <INTEGRADOR_API_TOKEN>. (Esse é o fluxo de saída, X-Adm → apps. Há também o fluxo de entrada — fonte externa PIED,Origem = INT-PIED, dado que volta ao X-Adm — no mesmo endpoint; ver decisão 0013.) - O Integrador faz o parse e persiste no Postgres. Para JSON válido o
endpoint sempre responde HTTP 200; o resultado vai no corpo
(
{requestId, resultado: SUCESSO|ERRO, mensagem}).HTTP 400só para JSON malformado. Detalhe em tratamento de erros. - Todo apply do XADM é serializado por um
ReentrantLock(fair=true)global, para preservar a ordemDELETE→PUTdo ERP e evitar deadlock no Postgres (40P01). Cada requisição é auditada na tabelarequest. - O PowerSync acompanha o WAL e replica para o SQLite do celular (offline-first).
- Em notas de venda/entrada, o Integrador envia push FCM (ver payload FCM).
- Checagem de "banco parado": o app consulta o endpoint público
GET /api/v1/powersync/lastchange.
Heartbeat do integrador-client¶
Uma instalação do integrador-client que para (máquina desligada, jar travado) só aparecia horas depois, como "a integração parou". O heartbeat torna isso visível sem acesso à máquina, passando por esta instância — o único destino que o firewall do cliente já libera:
- O integrador-client manda
POST /api/v1/heartbeat(versão, fluxos, hostname) no startup e a cada 60 min, com o mesmo BearerINTEGRADOR_API_TOKENdo write-back. - Esta instância valida o corpo, carimba o
cliente_id(envCLIENTE_ID— identidade do contexto, nunca do corpo) e repassa ao central-backend emPOST {CENTRAL_URL}/api/integrador/heartbeat, BearerCENTRAL_API_TOKEN. Não grava nada aqui. - O central registra a instalação, alerta no GlitchTip quando ela passa de 6 horas úteis sem sinal e a mostra na aba Deploy do central-ui.
Contrato do salto client → aqui: heartbeat. O porquê (rota em dois saltos, contrato congelado, alerta por horas úteis): a decisão local 0028 do central-backend. Configuração: heartbeat na configuração.
Modelo de dados¶
As entidades de negócio e as tabelas de infraestrutura (request,
pushenviada, pabast_*) estão descritas em
Modelagem de dados. Toda tabela de negócio carrega a chave de
negócio do X-Adm e um id UUID v7 surrogate, além de
soft-delete (deleted, deleted_at).
Modelo de autenticação¶
/api/**→ Bearer estático (INTEGRADOR_API_TOKENno ambiente; sem fallback para o antigoAPI_BEARER_TOKEN); 401 se errado. Exceção pública:GET /api/v1/powersync/lastchange(sem PII, exigido no cold boot do app). Ver Segurança e /health.- Views server-side (
/, MVC server-render JTE) → login Google/Firebase (domínio@xadm.com.br),SessionAuthenticationFetcher(libxadm-seguranca) +ViewSecurityRule(policy local, micronaut-security) + cookiexadm_session(JWT HS256, 8h) e CSRF double-submit./login,/health,/swagger/**públicos. Ver login Google e decisão 0005. - Nenhum JWT é emitido por este JAR (o login/JWT do pAbast foi removido — ver
decisão 0001); o JWKS do PowerSync aponta
para
auth.xadm.biz. - Botões de edição da UI e
/debugsó ligam sob os perfisdev/test; em produção a UI é somente-leitura. Ver perfis Micronaut.
Configuração¶
application.yml (defaults de produção) + ENV do Coolify; application-dev.yml
só com MICRONAUT_ENVIRONMENTS=dev (Postgres local :5432, Flyway clean-schema);
testes usam application-test.yml. Não há application-prod.yml. O Sentry só
inicializa com DSN válido em ambiente não-dev/test. Detalhe em
Configuração.
Modelagem de dados¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06
O modelo espelha as tabelas do X-Adm (base Zim) mais as tabelas de
infraestrutura da integração. O diagrama-fonte está em
anexos/modelagem.plantuml.
Convenções gerais. Toda tabela de negócio tem: a chave de negócio do
X-Adm (ex. ChaveCP, ChaveEst); um id UUID v7 surrogate (RFC 9562,
uuidv7() do Postgres 18 — ver decisão 0003);
e soft-delete (deleted, deleted_at).
Numéricos vêm do tipo Zim VastInt(n) (n decimais implícitos) → NUMERIC /
BigDecimal. Datas Zim Date(8) (AAAAMMDD) → DATE.
Espelho do ERP, dois fluxos. Todas as tabelas são do X-Adm — o Integrador
espelha o ERP. As mesmas tabelas servem duas direções: o fluxo de saída
(X-Adm → Integrador → PowerSync → apps, ex. Sul Plata; produtor = X-Adm, Chave*
já preenchida) e o fluxo de entrada (PIED → Integrador → PowerSync → X-Adm,
novo; produtor = fonte externa, Chave* vazia até o write-back). O fluxo de
entrada preenche mais colunas das mesmas tabelas e keia pela chave natural da
fonte (pied_x_ped/pied_codigo_alt/cgc_cpf — transporte PIED). Chave dupla, write-back e o princípio das
colunas de retorno em decisão 0013.
Tabelas de negócio¶
| Tabela | Chave de negócio | Papel |
|---|---|---|
CONTRATOS |
ChaveCP (saída) · PiedXPed (entrada, transporte) |
Cabeçalho do pedido; na entrada (PIED) carrega cliente, VlTot, NatOp, TpVda, … |
ITENSPED |
ChaveItem (saída) · PiedXPed+PiedCodigoAlt (entrada, transporte) |
Itens do pedido, ligados a contrato e produto; na entrada carrega Qtde/Valor/Total/TaxaFrete. |
FORMULAS |
PiedXPed+PiedCodProd (entrada, transporte) |
Fórmula (BOM) do kit: uma linha por componente (Qtde). Só entrada; sem old_id; sem colunas Chave* (o X-Adm nunca devolve chave neste fluxo); pied_formula_id = id sequencial do X-Adm gerado por nós. Ver decisão 0013. |
FONES |
CgcCpf (entrada) |
Contato do cliente (só fluxo de entrada; 1 por cliente; sem old_id). |
ITENSPEDAMX |
SeqN (BIGINT) |
Alocação de item; guarda BL, Qtde e Retirou (base do saldo de BL). |
ITPEDAMXDI |
SeqNItAmx |
Datas de desembaraço/DI do item alocado. |
ITENS / ITENSLOTE |
ChaveItemNF |
Itens da nota e por lote (ChaveLoteEst). |
ESTOQUE |
ChaveEst (saída) · CodProd (saída Thoms, ChaveEst null) · PiedCodigoAlt (entrada, transporte) |
Cadastro de produto (NomeProd + NomeProdCurto mobile); carrega EAN13/Venda/VendaPz/Saldo. Identidade = ChaveEst/CodProd; PiedCodigoAlt é código do produto NA PIED (rastreabilidade), não identidade. updated_at: ver a nota chave-versão abaixo. Ver decisão 0013. |
LOTEEST |
ChaveLoteEst |
Lote de estoque. |
NOTACOMPL |
ChaveNota |
Nota complementar; created_at (manual, ordenação cronológica) + updated_at (@DateUpdated, nullable — exceção da nota chave-versão abaixo). Base do lastchange e da ordenação de vendas. |
PROPRIEDADES |
ChaveProp (saída) · CgcCpf (entrada) |
Fornecedor/cliente; na entrada carrega endereço (Fantasia/Endereco/CEP/…); NomeProp + NomePropCurto. |
VEICULOS |
SeqVeic |
Veículo/navio (Descricao + DescricaoCurta). |
MUNICIPIO |
CodMun |
Municípios. |
TBPCOEST |
ChaveEst+período |
Tabela de preço por estoque/vigência. |
Colunas só-mobile (NomeProdCurto, NomePropCurto, DescricaoCurta) são
apelidos definidos pelo usuário no app; sincronizam de volta, nunca vão ao X-Adm.
Colunas de retorno do X-Adm. No fluxo de entrada, o X-Adm é dono das colunas
ChaveCP/ChaveEst/ChaveProp/ChaveItem/ChaveFone, CodRetorno e MsgRetorno:
elas são read-only no caminho de entrada (chegam vazias) e preenchidas só pelo
write-back (POST /api/v1/powersync), que o upsert preserva no reenvio.
Princípio: toda tabela que volta ao X-Adm (entrada) carrega CodRetorno +
MsgRetorno; numa tabela usada só na saída (Sul Plata) elas ficam sempre null.
As colunas-chave ficaram nullable (o X-Adm só as gera depois); a garantia de
cada fluxo virou crítica em runtime no XadmApplyService.
Ver decisão 0013.
Chave-versão updated_at. Toda tabela de negócio do espelho carrega
updated_at (TIMESTAMPTZ NOT NULL DEFAULT now(), índice por tabela): coluna
de controle do servidor, auto-populada por @DateUpdated (Micronaut Data) em
toda escrita de entidade (apply e write-back) e que sobrescreve qualquer valor
vindo de fora. É a ordem dos fatos que os consumidores externos varrem — o
integrador-client (PowerSync on-premise) processa os pendentes por
ORDER BY updated_at dentro de cada tabela; o tradutor Thoms faz delta-sweep por
updated_at no estoque. Exceção NOTACOMPL: ganha @DateUpdated no
updated_at, mas a coluna fica nullable (sem backfill) — o created_at
(ordenação cronológica) e o fallback COALESCE(updated_at, created_at) do
lastchange dependem de legado NULL. Tabelas
de infra (REQUEST/PUSHENVIADA/PABAST_*) ficam de fora.
Ver decisão 0014.
Tabelas de infraestrutura¶
| Tabela | Papel |
|---|---|
REQUEST |
Auditoria de cada requisição XADM (origem, tipo ETipo, json, resultado EResultado, mensagem). |
PUSHENVIADA |
Log dos pushes FCM enviados (tipo, título, conteúdo, erro). |
PABAST_EMPRESA / PABAST_SENHAS |
Empresa/credenciais legadas mantidas para o PowerSync; app/login/JWT removidos do integrador (ver decisão 0001 e esquema pAbast). |
Plataforma de Integração X-Adm — Arquitetura-Alvo¶
Status: Rascunho · Responsável: Gustavo Madruga · Atualizado em: 2026-08-11
Rascunho — rev. 3 (2026-07-06): estreitamento do hub + diagramas didáticos (fluxo, rede, ER, eventos). O hub deixa de ser "porta única de ingress" e passa a ser SÓ a cópia fiel do X-Adm (nas duas direções) + o connector do PowerSync; o banco por cliente vira o barramento; tudo que não é a cópia do X-Adm é um vertical standalone (porta própria) ou um reator (consome o banco, empurra pra fora), fino via xadm-commons. Reverte D12 (XLS fica standalone, não migra pra trás do hub), remove o gateway cadastrável e o dispatch do hub (D9/D10), e fixa o packaging de reator (microserviço fino, não FaaS, não plugin-no-hub). Ainda não é decisão publicada. Quando estabilizar, migra para o repo central (xadm/documentacao, "constituição") e um decision record espelha no integrador/docs/decisoes/ no início da implementação.
Rev. 4 (2026-08-11) — sincronização com o construído. A referência de reator (proc-thoms / webstorm-ecom) foi para produção provando um padrão diferente do previsto, e as libs da casa foram extraídas. Este doc foi atualizado para refletir o que existe: os reatores consomem por poll (@Scheduled) + webhook poke sobre o Postgres compartilhado (não PowerSync — descartado, junto com LISTEN/NOTIFY, na decisão 0003 do webstorm-ecom); a biblioteca é o xadm-commons multi-módulo (8 libs focadas), não o bi-commons único que a visão original previa — e sem base de reator nem bridge Kotlin PsBridge (a engine de reator é escrita à mão por app); o egress do thoms usa a tabela webstorm_ecom_request com chave cod_prod e idempotência derivada do watermark updated_at (não thoms_envio/EAN). A visão (hub estreito = cópia fiel, os 4 shapes, owner-writes, banco-barramento) permanece — mudou a mecânica de consumo dos reatores e o formato da biblioteca.
Escopo: reorganização de todas as integrações do ecossistema X-Adm — o integrador, os apps -xls, e os dois requisitos novos (PIED e thoms). Este documento define o alvo (north-star) e o roadmap de migração; não é código.
- Problema As integrações do X-Adm nasceram por cliente e por necessidade, e hoje vivem em formatos diferentes:
O integrador recebe eventos do X-Adm em tempo real (push HTTP) e mantém uma réplica na nuvem — código único, igual para todos os clientes. Os apps -xls (bi-transporte-xls, bi-comercial-xls) processam planilhas — um app por cliente, com o plumbing duplicado e a lógica de relatório hardcoded. Chegam dois requisitos com direções novas: PIED (dados de uma fonte externa entram no X-Adm) e thoms (dados do X-Adm saem para um e-commerce). À medida que as necessidades divergem por cliente, aparece a tensão central:
Motor genérico (compartilhado, estável) × lógica divergente (isolada, muda em ritmos diferentes).
Os dois erros a evitar:
(a) enfiar lógica divergente no motor compartilhado → "pra atualizar um cliente, redeploya todo mundo" (o medo que motivou este documento); (b) duplicar o motor por cliente → a dívida atual dos -xls (~42 classes de plumbing repetidas). O reframe que este documento adota: a variável de organização não é o cliente — é a forma (shape) do fluxo de dados.
Princípio-mestre desta revisão: o hub é a cópia fiel do banco do X-Adm — o caminho oficial pra extrair uma cópia do ERP e pra devolver dados a ele pelo mesmo banco. Qualquer coisa diferente disso tem o seu próprio caminho (um vertical com porta própria, ou um reator que consome o banco). O banco por cliente é o barramento; o erro (b) é evitado não por centralizar no hub, mas pela biblioteca comum (xadm-commons) que deixa cada vertical fino.
- Estado atual (mapa) 2.1 Como os dados do X-Adm chegam à nuvem hoje O X-Adm (ERP legado, on-prem) empurra os dados — não é pull.
Push HTTP → PUT/DELETE /api/v1/xadm no integrador. Body JSON com campo Origem (PNOTAI, PBLDI, PPEDAMX, PNOTAD, PPEDIDO, PCSTPROD, PREQUIS) + arrays por tabela. PUT = upsert, DELETE = soft delete. Aplicação numa única transação, serializada por lock global (ordem FIFO, evita deadlock intra-XADM). Contrato legado: responde sempre HTTP 200 (erro vai no corpo + Sentry). Batch XLSX → apps -xls. Recepção assíncrona (valida magic bytes ZIP, SHA-256 para dedupe, grava no storage Garage, status PENDENTE, responde 202) + processamento em background (Apache POI em modo SAX → merge diff-aware no Postgres). Ambos aterrissam num Postgres por cliente, replicado por PowerSync (1 deploy por cliente) para os apps Flutter.
2.2 Modelo de dados
integrador: 14 tabelas de negócio (contratos, estoque, fones, itens, itenslote, itensped, itenspedamx, itpedamxdi, loteest, municipio, notacompl, propriedades, tbpcoest, veiculos), + infra (request = auditoria, push_enviada, pabast_). Schema via Flyway; soft delete (deleted); PK UUID v7.
Espelho do ERP em duas direções: fluxo de SAÍDA (X-Adm → Integrador → PowerSync → apps; produtor = X-Adm, Chave já preenchida; ex. Sul Plata) e fluxo de ENTRADA (PIED → Integrador → PowerSync → X-Adm; produtor = fonte externa, chave natural xPed/CodigoAlt/CgcCpf; mais colunas das mesmas tabelas + fones). Princípio: toda tabela que VOLTA ao X-Adm (entrada) carrega CodRetorno + MsgRetorno, que o X-Adm preenche no write-back (POST /api/v1/powersync) junto com as Chave que gera; em tabelas usadas só na saída essas colunas ficam sempre null. O produtor (int-pied) reconcilia lendo GET /api/v1/xadm/retorno/{tabela} (status derivado: 006=confirmado, 0XX=pendente, 1XX/9XX=rejeitado). Ver decisão 0013 e modelagem.md.
Entrada vs saída de nota não são tabelas separadas: é o 4º caractere da chave_nota (P = entrada, R = saída/venda) — convenção do ERP. O app navarro (sulplata / onpetrotrading) calcula saldo de BL e vendas sobre notacompl + itens a partir dela. A integração reflete e documenta essa convenção do ERP (ver D0).
-xls: tabelas bi_ (sincronizam via PowerSync) e xls_* (metadados do processador, não sincronizam), em banco próprio por cliente.
2.3 Multi-cliente hoje
1 deploy por cliente (integracao.
Fora de escopo desta reorg: etc/database-sync (migração pontual PG→PG), etc/watchdog (monitor de proxy Coolify).
- Os 4 shapes de integração A organização gira em torno da forma do fluxo, não do cliente:
Push X-Adm em tempo real — X-Adm empurra eventos. Genérico, igual para todos. Entra no hub → réplica. (hoje: integrador) Batch de arquivo (XLSX/CSV) com parsing específico — o X-Adm exporta relatório, alguém faz upload, um parser específico do relatório transforma. Divergente por cliente. Vertical standalone com porta própria. (hoje: apps -xls) Pull/webhook de API externa → formato X-Adm (ENTRADA) — puxa/recebe de uma fonte externa, transforma para o formato do X-Adm, e entrega de volta ao ERP on-prem. Vertical standalone com porta própria. (novo: PIED; POC pronta em poc-pied-simples) Push para API externa (SAÍDA) — lê a réplica no banco, transforma, e faz POST numa API de terceiro. Reator (consome o banco). Divergente por cliente, mas leve. (novo: thoms → e-commerce) Shapes 1 e 2 são saída de dados do X-Adm (o X-Adm é a fonte). Shape 3 é entrada no X-Adm (o X-Adm é o destino). Shape 4 é saída, mas o destino é uma API externa em vez de um app.
Só o shape 1 (a cópia fiel do X-Adm) entra pelo hub. Os shapes 2 e 3 entram pela porta do PRÓPRIO vertical. O shape 4 não tem porta de entrada — é um reator que consome o banco.
3.1 O contrato de transporte X-Adm ↔ nuvem (padrão único por direção) Ficou definido um padrão único de transporte entre o X-Adm e a nuvem, um por direção. O que muda de uma integração para outra é só o processamento e o destino, nunca o transporte.
Saída (X-Adm → nuvem) — dois modos, conforme a natureza do dado:
Eventos de domínio em tempo real (nota emitida, BL liberado, pedido…): o X-Adm faz o push HTTP direto ao hub, sem delay (é o /api/v1/xadm de hoje). São eventos pequenos que exigem imediatismo — inclusive os que disparam push FCM. Viram a cópia fiel (réplica) no hub.
Dados em arquivo / lote: o X-Adm grava numa pasta de envio local (outbox); um .jar agente observa a pasta (ex.: a cada 1 min) e envia à nuvem. O agente roteia por TIPO (config, não código): dado bruto do ERP que vira a cópia fiel (ex.: produto/estoque tabular) → o hub (int.
O X-Adm fica desacoplado do destino: quem se preocupa com as integrações externas é a nuvem — é lá que vive a inteligência de "como e para onde entregar".
Entrada (fonte externa → X-Adm) — "espelho + PowerSync":
A nuvem recebe os dados de uma integração externa (ex.: PIED) na porta do vertical, processa e grava no formato de tabela que o X-Adm espera — um espelho (tabelas sem prefixo, com máquina de status por linha) no banco do cliente. Um cliente Java do lado do X-Adm conecta via PowerSync (connector do hub), lê as linhas PENDENTE, importa no ERP chamando o zimrtmu.exe (runtime do ZIM) e escreve o status de volta (PROCESSANDO/IMPORTADO/ERRO). Mesmo padrão para qualquer fonte de entrada — muda a integração externa e o processamento, não o contrato com o X-Adm. (Ver D6/D7.)
- Arquitetura-alvo
Uma Plataforma de Integração X-Adm: um hub estreito (a cópia fiel do X-Adm) por cliente + verticais standalone por fonte/relatório (porta própria) + reatores que consomem o banco + Postgres por cliente (o barramento) + PowerSync bidirecional (do hub).
X-Adm (ERP legado, on-prem)outbox + agente .jar │ push HTTP (tempo real) ▲ cliente Java (PowerSync) (~1 min, roteia p/tipo)│ + arquivos roteados │ lê PENDENTE, importa, ▼ │ escreve status (entrada) ┌──────────────────────────────┐ ┌───────────────────────────────────────────┐ │ HUB · int.
│ │ VERTICAIS (porta PRÓPRIA, finos) │ │ push X-Adm → RÉPLICA │ │ excel. — recebe XLS, parse, bi_ │ │ connector PowerSync │ │ pied. — webhook+poll, espelho │ │ write-back (nome/status ERP) │ │ (recepção/dedupe/envelope via xadm-commons) │ │ dono da CÓPIA FIEL do X-Adm │ └──────────────────────┬────────────────────┘ └──────────────┬───────────────┘ │ owner-writes │ owner-writes │ ▼ ▼ ┌─────────────────────────────────────────────────────────────────────────────┐ │ POSTGRES POR CLIENTE — O BARRAMENTO ⇄ PowerSync (do hub) │ │ réplica X-Adm · bi_ · estado-desejado (PENDENTE) · status dos reatores │ └───────┬───────────────────────────────┬─────────────────────────┬───────────┘ PowerSync PowerSync poll + webhook poke ▼ ▼ ▼ apps Flutter cliente Java ERP REATORES (consomem o banco, (bi-*, navarro_app) (importa no X-Adm) empurram pra fora: proc-thoms→ecom) (FCM: fica no integrador por ora — D8)
Diagrama do alvo — fluxo de dados de ponta a ponta (Mermaid):
flowchart TB
subgraph ONPREM["🏢 X-Adm — on-prem"]
direction TB
XADM["X-Adm<br/>(ERP ZIM)"]
AGENTE["Agente .jar<br/>(outbox, ~1 min)"]
PSC["Cliente Java + PsBridge<br/>chama zimrtmu.exe"]
end
subgraph CLOUD["☁️ Nuvem — 1 stack por cliente"]
direction TB
HUB["HUB · int.cliente<br/>cópia fiel do X-Adm<br/>+ connector PowerSync"]
EXCEL["excel.cliente<br/>XLS → bi_*"]
PIEDV["pied.cliente<br/>webhook + poll → espelho"]
REAT["Reator · proc-thoms<br/>consome → empurra"]
PG[("Postgres do cliente<br/>— O BARRAMENTO —")]
PS(["PowerSync"])
end
subgraph EXT["🌐 Externo"]
direction TB
PIEDAPI["API PIED"]
ECOM["E-commerce<br/>(Webstorm)"]
end
APP["📱 Apps Flutter<br/>bi-*, navarro_app"]
XADM -->|"① push REST JSON (tempo real)"| HUB
XADM -.->|"arquivos"| AGENTE
AGENTE -->|"XLS (anexo)"| EXCEL
AGENTE -->|"dado bruto ERP"| HUB
PIEDAPI <-->|"webhook / poll"| PIEDV
HUB -->|"réplica"| PG
EXCEL -->|"bi_*"| PG
PIEDV -->|"estado-desejado"| PG
PG <--> PS
HUB -.->|"connector"| PS
PS ==>|"② sync"| APP
APP -.->|"write-back (uploadData)"| HUB
HUB -.->|"FCM push (por ora no integrador)"| APP
PG ==>|"③ poll + webhook poke"| REAT
REAT -->|"POST egress"| ECOM
PS <-->|"④ PENDENTE + status"| PSC
PSC -->|"zimrtmu.exe importa"| XADM
classDef hub fill:#064490,color:#fff,stroke:#042c5e;
classDef vert fill:#2a5b97,color:#fff,stroke:#042c5e;
classDef reat fill:#8b5e00,color:#fff,stroke:#5e3f00;
classDef data fill:#5f6b78,color:#fff,stroke:#3a434d;
class HUB hub;
class EXCEL,PIEDV vert;
class REAT reat;
class PG,PS data;
Os quatro caminhos numerados: ① a cópia sai do X-Adm e chega no hub por REST JSON (push tempo real) — e dado bruto/relatório via o agente; ② os apps recebem por PowerSync (e devolvem write-back pelo connector do hub); ③ os reatores consomem o banco e empurram pra fora (ecom; o FCM fica no integrador por ora); ④ o que vai pro X-Adm desce por PowerSync até o cliente Java, que chama o zimrtmu.exe pra gravar no ERP e devolve o status.
Leitura: só a cópia fiel do X-Adm entra pelo hub (push HTTP + dado bruto do agente). Relatório e fonte externa entram pela porta do próprio vertical. O Postgres por cliente é o barramento: cada peça escreve as SUAS tabelas (owner-writes) e o hub é o connector do PowerSync que serve os apps e o cliente Java do ERP (entrada); os reatores leem o banco direto por poll+poke e empurram pra fora. O FCM segue no integrador por ora (D8).
4.1 Os tipos de app (taxonomia) Toda a organização se apoia em distinguir os tipos de app, com donos e responsabilidades diferentes.
Tipo O que é Exemplos (alvo) Deploy
Hub A cópia fiel do X-Adm por cliente: recebe o push do X-Adm (réplica), é o connector do PowerSync (serve os apps e o cliente do ERP; aplica write-backs), é dono da réplica + do request de auditoria. (Os reatores NÃO passam pelo connector — leem o banco por poll+poke.) Genérico, fino, NÃO é porta de ingress de nada além do X-Adm. int.@Scheduled) + webhook poke, reage, é dono da sua tabela de status/auditoria, e empurra pra fora (API de terceiro, push). Interno (sem porta pública). proc-thoms (→ e-commerce, em prod). FCM (navarro): fica no integrador por ora (D8). 1 por destino/reação
App de cliente O que o usuário final usa. Consome o banco via PowerSync; pode fazer write-back de campos editáveis (uploadData → connector do hub). Não é integração. navarro_app (sulplata/onpetrotrading), bi-transporte, bi-comercial 1 por app de cliente
E uma natureza que NÃO é app: a biblioteca da casa (xadm-commons, multi-módulo) com o plumbing genérico — hoje: auth (xadm-seguranca), infra web/RFC-7807/health (xadm-comum-web), outbox M2M (xadm-mensageria), utilitários (xadm-comum-util), storage S3/Garage (xadm-comum-storage), fixtures de teste (xadm-comum-teste), admin/reset PowerSync (xadm-comum-powersync) e o seam de ingestão XLS (xadm-ingestion-core). Usada pelo hub, verticais e reatores — é o que os torna finos. É o pilar do desenho (D11): sem ela, cada standalone reintroduziria o erro (b). Nota: a "base de reator (consumidor PowerSync + idempotência + status)" da visão original não foi construída — o reator consome por poll+poke e é escrito à mão (D8).
A fronteira nuclear: o hub é a cópia fiel do X-Adm e o connector do PowerSync; o vertical é a face para o mundo externo (recebe na SUA porta) e dono do seu domínio; o reator consome o banco e empurra pra fora; o app de cliente consome. Cada tabela tem um dono (owner-writes, D4); o banco compartilhado é o ponto de encontro.
Diagrama de camadas (código — o corte 75/25):
flowchart TB
COMMONS["xadm-commons — BIBLIOTECA multi-módulo, não é app: auth · web/RFC-7807 · mensageria · util · storage S3 · fixtures teste · admin/reset PowerSync · seam ingestão XLS (SPI). NB: base de reator NÃO construída — reator é hand-rolled (D8)"]
HUB["HUB · int.cliente — cópia fiel do X-Adm + connector PS + write-back (genérico)"]
EXCEL["excel.cliente (vertical) — ~25% próprio: parse abas 10/20/30, defesa veicular, chaves naturais, schema bi_*, sua porta/console"]
REAT["proc-thoms (reator) — ~pouco: declara tabelas, mapeia (EAN), tabela de status, POST egress"]
PG[("Postgres do cliente — barramento")]
APP["bi-transporte / bi-comercial / navarro_app (Flutter) — via PowerSync"]
HUB -.->|"depende"| COMMONS
EXCEL -.->|"depende"| COMMONS
REAT -.->|"depende"| COMMONS
HUB -->|"réplica + write-back"| PG
EXCEL -->|"bi_*"| PG
PG -->|"poll + poke"| REAT
REAT -->|"egress"| PG
PG -->|"PowerSync"| APP
Leitura: xadm-commons é a fundação (~75%) que hub, verticais e reatores compilam; o hub é a cópia + o connector; o vertical tem só o seu ~25% + a sua porta; o reator é ainda mais fino (declara o que ouve + a reação). Ninguém duplica plumbing.
4.2 Fronteiras e propriedade de dados Cada cliente tem um Postgres (o barramento), com um dono por tabela (owner-writes, D4) e vários leitores. Quem escreve o quê:
Hub (int.
Exemplo trabalhado — vantroba (hoje → alvo):
Hoje Alvo Hub não existe — o -xls acumula tudo nasce int.vantroba.xadm.biz ESTREITO: só a cópia fiel do X-Adm (réplica do push) + connector PowerSync + write-back Vertical bi-transporte-xls (Micronaut, ~75% boilerplate) segue como excel.vantroba (standalone, porta PRÓPRIA); NÃO migra pra trás do hub (D12→B); emagrece extraindo o plumbing pro xadm-commons; segue dono das suas tabelas (bi_faturamento/bi_movimento) + Flyway próprio App de cliente bi-transporte (Flutter) lê via PowerSync igual, sem mudança
Convenção de nomes de tabela (= propriedade do dado):
Prefixo Significa Sincroniza p/ apps? Exemplos
(sem prefixo) Dado do ERP — é, ou vai virar, dado do X-Adm Sim réplicas do X-Adm (itens, notacompl…); produto/estoque do thoms; estado-desejado do PIED que o cliente Java importa
webstorm_ecom_request (append-only, chave cod_prod CHAR(11); idempotência derivada do watermark updated_at, não uma coluna PENDENTE/ENVIADO/ERRO). EAN (ean13) é só correlação, não a chave.
4.3 Componentes de runtime Hub (genérico, 1 por cliente). Evolução ESTREITADA do integrador atual. NÃO é porta de ingress geral. Recebe: (1) o push HTTP do X-Adm em tempo real, e (2) via agente, o dado bruto do ERP que vira a cópia fiel (ex.: produto/estoque). Aplica na réplica, mantém o request de auditoria, é o connector do PowerSync (serve apps, cliente do ERP e reatores; aplica write-back). Não parseia relatório, não recebe webhook de fonte externa, não despacha nada, não conhece as tabelas dos verticais/reatores (owner-writes, D4).
Verticais de integração (divergente, deploy independente, finos via xadm-commons, PORTA PRÓPRIA). A face para o mundo externo que RECEBE:
Processadores de relatório (excel.
Reatores (consomem o banco, empurram pra fora; sem porta pública). Reagem a um dado no banco do cliente e empurram pra fora. Packaging FIXADO pelo proc-thoms construído (D8): microserviço Micronaut fino que consome o Postgres compartilhado por poll (@Scheduled) + webhook poke (PowerSync/NOTIFY descartados, decisão 0003 do webstorm-ecom), é dono da sua tabela de status/auditoria (idempotência por watermark), e chama o externo. NÃO é FaaS (sem infra nova; não há problema de escala — um punhado de reatores) e NÃO é plugin dentro do hub (isso reconstruiria o monólito). A engine é escrita à mão no app (não há base de reator compartilhada). proc-thoms é a referência que prova o padrão — em produção. O push FCM (hoje no integrador, específico do navarro_app) PERMANECE no integrador por ora — extração para reator deferida (D8).
Postgres por cliente (o barramento, compartilhado pelas peças daquele cliente). Réplica, bi_*, estado-desejado e status de reator entram no mesmo banco. Sem dono único de schema: o hub roda o Flyway da réplica; cada vertical/reator roda o seu (histórico separado). Um dono por tabela (D4).
PowerSync por cliente (transporte bidirecional, connector no hub). Além do sync X-Adm→apps, serve o cliente Java do ERP (entrada). Os reatores NÃO usam PowerSync — leem o banco por poll+poke. Write-back sempre pelo connector do hub (uploadData → POST int.
4.4 Varredura de fronteiras (o que vai onde) Regra de bolso para classificar qualquer peça:
É a cópia fiel do X-Adm (a réplica do push, ou dado bruto do ERP que vira réplica) ou o connector do PowerSync → hub. Recebe de fora (relatório, webhook/poll de fonte externa) e escreve suas tabelas → vertical com porta própria. Não recebe de fora, consome o banco e empurra pra fora → reator. Consome o resultado para um usuário → app de cliente.
O que está no integrador hoje → destino no alvo:
Peça atual Classificação Destino XadmController (push /api/v1/xadm) cópia fiel hub Recepção/apply na réplica + request (auditoria) cópia fiel hub Connector PowerSync + /lastchange + write-back connector hub Push FCM (contexto BL/venda, payload) reator (específico do navarro_app) fica no integrador POR ORA (extração deferida — D8) Views CRUD JTE por tabela, export XLSX, /debug operacional da réplica hub (a console da cópia fiel; login via SSO) Login Google/Firebase das views identidade plano auth; SSO nas portas APIs REST /api/v1/* por tabela da réplica hub (não mexer agora)
Reconforto: quase tudo do integrador é a cópia fiel do X-Adm e vira o hub estreito; o único resíduo específico de app é o push FCM, que fica onde está por ora.
E o bi-transporte-xls (rico) → alvo (medido: ~75% genérico / ~25% específico):
Peça do -xls Classificação Destino
Dedupe SHA-256, storage 3-modos, envelope de status, motor de upsert, motor SAX, auth 2-camadas, reset PowerSync, bi_configuracao, 100% das telas operacionais genérico (~75%) — idêntico ao bi-comercial-xls xadm-commons (biblioteca)
Parse (abas 10/20/30), regras (defesa veicular, seq_dentro_grupo), chaves naturais, schema (bi_faturamento/bi_movimento), a SUA porta/console específico (~25%) segue no vertical excel.
Apps de cliente → sua fronteira:
App Lê Escreve Fronteira navarro_app (sulplata/onpetrotrading) notas/BL via PowerSync nomes curtos via uploadData→hub consumidor + write-back; o push é do integrador (por ora), não do app bi-transporte (vantroba), bi-comercial (onpetro) bi_* via PowerSync nada (read-only) consumidor puro Nenhum app de cliente escreve no banco direto nem faz integração — sempre via hub (write-back) ou lendo via PowerSync.
4.5 Diagramas didáticos (rede, dados, eventos) A mesma arquitetura por três ângulos: a REDE (o que é público/interno/on-prem e quem fala com quem), os DADOS (o modelo no banco do cliente) e os EVENTOS (a ordem temporal de cada fluxo).
Rede — o que roda onde e quem fala com quem
flowchart TB
subgraph ONPREM["🏢 On-prem — LAN do cliente"]
XADM["X-Adm (ERP ZIM)"]
AG["Agente .jar"]
PSC["Cliente Java<br/>+ zimrtmu.exe"]
end
subgraph COOLIFY["☁️ Coolify + Traefik — HTTPS (Let's Encrypt)"]
subgraph PUB["Portas públicas — Traefik + SSO (login Google)"]
HUB["int.cliente.xadm.biz"]
EXCEL["excel.cliente.xadm.biz"]
PIEDV["pied.cliente.xadm.biz"]
end
subgraph INTERNOS["Internos — sem domínio público"]
REAT["proc-thoms"]
PS["PowerSync"]
PG[("Postgres do cliente")]
end
end
subgraph EXTNET["🌐 Internet"]
PIEDAPI["API PIED"]
ECOM["Webstorm"]
FCM["Firebase / FCM"]
end
APP["📱 Apps (mobile / web)"]
XADM -->|"HTTPS ↑ REST"| HUB
AG -->|"HTTPS ↑"| HUB
AG -->|"HTTPS ↑"| EXCEL
PSC <-->|"HTTPS ↕ PowerSync"| PS
PIEDAPI -->|"webhook ↓"| PIEDV
PIEDV -->|"poll ↑"| PIEDAPI
REAT -->|"HTTPS ↑"| ECOM
HUB -->|"HTTPS ↑"| FCM
APP <-->|"HTTPS ↕"| HUB
APP <-->|"HTTPS ↕ sync"| PS
HUB --- PG
EXCEL --- PG
PIEDV --- PG
REAT --- PG
PS --- PG
Leitura: o X-Adm (on-prem) só faz conexões de SAÍDA para a nuvem (HTTPS) — nenhuma porta aberta no cliente. Na nuvem, o Traefik expõe só as portas públicas (hub + verticais), com SSO na frente; reatores, PowerSync e Postgres são internos (sem domínio). As integrações externas (PIED, Webstorm, FCM) falam pela borda.
Dados — o modelo no banco do cliente (o barramento)
erDiagram
NOTACOMPL ||--o{ ITENS : "chave_nota"
NOTACOMPL ||--o{ BI_FATURAMENTO : "derivado"
NOTACOMPL {
uuid id PK
string chave_nota "4o char P=entrada R=venda"
boolean deleted
}
ITENS {
uuid id PK
string chave_nota FK
}
VEICULOS {
uuid id PK
string placa
}
BI_FATURAMENTO {
uuid id PK
string periodo
numeric valor
}
ESTADO_DESEJADO_PEDIDO {
uuid id PK
string status
string msg_erro
}
REQUEST {
uuid id PK
string origem
string status
}
WEBSTORM_ECOM_REQUEST {
string cod_prod
boolean sucesso
timestamp updated_at_enviado
}
Legenda de propriedade (owner-writes, D4): réplica sem prefixo (NOTACOMPL, ITENS, VEICULOS…) + REQUEST = hub · BI_ (derivado) + XLS_ = excel.cliente · ESTADO_DESEJADO_ (sem prefixo) + PIED_ (landing/normalizado) = pied.cliente · WEBSTORM_ECOM_REQUEST (auditoria por cod_prod) = proc-thoms. Convenção do ERP (D0): entrada vs saída de nota é o 4º char da chave_nota (P/R), não tabelas separadas.
Eventos — a ordem temporal de cada fluxo
Saída (tempo real) + reator de egress:
sequenceDiagram
autonumber
participant X as X-Adm
participant H as Hub
participant PG as Postgres
participant PS as PowerSync
participant A as App
participant R as Reator
participant E as E-commerce
X->>H: push REST JSON (nota)
H->>PG: upsert réplica (1 transação)
H-->>X: 200 (erro vai no corpo)
PG-->>PS: replica change
PS->>A: sync (nota nova)
H-->>R: webhook poke (opcional, baixa latência)
R->>PG: sweep @Scheduled: SELECT pendentes (watermark updated_at)
R->>E: POST egress (lote 10, teto 200)
R->>PG: webstorm_ecom_request (sucesso, updated_at_enviado)
Entrada (fonte externa → X-Adm, via zimrtmu.exe):
sequenceDiagram
autonumber
participant P as API PIED
participant V as pied.cliente
participant PG as Postgres
participant PS as PowerSync
participant C as Cliente Java (ERP)
participant Z as zimrtmu.exe
participant X as X-Adm
P->>V: webhook (ou V faz poll)
V->>PG: estado-desejado (PENDENTE)
PG-->>PS: change
PS->>C: linha PENDENTE
C->>Z: importar
Z->>X: grava no ERP ZIM
C->>PS: status = IMPORTADO
PS->>PG: write-back status
Excel (anexo → dashboard):
sequenceDiagram
autonumber
participant U as Agente / Upload
participant EX as excel.cliente
participant PG as Postgres
participant PS as PowerSync
participant A as Dashboard
U->>EX: XLS (anexo)
EX->>EX: dedupe SHA-256 + parse SAX
EX->>PG: bi_* (upsert diff-aware)
PG-->>PS: change
PS->>A: sync (bi_*)
- Decisões de projeto Decisões já tomadas, registradas com o porquê (viram decision records na implementação).
D0 — A integração espelha o banco do ERP, sempre Desenho do ERP manda, integração obedece. O ideal é refletir as tabelas e colunas do X-Adm tal como são. A integração é espelho: não reinterpreta nem remodela. Quando o ERP usa uma convenção não óbvia, a integração a documenta e a segue fielmente. Exemplo: entrada vs saída de nota é o 4º char da chave_nota (P/R), não tabelas separadas. É fronteira de responsabilidade: modelagem é do ERP; integração é espelho.
D1 — Organizar por shape do fluxo, não por cliente A lógica genérica (cópia fiel, transporte) é a mesma independente do cliente; a lógica divergente (parsing, mapeamento) muda por relatório/fonte. Separar as duas é o que resolve a tensão (a)/(b) da seção 1.
D2 — Hub = cópia fiel do X-Adm (in/out) + connector PowerSync, 1 deploy por cliente (NÃO é porta de ingress) O hub faz exatamente uma coisa: mantém a cópia fiel do banco do X-Adm (réplica do push; dado bruto do ERP), é o connector do PowerSync (serve apps, cliente do ERP e reatores; aplica write-back) e é dono da réplica + request. Ingress de qualquer coisa que NÃO seja a cópia fiel (relatório XLS, fonte externa) entra pela porta do PRÓPRIO vertical, não pelo hub. Mantém a propriedade boa do integrador (código idêntico, diferença só em config; blast radius por cliente) e dá ao hub uma responsabilidade única e nítida. "Hub estreito" ≠ monólito de ingress.
D3 — Lógica divergente em verticais/reatores standalone, deploy independente Isola a lógica volátil: atualizar o parser da vantroba (excel.vantroba) ou o mapeamento do thoms não redeploya o hub nem outros clientes. O plumbing (recepção/dedupe/status/UI/upsert) sai dos serviços para o xadm-commons (biblioteca) — não para o hub — então cada serviço fica fino (~25% de código próprio) sem duplicar infra. Prova empírica: hoje bi-transporte-xls e bi-comercial-xls já têm ~75% de classes idênticas.
D4 — Owner-writes: um dono por tabela, no banco compartilhado (o barramento) Cada tabela tem exatamente um dono — quem roda o DDL (Flyway) e escreve. O hub é dono da réplica + request. Cada vertical é dono das suas tabelas (bi_*, estado-desejado). Cada reator é dono da sua tabela de status. xadm-commons carrega o motor de escrita PowerSync-safe (upsert diff-aware, soft-delete, dedupe) — a disciplina é compartilhada por código, não por centralizar num hub. O ponto de integração é o banco, não um dispatcher.
D5 — Réplicas novas no Postgres do cliente PIED (pedido/cliente/produto) e thoms (produto/estoque) entram no Postgres do respectivo cliente, reaproveitando a topologia atual (um banco por cliente, compartilhado). O isolamento entre clientes é preservado; o acoplamento fica só entre peças do mesmo cliente (via o barramento).
D6 — Entrada no X-Adm (shape 3) via PowerSync bidirecional + máquina de status por linha O resultado do processamento fica em tabelas de estado-desejado na nuvem, cada linha com status PENDENTE → PROCESSANDO → IMPORTADO → ERRO (+ msg_erro). Um programa Java do lado do ERP conecta via PowerSync (connector do hub), lê PENDENTE, importa no X-Adm chamando o zimrtmu.exe (o runtime do ZIM que grava no ERP) e escreve o status de volta. Não cria canal de entrada novo no ERP — reusa o PowerSync que já roda. O poc-ps-java fica como POC/base do cliente PowerSync (consumo em Java exige a bridge Kotlin PsBridge.kt — a mesma que o xadm-commons usa nos reatores). Simetria: essa máquina de status por-linha é a mesma ideia da tabela request e generaliza para qualquer entrada futura.
D7 — Transporte X-Adm padronizado (por direção)
Saída — eventos em tempo real: push HTTP direto ao hub (o /api/v1/xadm atual permanece). Saída — arquivo/lote: X-Adm grava na outbox (JSON/XLS) → agente .jar roteia por tipo (config): dado bruto do ERP → hub; relatório → o vertical dono (excel.
D8 — Efeitos específicos de app são REATORES, fora do hub; packaging = microserviço fino
Reação/entrega específica (push, egress para API de terceiro) NÃO mora no hub — é um reator. Packaging fixado pelo proc-thoms construído: microserviço Micronaut FINO que consome o Postgres compartilhado por poll (@Scheduled, sweep ~60s) + um webhook poke HTTP para baixar a latência (near-real-time nos writes do X-Adm), é dono da sua tabela de status/auditoria (idempotência derivada de um watermark updated_at), reage e empurra pra fora. PowerSync e LISTEN/NOTIFY foram avaliados e descartados (decisão 0003 do webstorm-ecom): PowerSync existe para alcançar um consumidor sem acesso ao banco — mas o reator vive no mesmo banco; NOTIFY não persiste e exigiria o sweep de qualquer jeito. Padrão "Outbox = o próprio banco" (poll é o piso garantido; o poke é otimização de latência, não garantia). NÃO vive dentro do hub (plugin-no-hub = o monólito de volta, o análogo do "registro de telas" rejeitado no D12) e NÃO é FaaS (sem infra nova; sem problema de escala — um punhado de reatores; o "imposto de app" do D13 é uniforme e mais barato que levantar um FaaS). Não há base de reator compartilhada hoje: a engine (sweep, single-flight, leitura da réplica, mapper, egress em lote, auditoria) é escrita à mão no app — "fino" vem do banco fazer de fila, não de uma lib. O hub fica uniforme (só a cópia) pra TODO cliente; "tem push/egress" é composição explícita (deployar o reator), não config no motor genérico. FCM (navarro) e egress (thoms) são o mesmo padrão.
Sequência: proc-thoms foi o PRIMEIRO reator construído e a REFERÊNCIA — está em produção (webstorm.thoms.xadm.biz), com lote/breaker/retry/pacing configuráveis. O push FCM PERMANECE no integrador por ora (funciona hoje; a extração é purista, não funcional) — deferida (REGRA Nº 3). Gatilho pra GENERALIZAR num notificador único (regra/payload viram config): um 2º app precisar de push — antes disso, um reator por reação, com a regra em código. Aprendizado da referência: se algum dia sair uma "base de reator" compartilhada, ela padroniza sweep+poke+watermark, não um consumidor PowerSync.
D9 — Ingestão externa entra pela porta do PRÓPRIO serviço (não há gateway no hub) Cada vertical que recebe de fora (webhook/poll de fonte externa, arquivo) faz a própria recepção — auth de transporte (token do cadastro do serviço), dedupe, envelope de status — na SUA porta, via xadm-commons. Não há gateway cadastrável no hub (o hub não é porta de ingress). Webhook = fast path (tempo real); poll = reconciliação; idempotência por chave natural cobre a sobreposição — tudo dentro do serviço. Adicionar uma fonte = 1 serviço novo (vertical), sem tocar no hub.
D10 — Sem registro de serviços nem dispatch: o barramento é o banco Não há contrato de dispatch hub→serviço nem registro de serviços no hub. O ponto de integração é o banco compartilhado (owner-writes, D4): cada vertical escreve as suas tabelas; reatores consomem o banco via PowerSync; o hub não conhece as peças. UI: cada vertical tem a sua console (na sua porta, para a sua ingestão/domínio); o hub tem a sua (saúde da réplica/push); SSO (plano auth) unifica o login das portas. Custo aceito: mais de uma console por cliente — mitigado pelo SSO. Sem framework de plugin de UI.
D11 — a biblioteca da casa re-acopla; a política desacopla (o PILAR)
A lib da casa reintroduz acoplamento em compile-time: um breaking change toca o hub e TODOS os serviços que a consomem — o medo (a) voltando pela porta da biblioteca. Como AGORA cada vertical é standalone e fino graças a ela, a biblioteca é o pilar do desenho (é o que impede o erro (b)). Realidade construída (ADR 0019 do xadm-commons): não é o bi-commons único que a visão previa, e sim o xadm-commons multi-módulo — libs focadas, cada uma versionada e publicada por conta própria, o consumidor pina só o que usa. Módulos hoje: xadm-seguranca (auth), xadm-comum-web (RFC-7807/health/versão/Sentry), xadm-mensageria (outbox M2M + tela), xadm-comum-util (checksum/conversão), xadm-comum-storage (S3/Garage XLS), xadm-comum-teste (fixtures Testcontainers/ArchUnit), xadm-comum-powersync (admin/reset do PowerSync — não consumo), xadm-ingestion-core (seam SPI de ingestão XLS — o SAX/upsert são SPI do app, não da lib). Governança: SemVer por-módulo, tag <módulo>-vX.Y.Z, o release.yml publica só o módulo taggeado; distribuição via Maven registry do Forgejo (fonte.xadm.biz) + CI de release; consumidor pina a versão (nunca SNAPSHOT em produção); ZERO conhecimento de domínio, guardado por ArchUnit. Não existe (nem planejado no xadm-commons) uma base de reator / bridge Kotlin PsBridge — o reator é escrito à mão (D8); o upsert PowerSync-safe/envelope da visão original também não viraram módulo.
D12 — XLS segue vertical standalone (excel.
D13 — Todo hub/vertical/reator é um app da plataforma X-Adm
Cada peça (nova ou migrada) segue a governança da casa: repo próprio, checklist app-novo.md, docs/app.json (app_id; toolchain ∈ matriz-baseline da fábrica de CI), workflows docs/ci/release, /health OBRIGATÓRIO (constituição, Engenharia), GlitchTip via Central de Apps (constituição, Central de Apps), skills instaladas. Custo declarado ("imposto de app"): cada serviço paga o bootstrap completo — uniforme, proporcional para integrações de verdade, e é o que mantém a plataforma auditável (e mais barato que um runtime FaaS — D8). Transição de DNS: int.
- Como cada shape encaixa no alvo Shape 1 (push X-Adm): o hub recebe e aplica direto na réplica — a cópia fiel. É o integrador atual, estreitado (só isso + connector + write-back).
Shape 2 (XLSX/CSV): o vertical excel.
Shape 3 (PIED, entrada): vertical integracao/maxsul/pied com a PORTA PRÓPRIA e dois adaptadores de entrada para a mesma transformação: Webhook (tempo real): a PIED chama a porta do PRÓPRIO vertical (pied.maxsul.xadm.biz/webhook); o vertical recebe (auth de transporte, dedupe, status via xadm-commons), verifica assinatura e parseia. Poll REST (reconciliação): o vertical pola a API PIED direto (safety net / backfill). Ambos convergem no mesmo transform; o vertical grava as tabelas de estado-desejado (é dono delas) no banco do maxsul; o cliente Java no ERP importa e o status volta pelo connector do hub. Idempotência por chave natural torna a sobreposição webhook×poll segura. (Ver D6/D9.)
Shape 4 (thoms, saída): reator proc-thoms (em produção, webstorm.thoms.xadm.biz). O X-Adm grava JSON de produto/estoque na outbox; o agente envia para o hub (int.thoms.xadm.biz) — é dado bruto do ERP, vira a cópia fiel (réplica produto/estoque, sem prefixo, zero lógica thoms no hub). O proc-thoms (reator) consome a réplica por poll (@Scheduled, sweep ~60s) + webhook poke (não PowerSync), mapeia por cod_prod (EAN só correlação; só variante existente), e chama a API do parceiro (Webstorm, POST em lotes de 10, teto 200); mantém a SUA tabela webstorm_ecom_request (append-only, chave cod_prod, idempotência por watermark updated_at) — ecom offline/erro → o registro não avança o watermark e o próximo sweep reenvia (retry 2×, breaker em 3 falhas). É a referência do padrão de reator (D8).
Write-back de apps (bidirecional pelo lado do cliente): transversal ao shape 1 — o app mobile grava campos editáveis (nomes curtos) via uploadData → connector do hub. É o que faz sulplata/onpetrotrading bidirecionais. O hub trata como escrita nas tabelas que possui (a réplica), com a regra de que esses campos nunca são sobrescritos pelo push do X-Adm.
Push FCM (efeito específico do navarro_app): permanece no integrador POR ORA (D8). No alvo, é um reator como o proc-thoms — consome os eventos (PNOTAI/PBLDI) do banco e empurra o FCM. A extração é deferida até o proc-thoms provar o padrão de reator.
-
Detalhes de segundo nível (defaults propostos — abertos a veto) Recepção nos verticais (não no hub): cada vertical que recebe de fora faz dedupe + envelope de status via xadm-commons, na sua porta. Reusa o modelo de 2 fases dos -xls de hoje (recebe 202 → processa → status). Payload podre não bloqueia a fila; a UI do vertical permite REPLAY. O contrato exato entra na spec de cada vertical. Consumo nos reatores: poll (
@Scheduled, sweep ~60s) + webhook poke sobre o Postgres compartilhado (PowerSync/NOTIFY descartados — decisão 0003 dowebstorm-ecom). O reator lê a réplica por uma query com watermark (updated_at> último envio com sucesso); a tabela de status/auditoria própria garante idempotência (não reenvia a mesma versão) + retry com backoff + circuit-breaker. Cair/voltar re-sincroniza pelo próprio watermark; destino externo offline → o registro não avança o watermark e o próximo sweep reenvia. Números da referência (proc-thoms): lote 10 (teto 200), pacing 1s, breaker em 3 falhas, retry 2× no cliente. Auth: o write-back app→hub é por JWT (JWKS do auth). A ingestão externa de cada vertical autentica o transporte da fonte (token do serviço, comparação em tempo constante, fail-closed — padrão engenharia/seguranca). Não há canal hub↔serviço a autenticar — não existe dispatch. Orçamento de conexões por cliente: o Postgres é COMPARTILHADO — pools Hikari deliberadamente pequenos (hub 10, vertical/reator 5, minimum-idle 2). Subir pool é decisão de operação medida. Schema/Flyway: um dono por tabela (D4). Cada peça roda o seu Flyway (histórico separado, como o -xls já faz). Ownership disjunto no banco compartilhado, já em produção hoje. auth / authui ficam fora da reorg — plano de identidade compartilhado (JWKS que o PowerSync valida, admin, SSO das portas). Ortogonais. Custo do PowerSync-em-Java (só o cliente do ERP, shape 3 — os reatores NÃO usam PowerSync): o consumo em Java do lado do X-Adm exige a bridge Kotlin (PsBridge.kt, ~190 linhas, basepoc-ps-java) + cuidados de config (streams edition: 3, callback sempre-200, JWKS inline > jwks_uri). Ficar de olho no repo do PowerSync — deve sair uma API Java pura que elimina a bridge. Ainda não há módulo da casa que encapsule essa bridge (oxadm-comum-powersynccobre só admin/reset, não consumo). -
Roadmap de migração (incremental, sem big-bang) Ordenado do menor risco/maior aprendizado para o maior. Progresso (2026-08): os passos 1–3 já saíram em boa parte —
xadm-commonspublicado (8 módulos), proc-thoms em produção, int-pied capturando da API PIED real; o mecanismo de reator emergiu como poll+poke (não PowerSync). Segue o plano com o estado anotado: -
[feito, com correção de rota] Extrair a biblioteca da casa do plumbing duplicado. Saiu como
xadm-commonsmulti-módulo (não obi-commonsúnico da visão original), Maven registry do Forgejo + CI de release (D11). A "base de reator (consumidor PowerSync)" prevista NÃO foi construída — o reator provou-se hand-rolled sobre poll+poke, então não há o que extrair aí ainda. - [feito, em prod] thoms (reator proc-thoms). Exercitou o shape 4 (egress) + transporte de saída (outbox+agente → hub → réplica) + o consumo — que ficou poll+poke, não PowerSync (decisão 0003 do
webstorm-ecom). É a REFERÊNCIA do padrão de reator (D8): saiu fino, mas o "fino" vem do banco-fila, não de lib. - [em curso] PIED (novo vertical pied.
). Versão complexa/Caminho 2 — shape 3 inteiro (porta própria: webhook + poll → estado-desejado + status → cliente Java no ERP). Captura da API PIED real validada; Fase 2 (push ao ERP) atrás de gate manual. O poc-pied-simples é referência do Caminho 1; o poc-ps-java é a base entregue aos devs do ERP. - Evoluir o integrador → hub ESTREITO: manter o push X-Adm + connector PowerSync + write-back; remover/não-migrar o que não é a cópia fiel. O push FCM PERMANECE no integrador por ora (D8) — sua extração para reator é um passo posterior, disparado quando o padrão de reator (passo 2) estiver provado.
- (removido) ~~Migrar os -xls pra trás do hub~~ — os -xls ficam como estão (standalone excel.
, D12→B). Em vez disso: emagrecê-los oportunisticamente, extraindo o plumbing pro xadm-commons, SEM mudar a porta. Dependências do lado do X-Adm (time do ERP): o agente .jar (outbox → nuvem, roteia por tipo) e o cliente PowerSync (espelho → ERP) são genéricos e entregues pelo time do X-Adm. poc-ps-java é a base do cliente PowerSync.
Cada passo é entregável sozinho e não bloqueia os apps existentes.
- Questões — status
Resolvidas
Hub estreito: o hub é só a cópia fiel do X-Adm (in/out) + connector PowerSync; NÃO é porta de ingress. Ingress não-xadm é do vertical. (D2)
Packaging dos reatores: microserviço Micronaut fino que consome por poll (
@Scheduled) + webhook poke — PowerSync e NOTIFY descartados; não FaaS, não plugin-no-hub; engine escrita à mão (não há base de reator). proc-thoms em produção prova. (D8,webstorm-ecomADR 0003) Gatilho dos reatores (mecânica fina): DECIDIDO no build da referência — poll (sweep ~60s) como piso garantido + webhook poke como camada de latência; "banco compartilhado = a fila". NOTIFY/PowerSync avaliados e rejeitados. (D8) Idempotência / chaves naturais por fonte: VALIDADO contra a API PIED real (produto=product_code, cliente=documento, pedido=code; saída X-Adm=CgcCpf/CodigoAlt/xPed/(xPed,CodigoAlt); thoms=cod_prod, EAN só correlação). A lição "a doc da fonte mentiu sobre o filtro de delta" confirmou-se (apiSentnão existe;lastUpdateAftersó vale em pedidos) e está tratada: full-rescan de produto/cliente por rodada, janela sobreposta em pedido, upsert idempotente + reconciliação. (4.2, int-pied ADR 0001) Biblioteca da casa — mecânica de release: FEITO —xadm-commonsmulti-módulo, publicação via Maven registry do Forgejo + CI de release, SemVer por-módulo (tag<mod>-vX.Y.Z). (D11, ADR 0019) FCM: permanece no integrador por ora; extração deferida até valer a pena um 2º reator/notificador. (D8) XLS: segue standalone (excel.), não migra pra trás do hub. (D12→B) Egress do thoms (estado de envio): tabela própria do reator ( webstorm_ecom_request, chavecod_prod, idempotência por watermarkupdated_at), NÃO coluna na réplica, NÃOthoms_envio/EAN. (4.2) Transporte X-Adm ↔ nuvem: padronizado (saída = push tempo real / outbox+agente roteado; entrada = espelho + PowerSync). (D7) Nomenclatura das tabelas: sem prefixo = dado do ERP (sincroniza);= derivado (sincroniza); = interno (não sincroniza). (4.2)
Ainda abertas
Multi-tenancy: fica só para o futuro. D2 fixa 1-deploy-por-cliente; revisitar apenas se o número de clientes explodir.
NFRs por shape: sendo fechados por-app como previsto (proc-thoms e int-pied já têm os seus — lote/pacing/breaker; poll 6h/full-rescan). Falta só consolidar a nota geral (volumetria, SLA de entrega do thoms, retenção do storage) quando cada spec estabilizar.
Dados pessoais na réplica (LGPD): CPF/CNPJ trafega confirmadamente (int-pied usa documento como chave). Stub aberto em Dados pessoais (LGPD) — a definir; a política (retenção, acesso, logs, quem acessa as consoles via SSO) é do usuário/jurídico.
Base de reator compartilhada: NÃO existe nem está planejada no xadm-commons; cada reator é hand-rolled. Decidir se vale extrair (sweep+poke+watermark) quando surgir o 2º reator — não antes (evitar abstração prematura sobre 1 exemplo).
- Glossário
Hub: a cópia fiel do banco do X-Adm por cliente (int.
.xadm.biz) — recebe o push do X-Adm (réplica) e dado bruto do ERP, é o connector do PowerSync (apps + cliente do ERP) e aplica os write-backs. NÃO é porta de ingress de relatório/fonte externa. O integrador estreitado. Vertical de integração: app standalone com PORTA PRÓPRIA que recebe de fora (relatório/arquivo, webhook/poll), faz o específico e é dono das suas tabelas no banco do cliente (owner-writes). Fino via xadm-commons. Ex.: excel. , pied. . Reator: app standalone fino que NÃO recebe de fora — consome o banco do cliente por poll ( @Scheduled) + webhook poke (não PowerSync), é dono da sua tabela de status/auditoria (idempotência por watermark), e empurra pra fora (API de terceiro, push). Ex.: proc-thoms (em prod). FCM (navarro) será um reator, mas fica no integrador por ora (D8). Barramento: o Postgres por cliente — onde todas as peças daquele cliente escrevem (owner-writes) e leem. É o ponto de integração, no lugar de um dispatcher no hub. xadm-commons: a biblioteca da casa, multi-módulo (8 libs focadas:xadm-seguranca,xadm-comum-web,xadm-mensageria,xadm-comum-util,xadm-comum-storage,xadm-comum-teste,xadm-comum-powersync[admin/reset],xadm-ingestion-core[seam SPI]) — plumbing genérico, ZERO domínio, cada consumidor pina o que usa. Publicada no Maven registry do Forgejo (SemVer por-módulo). Pilar do desenho (D11). NÃO é um app. Não inclui base de reator nem bridge PowerSync Kotlin. Owner-writes: invariante de que cada tabela tem um dono (roda o Flyway e escreve). Hub = réplica + request; vertical = suas tabelas; reator = sua tabela de status. Estado-desejado (sem prefixo): tabelas na nuvem que representam o que deveria estar no X-Adm; consumidas pelo cliente Java do ERP via PowerSync, com máquina de status por linha. São dado do ERP → sem prefixo (4.2). zimrtmu.exe: o runtime do ZIM chamado pelo cliente Java do lado do ERP para gravar de fato no X-Adm as linhas PENDENTE do estado-desejado (o "importa no ERP" do shape 3). Shape: a forma do fluxo de dados (1 push X-Adm, 2 batch arquivo, 3 entrada→ERP, 4 push→API externa). A unidade de organização. PowerSync bidirecional: o mesmo PowerSync (connector no hub) serve saída (X-Adm→apps) e entrada (cloud→ERP via cliente Java + write-back). Os reatores NÃO usam PowerSync — consomem o banco por poll+poke. App de cliente: app que o usuário final usa (mobile, BI); consome via PowerSync. Não é integração.
Operação
Implantação — integração Maxsul (PIED)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-08
Como os quatro componentes da integração PIED → X-Adm da Maxsul ficam de pé, como se confere que subiram e como se desligam — hoje em staging. Traz também a config para um dev da XADM rodar os clientes no VSCode.
O desenho — arquitetura, fluxo de processamento, modelo de dados, contratos — é do livro: Documentação Completa, e o porquê do fluxo de entrada está na decisão 0013. Aqui não se repete: o que é daqui é o que subir, com que variáveis, como conferir e como desligar.
Topologia de operação¶
PIED ──webhook/REST──▶ int-pied ──PUT/DELETE /api/v1/xadm──▶ Integrador
pied.maxsul.xadm.biz ◀──GET /api/v1/xadm/retorno── int.maxsul.xadm.biz
│ grava Postgres (db_maxsul)
▼
PowerSync ◀──replicação lógica── Postgres
ps.maxsul.xadm.biz
│ SQLite streaming
▼
clientes ps-java / ps-dart (réplica no ERP)
└──write-back status──▶ POST /api/v1/powersync (Integrador)
| Componente | Host | Recurso Coolify | Repo |
|---|---|---|---|
| Integrador | int.maxsul.xadm.biz |
App (Dockerfile) | integracao/integrador |
| int-pied | pied.maxsul.xadm.biz |
App (Dockerfile) | clientes/maxsul/int-pied |
| PowerSync | ps.maxsul.xadm.biz |
Docker Compose | clientes/maxsul/powersync |
| Clientes | — (rodam no ERP / na máquina do dev) | — | clientes/maxsul/ps-java, ps-dart |
Banco: um Postgres, database db_maxsul (Postgres 18+ — o int-pied usa uuidv7()
nativo). Três papéis de conexão:
user_maxsul— usuário da aplicação; Integrador e int-pied conectam com ele e escrevem (o int-pied gerencia as tabelaspied_*com histórico Flyway próprioflyway_schema_history_pied; o Integrador é dono das tabelas-espelho e do Flyway principal). Ambos partilham o schemapublic.powersync_role— só leitura, com privilégio de replicação (WAL) — ver preparação do Postgres.
Convenção: staging agora, produção depois¶
Hoje estamos em staging: o operador entra no /console do int-pied e libera cada pedido à mão.
A promoção para produção é um conjunto de flags a virar — reunidas em
Promoção para produção. Todo passo abaixo já traz a coluna staging e a
nota do que muda em produção.
1. Integrador — int.maxsul.xadm.biz¶
Recurso App no Coolify, build do Dockerfile do repo integracao/integrador (Forgejo → Coolify).
Detalhe da config: application-config.md e deploy-coolify.md.
Coolify: porta interna 8080 · domínio int.maxsul.xadm.biz (TLS automático) · health check
GET /health.
Variáveis de ambiente:
| Variável | Valor (staging) | Nota |
|---|---|---|
CLIENTE |
maxsul |
Vira a tag cliente de todo evento GlitchTip (separa o tenant no projeto único). |
DATASOURCES_DEFAULT_URL |
jdbc:postgresql://<host>:5432/db_maxsul |
JDBC. |
DATASOURCES_DEFAULT_USERNAME |
user_maxsul |
Usuário-app da Maxsul. |
DATASOURCES_DEFAULT_PASSWORD |
(secret) | |
INTEGRADOR_API_TOKEN |
(secret — gerar 1 segredo forte) | Bearer estático de /api/**. Mesmo valor usado pelo int-pied e pelos clientes (uploadToken). Nome por destino (quem valida é o integrador), norma da constituição em engenharia/seguranca.md; detalhe local em Segurança. O nome antigo API_BEARER_TOKEN não é lido: app.api-token: ${INTEGRADOR_API_TOKEN:} não tem fallback, e setar só o antigo deixa o validador dormente (/api/** aberto) ou o boot recusado pela guarda da xadm-seguranca. |
SENTRY_DSN |
https://77e5b71d9213456caea902ec36aae742@bug.xadm.biz/16 |
DSN do projeto GlitchTip integrador (fonte: docs/app.json; igual em todo cliente). |
SENTRY_ENVIRONMENT |
staging |
Em produção → production. |
Sem MICRONAUT_ENVIRONMENTS (produção = application.yml + ENV). PUSH_* ficam desligados
(default false) — a Maxsul não usa FCM.
Migrations: o Flyway roda no start; garanta que o user_maxsul tem permissão de DDL no public.
2. int-pied — pied.maxsul.xadm.biz (staging)¶
Recurso App no Coolify, build do Dockerfile do repo clientes/maxsul/int-pied.
Config detalhada: clientes/maxsul/int-pied/docs/operacao/configuracao.md; modo staging:
ADR 0004-modo-staging-gate-manual.md do mesmo repo.
Coolify: porta interna 8080 · domínio pied.maxsul.xadm.biz · health GET /health.
Variáveis de ambiente:
| Variável | Valor (staging) | Nota |
|---|---|---|
DATASOURCES_DEFAULT_URL |
jdbc:postgresql://<host>:5432/db_maxsul |
Mesmo banco do Integrador (schema public, tabelas pied_*). |
DATASOURCES_DEFAULT_USERNAME |
user_maxsul |
|
DATASOURCES_DEFAULT_PASSWORD |
(secret) | |
PIED_TOKEN |
(secret — token da PIED) | Bearer da API PIED. Necessário para o poll e para as ações do /console que buscam na PIED. |
PIED_WEBHOOK_HABILITADO |
true |
Liga POST /webhook/pied. |
PIED_WEBHOOK_SECRET |
(secret do painel PIED, ou vazio) | Vazio = aceita sem validar (MVP). Cadastro do webhook na PIED é manual (ver abaixo). |
PIED_POLL_HABILITADO |
false |
Staging opera por webhook + console. Ligar (true) só com PIED_TOKEN válido. |
PIED_INTEGRACAO_HABILITADA |
true |
Liga a Fase 2 (transform + push ao Integrador). |
PIED_INTEGRACAO_MODO |
STAGING |
Chave do staging: transforma e enfileira (NA_FILA); nada vai ao Integrador até o operador liberar no /console. |
INTEGRADOR_BASE_URL |
https://int.maxsul.xadm.biz |
Base do Integrador (item 1). |
INTEGRADOR_API_TOKEN |
(o mesmo secret do item 1) | Bearer que o int-pied manda ao Integrador (integrador.token). Nome pelo destino, e é o mesmo nome dos dois lados: aqui é para quem eu falo, no item 1 é quem eu valido. |
PORT |
8080 |
(default) |
Não setar
SENTRY_DSN: o entrypoint do container lêdocs/app.json(projeto GlitchTip15) e exporta sozinho. Setar à mão fura a fonte única.
Cadastro do webhook na PIED (manual, uma vez): no painel da PIED, apontar o webhook para
https://pied.maxsul.xadm.biz/webhook/pied (com o header X-Pied-Secret = PIED_WEBHOOK_SECRET, se
definido).
Segurança:
/console,/capturae/test/glitchtipestão abertos, sem auth, e expõem PII e ações ao vivo (push ao X-Adm, fetch na PIED). Proteger (basic-auth do Coolify ou allowlist de IP) antes de expor fora da rede.
Operação em staging (o passo do operador): com pedidos na fila, abrir
https://pied.maxsul.xadm.biz/console e usar Processar próximo (libera 1, inspeção item a item)
ou Processar todos os pendentes. Cada item liberado vira PUT /api/v1/xadm no Integrador.
3. PowerSync — ps.maxsul.xadm.biz¶
Recurso Docker Compose no Coolify, repo clientes/maxsul/powersync (imagem
journeyapps/powersync-service:1.20.5, config assada na imagem — mudar powersync.yaml = rebuild).
Operação do dia a dia: clientes/maxsul/powersync/docs/operacao/runbook.md. Conceitos (volumes,
publicação, replica identity): powersync-docker.md e
powersync-runbook.md.
3.1. Preparar o Postgres origem (uma vez)¶
No db_maxsul, habilitar replicação lógica e criar o papel de leitura (detalhe completo e
pg_hba.conf em powersync-docker.md):
-- cluster (superusuário), uma vez
ALTER SYSTEM SET wal_level = logical; -- exige restart do Postgres
CREATE ROLE powersync_role WITH REPLICATION BYPASSRLS LOGIN PASSWORD '<senha_forte>';
-- em db_maxsul
GRANT CONNECT ON DATABASE db_maxsul TO powersync_role;
\c db_maxsul
GRANT USAGE ON SCHEMA public TO powersync_role;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO powersync_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO powersync_role;
CREATE PUBLICATION powersync FOR TABLE contratos, propriedades, fones, itensped, estoque, formulas,
integracao_remessa, integracao_remessa_item;
As tabelas-espelho nascem na Fase 2 do int-pied e as duas do manifesto de remessa na V27 do Integrador; até existirem, o PowerSync loga "tabela ausente" — não é incidente.
Instalação que já existe (
FOR TABLEcom lista antiga): o deploy da V27 exige um passo manual. Nenhuma migration toca a publication. Com lista explícita, as tabelas que a V27 cria ficam fora: o PowerSync nunca vêintegracao_remessa/_item, ointegrador-clientrecebe manifesto vazio, cai no caminho por presença — e a correção do "filho sem pai" fica inerte sem erro nenhum. Depois da V27 aplicada:-- confira primeiro: 't' = FOR ALL TABLES (nada a fazer; as tabelas novas entram sozinhas) SELECT puballtables FROM pg_publication WHERE pubname = 'powersync'; -- se 'f', veja a lista e adicione o que faltar SELECT tablename FROM pg_publication_tables WHERE pubname = 'powersync'; ALTER PUBLICATION powersync ADD TABLE integracao_remessa, integracao_remessa_item;(
ADD TABLEnuma publicationFOR ALL TABLESdá erro — por isso a conferência vem antes.) É o mesmo passo quebaixa-mdfe-sascar.mdjá manda para a tabelamdf. Esta lista também estava semformulas, que produção comprovadamente replica (os logs do incidente de 08/09 mostram o coletor processandoformulas) — ou seja, produção não está como este doc dizia; a conferência acima é o que vale, não o doc. Ostaging/e2e-maxsul-pied(tests) mede isso sozinho:0-pull-dump -Checarreporta a publication de produção e o3-verifyreprova se o manifesto estiver fora. Tabelas sem PK que entram na publicação precisam deREPLICA IDENTITY(ver powersync-docker.md).
3.2. Variáveis de ambiente (secrets do recurso Coolify)¶
| Variável | Valor | Nota |
|---|---|---|
PS_DATA_SOURCE_URI |
postgresql://powersync_role:<senha>@<host>:5432/db_maxsul |
URI postgresql:// completa (não JDBC); @ na senha → %40. |
PS_MONGO_URI |
mongodb://psadmin:<senha>@mongo-maxsul:27017/powersync_maxsul?authSource=admin&directConnection=true |
Host = nome do serviço (mongo-maxsul); senha SEM @ nem :. |
MONGO_ROOT_USER |
psadmin |
Igual ao user embutido em PS_MONGO_URI. |
MONGO_ROOT_PASSWORD |
(secret) | Igual à senha embutida em PS_MONGO_URI. |
SERVICE_FQDN_POWERSYNC / SERVICE_URL_POWERSYNC |
ps.maxsul.xadm.biz |
Magic vars do Coolify → domínio + labels Traefik. |
A auth dos clientes (client_auth com JWK inline) já vem no powersync.yaml — ver item 4.4.
3.3. Subir¶
Pelo Coolify (deploy do recurso) ou, no servidor:
docker compose up -d --build # config assada na imagem → sempre --build
docker compose logs -f powersync
Nunca docker compose down -v em produção (apaga mongo_storage/mongo_meta → resync total).
4. Clientes ps-java / ps-dart — rodar no VSCode¶
Não são apps de UI: são CLIs daemon que mantêm a réplica SQLite e fazem write-back. Ambos rodam
a partir de dist/ (onde estão a config *.properties e a chave maxsul-private.pem) — rodar de
outro diretório quebra o carregamento da chave. Guias no repo: LEIAME.md (operador) e
DESENVOLVIMENTO.md (dev).
4.1. ps-java (JDK 21+)¶
# extensão VSCode: "Extension Pack for Java"; deixar o Gradle sync rodar
./gradlew shadowJar # gera e copia o jar para dist/
cd dist && ./rodar.sh # (rodar.bat no Windows)
./gradlew run não funciona out-of-box (cwd errado, não acha a chave). Para debugar no VSCode,
criar um launch para a main br.com.xadm.maxsul.ps.Main com working directory = dist/.
4.2. ps-dart (Dart SDK ≥ 3.7, não Flutter)¶
# extensão VSCode: "Dart"
dart pub get
dart build cli -o dist/build # NÃO use `dart compile exe` (native assets do PowerSync)
cd dist && ./rodar.sh # ou, do fonte: cd dist && dart run ../bin/ps_dart.dart
Cópia Windows (
C:\Work\Xadm\ps-dart): o bundle fica emdist\bundle\e roda pordist\rodar.bat. Odist\ps-dart.propertieslá vai com os valores descomentados (override ativo) para ops_dart.exejá compilado funcionar sem recompilar.
4.3. Configuração (ps-java.properties / ps-dart.properties em dist/)¶
| Chave | Valor de produção | Nota |
|---|---|---|
powersync.syncUrl |
https://ps.maxsul.xadm.biz |
PowerSync (item 3). |
powersync.uploadUrl |
https://int.maxsul.xadm.biz/api/v1/powersync |
Write-back no Integrador. |
powersync.uploadToken |
(o INTEGRADOR_API_TOKEN do item 1) |
Bearer do POST de upload. |
powersync.privateKeyPath |
maxsul-private.pem |
Chave que assina o JWT (ver 4.4). Já é default. |
jwt.kid / jwt.audience |
maxsul-ps-v1 / maxsul |
Batem com o client_auth do PowerSync. Já são default. |
4.4. Autenticação dos clientes — JWT self-signed¶
Os clientes são programas sem usuário nem senha: não "logam", provam posse de uma chave. O
cliente assina um JWT curto com a chave privada maxsul-private.pem; o PowerSync valida contra a
chave pública correspondente, embutida inline no client_auth do powersync.yaml. Sem serviço
de auth, sem login — a chave privada é a credencial (como uma API key / service account). É o
padrão máquina-a-máquina, e já vem configurado por default nos dois clientes.
Como está montado (já feito):
- Par de chaves:
maxsul-private.pem(RSA 2048 PKCS#8) entregue nodist/de cada cliente, fora do git (.gitignore); a pública emclientes/maxsul/powersync/keys/maxsul-public.pem. - PowerSync (
clientes/maxsul/powersync/powersync.yaml) —client_auth.jwkscom a pública inline (kid: maxsul-ps-v1,audience: ['maxsul']). Inline em vez dejwks_uride propósito: o fetch por URL doauth.xadm.bizdeu bug de rede em outras instâncias (vantroba/onpetro). - Clientes — defaults já apontam para a chave e os claims certos (
powersync.privateKeyPath=maxsul-private.pem,jwt.kid=maxsul-ps-v1,jwt.audience=maxsul,jwt.issuer=maxsul-ps,jwt.subject=maxsul-ps-{java,dart}).
O que o operador/dev precisa fazer: garantir que a maxsul-private.pem está no dist/ do cliente
(entregue fora do git) e que o PowerSync foi rebuildado com o powersync.yaml atual
(docker compose up -d --build — config assada na imagem). Rodar o cliente → sincroniza sem 401.
Rotação de chave: gerar novo par
(openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048), trocar o n e o kid no
powersync.yaml, atualizar maxsul-private.pem/jwt.kid no cliente, rebuild do PowerSync. Sintoma de
chave/audience errados: 401 PSYNC_S2101 no cliente.
Segurança: quem tiver a
maxsul-private.pememite token válido. Mantê-la fora do git (já no.gitignore), entregar por canal seguro, rotacionar okidse vazar.
Superfície de operação¶
O que o operador olha, depois de implantado:
https://pied.maxsul.xadm.biz/console— a fila de pedidos: o que foi capturado, o que estáNA_FILA, o que foiENVIADO/confirmado, e as ações (liberar, reenviar, reconciliar).https://pied.maxsul.xadm.biz/console/{code}— detalhe de um pedido, com o de-para dry-run.https://int.maxsul.xadm.biz/— as telas do integrador (tabelas-espelho, requests, debug).GET /healthnos três serviços; logs do PowerSync no Coolify.
Pedido preso em 1XX (erro terminal) — re-armar¶
1XX é terminal: o integrador-client só coleta linha com cod_retorno em 000 ou 9XX, e o
upsert do PUT preserva o retorno por regra (mão única — senão o re-push de rotina apagaria o
retorno de pedido já gravado no ERP). Resultado: Reimportar no painel do PIED não destrava um
1XX, por mais vezes que se clique — os pushes chegam e são aplicados, e o cod_retorno continua
o mesmo.
O botão Reimportar do painel do int-pied já faz o certo desde 2026-09-08: push do pedido
primeiro (dado fresco no espelho), re-armar depois. Use-o. O curl abaixo é o mesmo comando na
mão, para diagnóstico ou quando o painel está fora do ar — e sempre depois de o dado
corrigido já ter subido, senão o cliente coleta a linha com o valor velho.
curl -fsS -X POST \
-H "Authorization: Bearer $INTEGRADOR_API_TOKEN" \
"https://int.maxsul.xadm.biz/api/v1/xadm/retorno/pedido/reenfileirar?chave=260079421"
# → {"xPed":"260079421","contratos":1,"itensped":3,"formulas":2,"total":6}
Volta a 000 todos os itens em 1XX da remessa daquele pedido — as seis tabelas, inclusive o
pai (propriedades/fones/estoque). 006 (já no ERP) e 9XX (retenta sozinho) ficam
intactas, e linha soft-deletada não entra na conta.
O parâmetro passou a ser chave (o xPed segue valendo como alias), e ele aceita também o
cgc_cpf de uma remessa de cadastro de cliente. Consulte antes o que está travado:
curl -fsS -H "Authorization: Bearer $INTEGRADOR_API_TOKEN" "https://int.maxsul.xadm.biz/api/v1/integracao/remessas?origem=INT-PIED&status=TRAVADA"
A resposta traz, por remessa travada, os itens em 1XX com bloqueado. A causa real é a mensagem
do item NÃO bloqueado — os bloqueados são colaterais do pai morto, e reportá-los faz o operador
perseguir o sintoma errado. total: 0 significa
"não havia nada preso", não falha. Confirmar no ciclo seguinte pelo dXpEnvio novo no
integrador-client.log. Contrato completo em
Contratos do integrador.
Se total vier certo mas a linha voltar para 1XX no ciclo seguinte, não é o re-armar que falhou:
é o write-back do cliente carimbando o mesmo erro de novo — o dado na origem continua errado.
Não é mais fora de escopo: propriedades/fones/estoque presas em 1XX são alcançadas pelo
re-arme desde o manifesto de remessa — era o beco que deixava pedido travado sem saída.
Cadastro em 006 com dado corrigido — re-arme manual (caso 260081441)¶
Desde a revisão 2026-09-17 da decisão 0023, um
PUT com dado diferente rearma sozinho a linha 006 de propriedades/fones/estoque.
Mas PUT passado não se repete (caminho só de ida): para a linha que já está corrigida no espelho e
parada no 006 — como a do cliente abaixo — o re-arme é SQL na mão, depois de alinhar com a
equipe X-Adm o que o pAbast faz com o código 0 existente:
UPDATE propriedades
SET cod_retorno = '000', msg_retorno = NULL, updated_at = now()
WHERE TRIM(cgc_cpf) = '34586067934' AND TRIM(cod_retorno) = '006';
No ciclo seguinte o client coleta a linha e o dXpEnvio sai com o endereço corrigido. Conferir
pelo integrador-client.log (envio N: | ...propriedades ...).
Pedido lançado à mão no X-Adm — fechar a remessa¶
Há travamento que reenviar não resolve: o produto-kit que o ERP não criou, por exemplo. O operador
lança o pedido direto no X-Adm, e a remessa tem de ser fechada, não re-armada. O botão
"Importado manualmente" do painel do maxsul-pied faz isso. O curl abaixo é o mesmo comando na
mão:
curl -fsS -X POST \
-H "Authorization: Bearer $INTEGRADOR_API_TOKEN" -H "Content-Type: application/json" \
-d '{"observacao":"Lançado manualmente no X-Adm: kit sem produto.","operador":"fulano"}' \
"https://int.maxsul.xadm.biz/api/v1/integracao/remessas/resolver-manual?origem=INT-PIED&chave=260079421"
# → {"chave":"260079421","resolvidas":1}
A remessa vai de TRAVADA a RESOLVIDA_MANUAL, e a observação fica gravada nela. As linhas 1XX
não mudam. Só TRAVADA pode ser fechada: ABERTA dá 409, porque ainda está em entrega.
Depois disso, o re-arme daquela chave responde 409, porque reenviaria ao ERP o que já foi
lançado. Não há comando que desfaça a resolução; se precisar, é SQL na mão. Decisão:
0025.
Verificações pós-deploy¶
Cada passo declara o resultado esperado conferido no código, não o que parece razoável.
| Host | Como verificar | GlitchTip |
|---|---|---|
int.maxsul.xadm.biz |
GET /health → {"status":"UP","versao":"X.Y.Z"} (shape do HealthController; o status:"ok" é do /api/health, outro endpoint — não confundir). Provocar um erro controlado (requisição inválida a /api/** que gere log.error) e conferir o evento no projeto integrador filtrando pela tag cliente=maxsul. |
✅ projeto 16 |
pied.maxsul.xadm.biz |
GET /health → 200. Smoke do GlitchTip: POST https://pied.maxsul.xadm.biz/test/glitchtip → evento no projeto 15. |
✅ projeto 15 |
ps.maxsul.xadm.biz |
GET /probes/liveness → 200 + docker compose logs -f powersync sem erro de conexão. |
⚠️ não reporta — a imagem journeyapps não tem GlitchTip (glitchtip.enabled:false). Verificação é liveness + logs, por decisão. |
Fumaça do fluxo de entrada (staging): liberar um pedido no /console → o int-pied dá
PUT /api/v1/xadm no integrador → o pedido vira ENVIADO e a linha aparece na tabela-espelho.
Repetir o envio do mesmo pedido, sem mudança, → resultado PULADO, não um novo PUT: o
PushService compara o content_hash do payload antes de enviar (dirty-check de conteúdo). Um
segundo PUT aqui seria sintoma, não normalidade.
Reversão — desligar a integração sem derrubar o X-Adm¶
O int-pied descobre pedido por duas vias independentes (webhook e poll) e empurra ao integrador
por três caminhos: os dois jobs @Scheduled (transform e reconciliação) e o clique do
operador no /console. Desligar uma flag não para os envios:
| Caminho | Efeito |
|---|---|
| Parar o container do int-pied (Coolify) | Para tudo. Mais simples e garantido — prefira este |
PIED_INTEGRACAO_HABILITADA=false |
Para os dois jobs (TransformJob, ReconciliacaoJob). ⚠️ Não para o /console: POST /console/{code}/enviar, /reenviar e /enviar-todos chamam o PushService sem passar pela flag — e o /console está aberto, sem auth. Um clique ainda empurra ao X-Adm |
PIED_WEBHOOK_HABILITADO=false |
⚠️ Não desliga — corta só a recepção pelo webhook; o que já está capturado segue processável, e o poll (se ligado) segue trazendo pedido novo |
PIED_POLL_HABILITADO=false |
⚠️ Não desliga — o webhook segue recebendo (é o default true) |
PIED_INTEGRACAO_MODO=STAGING |
⚠️ Não desliga — só troca envio automático por manual; o operador segue empurrando pelo /console |
| Parar o container do integrador | Para a entrada no X-Adm, mas o int-pied segue capturando e acumulando falha de push (ERRO) |
Desligar de verdade, mantendo as telas no ar: PIED_INTEGRACAO_HABILITADA=false e bloquear
o /console (auth/allowlist no Coolify). Sem as duas, a via manual continua aberta.
Em qualquer caso a PIED segue postando no webhook (ou o poll acumula), e o atraso sai quando religar — nada se perde.
Reverter versão: redeploy da tag anterior no Coolify. As migrations do int-pied (pied_*, com
histórico Flyway próprio) e as do integrador são aditivas — reverter o container não desfaz schema.
Promoção para produção¶
Quando sair de staging, virar (na ordem):
- int-pied —
PIED_INTEGRACAO_MODO=PRODUCAO(push automático, sem gate no/console);PIED_POLL_HABILITADO=true(comPIED_TOKENválido) se quiser o backstop REST; confirmarPIED_WEBHOOK_SECRETreal com a PIED. - int-pied — proteger
/console,/captura,/test/glitchtip(auth/allowlist). Não é só PII: enquanto o/consoleestá aberto, ele é uma via de push ao X-Adm que nenhuma flag corta (§ Reversão). - Integrador —
SENTRY_ENVIRONMENT=production. - Clientes —
maxsul-private.pemde produção entregue nodist/e PowerSync rebuildado com o JWK inline (item 4.4). Trocar a chave dev-only por uma chave de produção, se ainda não feito.
Pares que têm de casar (segredos compartilhados — não commitar)¶
Valor que existe nos dois lados e diverge em silêncio é falha de implantação que só aparece depois, em produção. Enumerados:
| Ponta A | Ponta B | Sintoma se divergirem |
|---|---|---|
INTEGRADOR_API_TOKEN (integrador, define) |
INTEGRADOR_API_TOKEN (int-pied) |
PUT /api/v1/xadm responde 401; o pedido fica ERRO no /console, nada entra no espelho |
INTEGRADOR_API_TOKEN (integrador) |
powersync.uploadToken (ps-java / ps-dart) |
write-back do status responde 401; o ERP replica mas nunca confirma — o pedido trava em ENVIADO |
Senha do powersync_role (Postgres) |
PS_DATA_SOURCE_URI |
PowerSync não conecta na origem; sem replicação, cliente não recebe nada |
MONGO_ROOT_PASSWORD |
senha embutida em PS_MONGO_URI |
PowerSync sobe e falha no storage; /probes/liveness denuncia |
maxsul-private.pem (dist/ dos clientes) |
JWK público inline no powersync.yaml |
401 PSYNC_S2101 no cliente (ver 4.4) |
PIED_WEBHOOK_SECRET (int-pied) |
header X-Pied-Secret cadastrado no painel PIED |
webhook rejeitado — a PIED para de entregar e a captura fica só no poll (se ligado) |
A chave privada maxsul-private.pem fica fora do git (.gitignore) e é entregue por canal
seguro; quem a tiver emite token válido.
Referências¶
- Config do Integrador: application-config.md · deploy-coolify.md
- Observabilidade (tag
cliente): observabilidade.md - PowerSync (Postgres/volumes): powersync-docker.md · powersync-runbook.md
- int-pied:
clientes/maxsul/int-pied/docs/operacao/configuracao.md· ADR0004-modo-staging-gate-manual.md - PowerSync Maxsul:
clientes/maxsul/powersync/docs/operacao/runbook.md - Clientes:
clientes/maxsul/ps-java/DESENVOLVIMENTO.md·clientes/maxsul/ps-dart/DESENVOLVIMENTO.md
Implantação — integrador no Coolify (um recurso por cliente)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15
Como uma instância do integrador fica de pé no Coolify, como se troca o jar pelo native, como se confere que subiu e como se desliga. O integrador é multi-tenant por implantação: cada cliente tem o seu recurso Coolify, o seu subdomínio e o seu banco — não há um servidor único servindo vários.
O desenho — componentes, fluxo de dados, modelo de dados, autenticação — é do livro: Documentação Completa. A lista completa de propriedades e ENV é da configuração. Por que native e jar convivem e a ordem da troca estão na decisão 0028. Aqui fica só o que é de operar.
Quando usar¶
- Ao provisionar um cliente novo.
- Ao trocar o jar pelo native numa instância (§ Troca jar → native).
- Ao atualizar a instância de um cliente, ou ao reverter uma versão.
- Ao precisar desligar o webhook de saída sem derrubar o app (§ Reversão).
- Ao instalar um integrador-client no cliente (§ Ao instalar um integrador-client).
Implantação de uma integração inteira (com int-pied, PowerSync e clientes) tem runbook próprio: Maxsul (PIED).
Pré-requisitos¶
- Postgres do cliente provisionado, com um usuário que tenha DDL no schema (o Flyway roda no start do app).
- DNS do subdomínio do cliente (
int.<cli>.xadm.biz) apontando para o Coolify; na troca, também o do jar (int-jar.<cli>.xadm.biz). - Segredos do § Variáveis à mão (cofre da X-Adm).
- Imagem publicada: o
pipeline.ymlbuilda e publicafonte.xadm.biz/xadm/integrador-servercom as tags móveisnative-amd64ejar-amd64; o Coolify puxa, não builda. A primeira imagem vem de uma release ou de umworkflow_dispatch.
Topologia de operação¶
%% caption: O que se implanta, por cliente (durante a troca, os dois recursos no ar)
flowchart TB
PROXY["Proxy do Coolify<br/>(TLS automático)"]
NAT["integrador-server-<cli>-native-pull<br/>imagem native-amd64<br/>porta interna 8080"]
JAR["integrador-server-<cli>-jar-pull<br/>imagem jar-amd64<br/>porta interna 8080"]
DB[("Postgres do cliente<br/>(db_<cliente>)")]
PROXY -->|"int.<cli>.xadm.biz"| NAT
PROXY -->|"int.<cli>.xadm.biz até provar o native,<br/>depois int-jar.<cli>.xadm.biz"| JAR
NAT --> DB
JAR --> DB
| Peça | Onde | Nota |
|---|---|---|
| Integrador native | recurso integrador-server-<cli>-native-pull, Build Pack Docker Image |
o alvo padrão; porta interna 8080 |
| Integrador jar | recurso integrador-server-<cli>-jar-pull, Build Pack Docker Image |
fica no ar ao lado do native até a sexta instância trocar, no int.<cli> até a instância provar o native e no int-jar.<cli> depois; em seguida, parado como fallback |
| Proxy | Traefik do Coolify | termina TLS; o contrato com apps e proxies é o host (DNS), não a porta |
| Postgres | banco do cliente | o app é dono do schema e roda o Flyway no start |
Subdomínio: um por cliente, int.<cli>.xadm.biz (ex.: a instância da Maxsul é
int.maxsul.xadm.biz). O FQDN de cada cliente já implantado é o que está no recurso Coolify —
confira lá, não presuma pelo padrão.
Como o deploy acha o recurso: o reconcile do control-plane do central tira a instância do
primeiro FQDN do recurso, na forma int.<cli>.xadm.biz e, no jar da troca, int-jar.<cli>.xadm.biz
(Coolify da casa). O native
leva o int.<cli> como primeiro domínio. O jar leva o int.<cli> até a instância provar o native e o
int-jar.<cli> depois — só quando o reconcile no ar aceita esse host: o INSTANCE_FROM_FQDN do
DeployReconcileService do central-backend casa int-jar. Um recurso com domínio que o reconcile não
lê fica sem instância e colide no mapa de deploy com os de outros clientes.
Variáveis de ambiente¶
A lista completa, com defaults e o mapeamento propriedade Micronaut ↔ ENV, é da configuração — não se copia aqui. O mínimo para subir:
| Variável | Nota |
|---|---|
CLIENTE |
identificador do tenant; vira a tag cliente de todo evento no GlitchTip |
DATASOURCES_DEFAULT_URL |
JDBC do banco do cliente |
DATASOURCES_DEFAULT_USERNAME / DATASOURCES_DEFAULT_PASSWORD |
segredo — usuário com DDL no schema |
INTEGRADOR_API_TOKEN |
segredo, obrigatório — Bearer estático de /api/**. Nome por destino; o antigo API_BEARER_TOKEN não é lido (sem fallback). Ausente ou vazia, o app não sobe: o BearerTokenGuard da xadm-seguranca recusa o boot — ver segurança |
SENTRY_DSN |
obrigatório — DSN do GlitchTip (client-grade, fonte: docs/app.json). O nome GLITCHTIP_DSN não é lido: sem SENTRY_DSN o Sentry não inicializa e erro nenhum é reportado, só um WARN no boot (observabilidade) |
SENTRY_ENVIRONMENT |
staging ou production |
Sem MICRONAUT_ENVIRONMENTS: produção = application.yml + ENV. Opcionais por cliente: PUSH_*
(FCM), AUTH_* (login Google — segurança), WEBSTORM_* (poke de
saída ao tradutor, só onde há tradutor) e CENTRAL_API_TOKEN + CLIENTE_ID (heartbeat, só onde há
integrador-client — § Ao instalar um integrador-client). Segredos vão em secret do Coolify, nunca no
repo. Os recursos native e jar de uma instância têm as mesmas variáveis.
Pares que têm de casar¶
| Ponta A | Ponta B | Sintoma se divergirem |
|---|---|---|
INTEGRADOR_API_TOKEN (aqui; sem fallback — o antigo API_BEARER_TOKEN é ignorado) |
INTEGRADOR_API_TOKEN do tradutor (adapter CSV) / Bearer do cliente X-Adm / int-pied / uploadToken dos clientes PowerSync |
401 no PUT /api/v1/xadm ou no write-back; nada entra e o remetente acumula erro |
WEBSTORM_API_TOKEN (aqui; sem fallback — ${WEBSTORM_API_TOKEN:} também não é aninhado) |
WEBSTORM_API_TOKEN (tradutor) |
o poke responde 401; o sweep do tradutor mascara — envia atrasado e ninguém nota |
CENTRAL_API_TOKEN (aqui) |
CENTRAL_API_TOKEN dos recursos do central-backend |
o heartbeat responde 502 heartbeat_recusado + ERROR (invalid_token); a instalação some do painel e, depois de 6h úteis, o central alerta silêncio |
CLIENTE_ID (aqui) |
clientes.cliente_id no central-backend |
422 cliente_desconhecido no central, 502 heartbeat_recusado + ERROR aqui; a instalação nunca é registrada |
| variáveis do recurso native | variáveis do recurso jar da mesma instância | cada cópia se comporta diferente conforme a requisição cai numa ou noutra |
Passos — cliente novo¶
- Banco. Garantir o Postgres do cliente acessível e o usuário com DDL no schema.
- Recurso.
+ New Resource→ Build PackDocker Image→fonte.xadm.biz/xadm/integrador-server:native-amd64, nomeintegrador-server-<cli>-native-pull. Auto-deploy desligado: o deploy é o jobdeploydopipeline.yml, pelo control-plane. - Porta e domínio. Porta interna
8080; FQDNhttps://int.<cli>.xadm.bizcomo primeiro domínio. - Health check.
GET /health(oHEALTHCHECKda imagem já bate no mesmo endpoint). - Limite e reserva de memória do recurso, pela baseline de operação do Coolify.
- Variáveis. Preencher o § Variáveis.
- Deploy.
Redeployno recurso (ou a próxima release); o Flyway roda no start — acompanhar o log da primeira subida.
Troca jar → native (instância que roda jar)¶
A ordem entre as instâncias está na decisão 0028: a primeira é uma instância com o push ligado, e cada seguinte só depois de smoke verde e GlitchTip sem exceção nova na anterior.
- Crie o
integrador-server-<cli>-native-pullcomo nos passos 2–6 acima, com as mesmas variáveis do-jar-pullda instância e o mesmo FQDN (int.<cli>.xadm.biz) como primeiro domínio. Não mexa no recurso jar. - Deploy do native:
Redeployno recurso novo. O proxy passa a dividir o tráfego da instância entre jar e native. - Confira pelo § Verificação, e o split pelo
flavor: 20 chamadas ao/healthtêm de mostrar"flavor":"native"e"flavor":"jvm". - Mova o jar para o
int-jar.<cli>.xadm.biz, com smoke verde e GlitchTip sem exceção nova na instância. Antes, confira as duas pré-condições: o reconcile no ar aceita o host (§ Topologia, "Como o deploy acha o recurso") e o DNS deint-jar.<cli>.xadm.bizresolve para o Coolify. Sem o reconcile, o jar segue noint.<cli>e este passo espera. No-jar-pull, troque o domínio porhttps://int-jar.<cli>.xadm.bize façaRedeploy. O native fica sozinho noint.<cli>: 20 chamadas ao/healthsó mostram"flavor":"native", e ohttps://int-jar.<cli>.xadm.biz/healthmostra"flavor":"jvm". Rode o reconcile e confira no mapa de deploy o-jar-pullcom a instância<cli>e o alvojar. - Deixe os dois no ar. O jar desta instância não para agora: enquanto
jarestiver nobuild.targets, cada release redeploya o-jar-pull, e um recurso parado voltaria a subir. - Depois da sexta instância: a release que tira
jardobuild.targets; em seguida, pare os seis-jar-pull(ficam parados como fallback, nunca apagados).
Verificação¶
Resultado esperado conferido no código, não no que parece razoável.
GET /health→{"status":"UP","versao":"X.Y.Z","flavor":"native",…}— shape do/healthdaxadm-comum-web. O{"status":"ok"}é do/api/health, outro endpoint: esperar"ok"no/healthdá falso alarme no dia do deploy. Aversaotem de bater com a tag implantada (health e versão).- Abrir a raiz (
/) → as telas sobem com o cabeçalho X-Adm e o nome do cliente (UI das telas). PUT /api/v1/xadmsem o Bearer →401. Com o Bearer →200.- Provocar um erro controlado e conferir o evento no GlitchTip filtrando por
cliente=<CLIENTE>. - Log do start sem falha de Flyway (migration pendente aplicada).
- Na instância piloto da troca: uma nota que dispara push chega uma vez ao celular, e aparece uma
linha por mensagem em
/push-enviadas.
Ao instalar um integrador-client¶
O heartbeat do integrador-client passa por esta instância a caminho do central-backend
(contrato). Nas instâncias sem client as duas envs ficam vazias:
nenhum heartbeat chega, e a config vazia só responde 503 se alguém chamar a rota.
- Ordem. A versão do integrador-server com
POST /api/v1/heartbeattem de estar no ar antes do jar do client com heartbeat. Ao contrário, o client leva404e só avisa, mas aqui cada404autenticado vira ERROR no GlitchTip — uma issue por hora. - Central. Conferir que o cliente existe na tabela
clientesdo central-backend (ocliente_id, ex.:maxsul). - Envs.
CLIENTE_ID= essecliente_id;CENTRAL_API_TOKEN= o mesmo valor cadastrado nos recursos do central — nos recursos native e jar da instância. Redeploy. - Prova, antes do client.
curl -X POST https://<fqdn>/api/v1/heartbeat -H "Authorization: Bearer <INTEGRADOR_API_TOKEN>" -H "Content-Type: application/json" -d '{"versao":"smoke","fluxos":[]}'→204. A instalação aparece no painel do central com a versãosmokeaté o client real bater (no máximo 1h depois da instalação do jar).502/503aqui: ver incidentes comuns.
Reversão¶
| Caminho | Efeito |
|---|---|
Parar o -native-pull da instância (durante a troca) |
Com o jar ainda no int.<cli>, ele segue atendendo sozinho. Com o jar já no int-jar.<cli>, devolva antes o int.<cli> como primeiro domínio do -jar-pull e faça Redeploy: o recurso que fica assume o domínio antes de o outro parar, senão o int.<cli> responde 503. É a volta do native |
| Parar os recursos da instância (Coolify) | Para tudo nesta instância. Mais simples e garantido |
Apontar a tag imutável <sha>-native (ou <sha>-jar) no recurso e Redeploy |
Volta a versão. As migrations são aditivas — reverter o container não desfaz schema; migration nova em produção só se reverte à mão |
WEBSTORM_WEBHOOK_ENABLED=false |
Corta só o poke de saída ao tradutor (EstoqueWebhookListener). ⚠️ Não para a integração: o tradutor tem sweep próprio e segue descobrindo mudança no banco sozinho — desligar o envio ao parceiro é no repo do tradutor |
PUSH_ENABLED=false |
Corta só o push FCM. Não afeta ingestão nem replicação |
Esvaziar CLIENTE_ID ou CENTRAL_API_TOKEN |
⚠️ Não é um desligamento limpo — não há flag do heartbeat neste server: cada heartbeat passa a responder 503 heartbeat_desligado com ERROR no GlitchTip. Desligar o heartbeat é no client (heartbeat.intervaloMinutos=0); parar de acompanhar a instalação é no painel do central |
Nenhuma dessas flags corta a entrada: enquanto o app está no ar, o X-Adm e o int-pied seguem
podendo PUT /api/v1/xadm com o Bearer válido. Para fechar a entrada, pare o container ou
rotacione o INTEGRADOR_API_TOKEN.
Referências¶
- Configuração completa (propriedades ↔ ENV): application-config.md
- Build e diagnóstico do native: native-image.md
- Perfis Micronaut: micronaut-profiles.md
- Observabilidade (tag
cliente): observabilidade.md - Implantação de integração completa: Maxsul (PIED)
- Operação no Coolify da casa: docs.xadm.biz/infraestrutura/coolify
PowerSync - Comandos Docker¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06
Documentação dos comandos para operar o Docker Compose do PowerSync no servidor.
Runbook (volumes, restart seguro, down -v): Fase 5 — migration-phase5-powersync-runbook.md.
Localização (um Compose por cliente)¶
Cada cliente tem a sua pasta no repositório, com docker-compose.yaml e config.yaml juntos — por exemplo:
powersync/sulplata/powersync/onpetrotrading/powersync/vantroba/powersync/onpetro/
No servidor, faz-se cd para a pasta desse cliente (clone do mono-repo ou cópia só dessa pasta) e usa-se docker compose aí dentro. Visão geral: Fase 5 — migration-phase5-powersync-runbook.md.
Exemplo:
cd /caminho/do/repo/powersync/sulplata
Docker Compose v2 (plugin)¶
A partir do Docker Compose v2, o comando é o subcomando do Docker CLI: com espaço — docker compose — e não o binário legado docker-compose (hífen, v1).
Para conferir a instalação:
docker compose version
O arquivo de definição neste projeto continua sendo docker-compose.yaml (nome convencional; em outros projetos também se usa compose.yaml).
PostgreSQL: replicação lógica, role e publicação¶
Antes de subir o PowerSync, cada banco usado pelo integrador (ex.: integracao_sulplata, vantroba, onpetro, …) precisa estar preparado para replicação lógica. O PowerSync lê o WAL do Postgres e exige publicação nomeada powersync e um usuário com privilégio de replicação e SELECT nas tabelas publicadas.
Documentação oficial (inglês): Source Database Setup — Postgres.
1. Habilitar replicação lógica (wal_level)¶
O tipo de WAL deve ser logical (não apenas replica).
SHOW wal_level;
Se não for logical, como superusuário:
ALTER SYSTEM SET wal_level = logical;
Reinicie o PostgreSQL para aplicar. Em instalações próprias, isso costuma estar em postgresql.conf como wal_level = logical.
Ajuste também limites se tiver muitas instâncias PowerSync (cada uma usa slots de replicação), por exemplo em postgresql.conf ou via ALTER SYSTEM:
max_wal_senders = 10
max_replication_slots = 10
(Valores mínimos dependem de quantos serviços PowerSync e slots ativos existem; monitore com os comandos da documentação de manutenção.)
2. pg_hba.conf: permitir o host do PowerSync¶
O container do PowerSync acessa o Postgres pelo IP do host (no compose atual costuma ser algo como 172.17.0.1 apontando para a porta do servidor). Inclua regras que permitam conexões desse host até o banco, com o usuário do PowerSync (ex.: powersync_role), usando o método de autenticação que você usa (ex.: scram-sha-256).
Exemplo (ajuste IP/rede e o nome do usuário):
host all powersync_role 172.17.0.0/16 scram-sha-256
Recarregue o Postgres (pg_ctl reload ou SELECT pg_reload_conf();) após alterar o pg_hba.conf.
3. Role do PowerSync e permissões (cluster + por banco)¶
O usuário (powersync_role ou outro nome alinhado ao config.yaml) costuma ser criado uma vez no cluster. Em cada database onde existir uma instância PowerSync, conceda CONNECT, permissões no schema e a publicação.
Uma vez no cluster (como superusuário, ex.: postgres):
CREATE ROLE powersync_role WITH REPLICATION BYPASSRLS LOGIN PASSWORD 'defina_uma_senha_forte';
Em cada database do integrador (repita trocando nome_do_banco; URI típica no YAML: postgresql://powersync_role:...@172.17.0.1:32123/nome_do_banco):
GRANT CONNECT ON DATABASE nome_do_banco TO powersync_role;
\c nome_do_banco
GRANT USAGE ON SCHEMA public TO powersync_role;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO powersync_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO powersync_role;
Se preferir restringir leitura e publicação só a tabelas específicas (recomendável com muitos dados), liste explicitamente as tabelas no GRANT SELECT e na publicação (próximo passo), como na documentação do PowerSync.
4. Publicação powersync (obrigatório o nome)¶
A publicação deve se chamar powersync.
Opção simples (todas as tabelas do schema; bom para dev ou bases pequenas):
CREATE PUBLICATION powersync FOR ALL TABLES;
Opção enxuta (ex.: só pAbast, alinhado aos sync rules iniciais):
CREATE PUBLICATION powersync FOR TABLE pabast_empresa, pabast_senhas;
Nota: o serviço PowerSync processa as alterações das tabelas presentes na publicação, mesmo que o config.yaml filtre no sync rules — por isso, em produção com volume grande, prefira publicar só o necessário.
Para alterar uma publicação existente: ALTER PUBLICATION ... ADD/DROP TABLE ... (veja documentação PostgreSQL).
5. Um banco por cliente¶
Repita os passos 3 e 4 em cada database usado por uma instância PowerSync (ex.: Sul Plata, On Petro Trading, Vantroba, On Petro), pois publicação e role são por database.
6. Replica identity (DELETE/UPDATE com publicação lógica)¶
Se uma tabela entra na publicação powersync e o Postgres replica DELETE (e UPDATE), o servidor precisa saber qual linha foi afetada ao escrever no WAL. Sem isso, ocorre erro ao excluir/atualizar, por exemplo:
cannot delete from table "faturamento" because it does not have a replica identity and publishes deletes
HINT: To enable deleting from the table, set REPLICA IDENTITY using ALTER TABLE.
- Tabelas com PRIMARY KEY (ou índice único adequado) costumam usar a identidade padrão (
DEFAULT= usar a PK) e raramente precisam de ajuste. - Tabelas sem chave primária (como
faturamento/movimentono BI Vantroba) precisam de replica identity explícita.
Opção usual para tabelas sem PK (envia a linha inteira no WAL em UPDATE/DELETE; mais volume de WAL, mas simples):
-- Exemplo: banco vantroba, tabelas BI sem PK
ALTER TABLE faturamento REPLICA IDENTITY FULL;
ALTER TABLE movimento REPLICA IDENTITY FULL;
Alternativa melhor em médio prazo: criar uma chave primária (ou índice único) e deixar o padrão REPLICA IDENTITY DEFAULT, que usa esse índice — menos WAL que FULL.
Documentação PostgreSQL: Replica Identity.
Comandos Principais¶
Iniciar os containers¶
Iniciar todos os serviços em background (detached mode):
docker compose up -d
Iniciar e ver os logs em tempo real:
docker compose up
Parar os containers¶
Parar todos os serviços:
docker compose down
Parar e remover volumes (⚠️ CUIDADO: Remove dados do MongoDB):
docker compose down -v
Ver os logs¶
Ver logs de todos os serviços:
docker compose logs
Ver logs em tempo real (follow):
docker compose logs -f
Ver logs de um serviço específico:
docker compose logs powersync
docker compose logs mongo
Ver últimas N linhas de log:
docker compose logs --tail=100
Ver logs em tempo real do PowerSync:
docker compose logs -f powersync
Status dos containers¶
Ver status de todos os containers:
docker compose ps
Ver todos os containers (incluindo parados):
docker compose ps -a
Reiniciar serviços¶
Reiniciar todos os serviços:
docker compose restart
Reiniciar só o PowerSync (após mudar config.yaml):
docker compose restart powersync
Recriar containers¶
Recriar containers (útil após mudanças no docker-compose.yaml):
docker compose up -d --force-recreate
Atualizar imagens¶
Atualizar as imagens Docker e recriar containers:
docker compose pull
docker compose up -d
Comandos Úteis¶
Ver uso de recursos¶
docker stats
Entrar em um container¶
docker compose exec powersync sh
docker compose exec mongo mongosh
Limpar logs antigos¶
# Limpar logs de todos os containers
docker compose logs --tail=0
Verificar saúde dos serviços¶
# Verificar se os serviços estão rodando
docker compose ps
# Verificar logs de erro
docker compose logs | grep -i error
Serviços em cada pasta de cliente¶
Em cada powersync/<cliente>/docker-compose.yaml:
- powersync — serviço PowerSync (porta interna 8080).
- mongo — MongoDB (replica set; porta 27017 só na rede Compose).
- mongo-rs-init — job one-shot que faz
rs.initiateno Mongo.
Portas e HTTPS¶
- Dentro do container PowerSync: 8080 (também em
config.yaml:port: 8080). - Host: o Compose publica
8080:8080. Um projeto Coolify por cliente costuma expor 8080 ao proxy; subdomínios sugeridos:ps.sulplata.xadm.biz,ps.vantroba.xadm.biz, etc. — ver Fase 5.
Os config.yaml em powersync/<cliente>/config.yaml versionam placeholders na URI Postgres; no servidor, preencher com credenciais reais (sem commitar segredos). Alinhar host/porta/banco com o Postgres de cada cliente.
JWT / JWKS: o integrador não expõe JWKS para o PowerSync. O modelo no repo usa https://auth.xadm.biz/.well-known/jwks.json — verificar o path real no serviço de auth. Contexto: docs/dev/pabast-removal-phase1.md.
Troubleshooting¶
PostgreSQL: does not have a replica identity and publishes deletes¶
Tabelas na publicação lógica sem PK precisam de REPLICA IDENTITY — ver a seção «6. Replica identity» acima. Rode os ALTER TABLE ... REPLICA IDENTITY FULL (ou adicione PK) no mesmo database onde está a tabela.
Container não inicia¶
# Ver logs detalhados
docker compose logs [nome-do-servico]
# Verificar configuração
docker compose config
Container reinicia constantemente¶
# Ver logs para identificar o erro
docker compose logs -f [nome-do-servico]
# Verificar status
docker compose ps
Limpar tudo e recomeçar¶
⚠️ ATENÇÃO: Isso remove todos os dados do MongoDB!
docker compose down -v
docker compose up -d
Fase 5 — PowerSync: compose, volumes e operação segura¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06
Objetivo: documentar o stack PowerSync self-hosted (Docker Compose no servidor) de forma operacional: o que cada volume guarda, como reiniciar sem perder dados, e por que docker compose down -v é um anti-padrão em produção.
Este passo é independente da Fase 4 — Coolify / integrador: o integrador (Micronaut) deploya por subdomínio e porta 8080; o PowerSync é um projeto Compose por cliente (esta pasta no Git = um deploy), tipicamente exposto em HTTPS como ps.<cliente>.xadm.biz (ou sufixo DNS equivalente), com MongoDB dedicado ao bucket daquele cliente.
Modelo: um docker-compose por cliente¶
Cada cliente tem a sua pasta com dois ficheiros versionáveis em conjunto:
| Pasta no repo | Conteúdo |
|---|---|
powersync/sulplata/ |
docker-compose.yaml + config.yaml |
powersync/onpetrotrading/ |
idem |
powersync/vantroba/ |
idem |
powersync/onpetro/ |
idem |
No Compose, o serviço PowerSync chama-se sempre powersync e o Mongo mongo (hostname interno estável). O config.yaml usa mongodb://mongo:27017/....
Fluxo Git: alterar config.yaml (sync rules, etc.) ou o próprio Compose → commit → push → deploy no servidor (pull ou cópia da pasta) → docker compose up -d ou docker compose restart powersync dentro dessa pasta.
Domínio HTTPS (proxy)¶
O contrato público é o host, não a porta no edge:
Cliente (prod/) |
Subdomínio PowerSync sugerido |
|---|---|
sulplata |
ps.sulplata.xadm.biz |
onpetrotrading |
ps.onpetrotrading.xadm.biz |
vantroba |
ps.vantroba.xadm.biz |
onpetro |
ps.onpetro.xadm.biz |
O proxy (Traefik / Caddy / Coolify) termina TLS e encaminha para o container PowerSync na porta interna 8080. O docker-compose.yaml publica 8080:8080. Se várias stacks partilharem o mesmo host Docker e precisarem de portas TCP distintas no host, ajusta manualmente o mapeamento no Compose desse cliente (ou usa um override Compose local não versionado).
Credenciais no config.yaml¶
No repositório, a URI Postgres usa placeholders (REPLACE_USER, REPLACE_PASSWORD, REPLACE_HOST, REPLACE_PORT, REPLACE_DATABASE). No servidor, preenche com valores reais (ou gere o ficheiro por secret store) e não commits com segredos. O JWKS em modelo aponta para https://auth.xadm.biz/.well-known/jwks.json — confirma o path com o teu serviço de auth.
Volumes nomeados (MongoDB) — o que não se apaga à toa¶
Em cada pasta de cliente, o Compose declara um volume mongo_storage (nome lógico; no Docker fica prefixado com o nome do projeto Compose, por pasta).
Apagar esse volume (down -v) remove o estado do bucket PowerSync naquele cliente → possível resync completo e impacto em apps/Postgres.
Bind mount (config.yaml)¶
- Montagem:
./config.yaml:/config/config.yaml:ro(relativo à pasta do cliente). - Alterar sync rules: editar
config.yamlno repo, deploy, depoisdocker compose restart powersyncnessa pasta (Mongo pode ficar de pé).
Restart seguro (recomendado)¶
- Só mudou
config.yaml:docker compose restart powersync(na pasta do cliente). - Mudou
docker-compose.yaml:docker compose up -douup -d --force-recreateconforme necessidade. - Imagens:
docker compose pull+docker compose up -d— sem-vsalvo intenção explícita. - Diagnóstico:
docker compose ps,docker compose logs -f powersync.
Guia rápido: docs/servidor/update-syncrules.md.
Anti-padrão: docker compose down -v¶
| Comando | Efeito típico |
|---|---|
docker compose down |
Para containers e rede; mantém volumes (dados Mongo persistem). |
docker compose down -v |
Remove volumes declarados → apaga dados do Mongo do PowerSync nessa stack. |
Em produção: evitar -v sem plano de resync/backup.
Auth (JWT / JWKS)¶
O integrador não expõe JWKS para o PowerSync. Ajustar client_auth.jwks_uri no config.yaml — ver pabast-removal-phase1.md.
Referências¶
- Comandos Docker e Postgres (publicação, replica identity): docs/servidor/powersync.md
- Índice: powersync/README.md
- Deploy integrador: migration-phase4-coolify.md
- Segurança da API e roadmap (Fases 6–9): migration-roadmap-phases-6-9.md
Atualizar sync rules do PowerSync¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06
Atualizar sync rules do PowerSync¶
Este guia explica como aplicar alterações ao config.yaml (sync rules e restantes opções) depois de um commit no repositório.
1. Modelo atual: uma pasta por cliente¶
O Compose está em powersync/<cliente>/ (ex.: powersync/sulplata/), com config.yaml na mesma pasta. No servidor, trabalha-se dentro dessa pasta (ou cópia equivalente).
cd /caminho/para/powersync/sulplata
(Ajusta o caminho: clone do mono-repo em /xadm/integrador, ou só a pasta desdobrada em /xadm/powersync-sulplata, etc.)
2. Atualizar ficheiros¶
git pull(ou copiar oconfig.yamlnovo para esta pasta).
3. Aplicar (só o PowerSync)¶
O config.yaml monta-se só no serviço powersync; o Mongo não precisa de reinício para mudanças de sync rules.
docker compose restart powersync
Se precisares de recriar o container:
docker compose up -d powersync
Evita docker compose down em todo o stack só por mudança de YAML — ver Fase 5 — runbook.
4. Verificar¶
docker compose ps
docker compose logs -f powersync
5. docker compose down -v¶
Não uses -v em produção sem ler o runbook — remove o volume do Mongo desse cliente. Ver migration-phase5-powersync-runbook.md.
Runbook de incidentes comuns (runtime)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-10
Primeiros passos para sintomas de produção (não build/deploy — isso está em
diagnóstico de builds flaky). Observabilidade: todo ERROR
vai ao Sentry/GlitchTip (SentryAppender no logback.xml) — o dashboard é o primeiro lugar a
olhar. Erros de API saem em RFC 7807 (application/problem+json); o code/detail do corpo
ajudam a identificar a causa.
Push FCM não chega ao app¶
Ordem de verificação:
- A instância está com a empresa certa? Cada instância roda com
push.empresa(sulplata|onpetrotrading) e envia nos tópicosbl_liberado_<empresa>enota_venda_emitida_<empresa>. O app precisa estar inscrito no tópico dessa empresa. - Push está ligado?
push.enabled=truee as credenciais Firebase presentes (PUSH_CREDENTIALS_PATH→ o secret file montado no container). Compush.enabled=truee a credencial ilegível a instância não sobe — o erro de startup nomeia o path (decisão 0030), então "push mudo com a instância no ar" nunca é credencial faltando. Compush.enabled=falseo serviço é no-op silencioso. - Testar sem esperar um evento real:
POST /debug/send-bl-liberado(tela/debug, login-gated) dispara um push de teste ao tópico da instância. Se o teste chega e o real não, o problema é no gatilho (o PUT XADM não chegou / origem errada), não no FCM. - Falha de envio? O
FcmPushServicereenvia erros transitórios do FCM (INTERNAL500,UNAVAILABLE503,QUOTA_EXCEEDED) com backoff exponencial + jitter (default 3 tentativas, base 1 s → ~1 s / 2 s / 4 s). Um 500 isolado é reabsorvido no retry e não vira alarme. O Sentry/GlitchTip só recebe a exceção quando: (a) esgotam as tentativas de um erro transitório, ou (b) o erro é permanente (UNREGISTERED,INVALID_ARGUMENT,SENDER_ID_MISMATCH,THIRD_PARTY_AUTH_ERROR) — que falha de imediato, sem retry. Tags no evento:push.topic/push.tipo/push.error_code/push.attemptse os extraspush.title/push.body.push.attempts=3(o default) num evento transitório = outage do FCM, não blip. Ajustável viapush.retry.max-attempts/push.retry.base-delay-ms.
Detalhe do payload e do gatilho: push FCM · testar push.
PowerSync: app não recebe atualizações (dessincronizado)¶
O runbook dedicado cobre reset e operação: Runbook PowerSync. Checklist rápido antes:
- O serviço PowerSync está no ar? Ver PowerSync via Docker.
- As sync rules estão atualizadas para o schema atual? Ver atualizar sync rules.
- O
last-changeavança? O endpoint/api/v1/powersync/lastchangereflete a última mudança aplicada; se ele avança mas o app não recebe, o problema é do lado PowerSync/cliente. - Caso extremo (cliente com estado corrompido): o reset está no Runbook PowerSync.
Erros 500 / exceções em produção¶
- Sentry/GlitchTip primeiro — todo
ERRORé capturado; o stack trace e o contexto estão lá. - A resposta HTTP de erro sai em RFC 7807 (
application/problem+json): ocodee odetailidentificam o tipo (validação, rota inexistente,HttpStatusException). - Ingestão XADM (
PUT/DELETE /api/v1/xadm) falhando: o corpoXadmResponsetraz o resultado por entidade; um 400 é erro de cliente (JSON malformado / dado inválido) — o agente on-premises deve parar e notificar, não re-tentar.
Heartbeat do integrador-client¶
O integrador-client manda o heartbeat para POST /api/v1/heartbeat e esta instância o repassa ao
central-backend (contrato). Os dois ERRORs abaixo são
misconfiguração: não se curam sozinhos e repetem a cada heartbeat (até 1 por hora), agrupados numa
issue do GlitchTip.
ERROR heartbeat desligado: <ENV> vazio neste integrador-server (resposta 503
heartbeat_desligado). A env nomeada no log (CENTRAL_API_TOKEN, CLIENTE_ID ou as duas) está vazia
no recurso Coolify. Desde a decisão 0029, o
CLIENTE_ID vazio só derruba o heartbeat quando o COOLIFY_FQDN também não serve (fora do
formato int.<slug>.xadm.biz) — a linha cliente_id do heartbeat: no boot diz qual fonte valeu.
Cadastrar e fazer redeploy — checklist em
implantação. Se esta instância não deveria ter
integrador-client, quem está mandando heartbeat é um jar instalado por engano: desligar lá
(heartbeat.intervaloMinutos=0).
ERROR heartbeat recusado pelo central-backend: HTTP <status> [<code>] (resposta 502
heartbeat_recusado). O central devolveu 4xx. Pelo code:
| No log | Causa | Correção |
|---|---|---|
HTTP 422 cliente_desconhecido |
CLIENTE_ID não existe em clientes no central (é o cliente_id, ex. maxsul — não o CLIENTE) |
corrigir o CLIENTE_ID |
HTTP 401 invalid_token / missing_token |
CENTRAL_API_TOKEN diferente do cadastrado no central |
alinhar o valor com os recursos do central |
HTTP 400 BAD_REQUEST |
o client mandou dado que passou aqui e o central recusou (ex. iniciado_em malformado — só o central o valida) |
ver a versão do client instalada |
HTTP 404 sem code |
CENTRAL_URL aponta para o lugar errado (o proxy responde HTML) |
corrigir ou apagar o CENTRAL_URL (o default é o central de produção) |
WARN heartbeat não repassado: central-backend … (resposta 502 central_indisponivel). Central
fora, lento ou rede: transitório, fica em WARN de propósito e não abre issue. O próximo heartbeat tenta
de novo. Se durar horas, o próprio central acusa silêncio da instalação depois de 6h úteis — o runbook
do lado de lá é o
heartbeat do integrador-client no central.
Build/deploy falha (não é runtime)¶
Ver diagnóstico de builds flaky — o que a suíte instrumenta e como ler
a falha do job gate.
HTTPS com Nginx + Certbot (Ubuntu)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06
Este guia configura HTTPS para dois apps Micronaut no mesmo servidor Ubuntu, usando Nginx como proxy reverso e Let's Encrypt (Certbot).
| Aplicação | Domínio | Porta (backend) |
|---|---|---|
| Sul Plata | sulplata.integracao.xadm.com.br | 3001 |
| Onpetro Trading | onpetrotrading.integracao.xadm.com.br | 3002 |
1. DNS (Zona do domínio)¶
No painel onde está a DNS Zone de xadm.com.br (ou onde integracao.xadm.com.br é gerenciado), crie os registros antes de instalar certificados, para o Certbot conseguir validar o domínio.
Registros necessários¶
Apontar ambos os subdomínios para o IP do servidor Ubuntu onde Nginx e os apps rodam.
| Tipo | Nome / Host | Valor / Conteúdo | TTL (opcional) |
|---|---|---|---|
| A | sulplata.integracao |
IP_DO_SERVIDOR |
300 ou padrão |
| A | onpetrotrading.integracao |
IP_DO_SERVIDOR |
300 ou padrão |
- Nome exato depende do provedor:
- Se a zona for xadm.com.br: use
sulplata.integracaoeonpetrotrading.integracao - Valor: mesmo IP para os dois (o servidor Ubuntu).
- Aguarde a propagação (alguns minutos; em alguns casos até 48h). Teste com:
ping sulplata.integracao.xadm.com.br ping onpetrotrading.integracao.xadm.com.br
2. Servidor Ubuntu: instalar Nginx e Certbot¶
sudo apt update
sudo apt install -y nginx certbot python3-certbot-nginx
sudo systemctl enable nginx
sudo systemctl start nginx
3. Configurar Nginx (proxy para as duas aplicações)¶
Crie um arquivo de configuração, por exemplo /etc/nginx/sites-available/integrador:
# Sul Plata - porta 3001
server {
listen 80;
server_name sulplata.integracao.xadm.com.br;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Onpetro Trading - porta 3002
server {
listen 80;
server_name onpetrotrading.integracao.xadm.com.br;
location / {
proxy_pass http://127.0.0.1:3002;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Ativar o site e testar:
sudo ln -sf /etc/nginx/sites-available/integrador /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Confirme que as duas aplicações Micronaut estão rodando nas portas 3001 (Sul Plata) e 3002 (Onpetro Trading) e que o firewall libera as portas 80 e 443 (e, se necessário, 3001/3002 só em localhost).
4. Obter certificados SSL (Certbot)¶
Com o DNS já apontando para o servidor e o Nginx respondendo em HTTP na porta 80:
sudo certbot --nginx -d sulplata.integracao.xadm.com.br -d onpetrotrading.integracao.xadm.com.br
- O Certbot pergunta e-mail para avisos de renovação e termos de uso.
- Ele altera automaticamente os blocos
serverno Nginx (adicionalisten 443 ssl, caminhos do certificado e redirect HTTP → HTTPS).
Ou certificados separados por domínio:
sudo certbot --nginx -d sulplata.integracao.xadm.com.br
sudo certbot --nginx -d onpetrotrading.integracao.xadm.com.br
Após isso, recarregue o Nginx se o Certbot não fizer isso sozinho:
sudo nginx -t
sudo systemctl reload nginx
5. Renovação automática¶
O Certbot agenda um cron/systemd timer para renovar os certificados. Verifique:
sudo systemctl status certbot.timer
sudo certbot renew --dry-run
6. Aplicações Micronaut¶
- As duas apps continuam em HTTP nas portas 3001 e 3002.
- Não é necessário configurar SSL no
application.yml; o Nginx faz a terminação TLS. - Os headers
X-Forwarded-ProtoeHostjá são enviados pelo proxy; o Micronaut pode usá-los se precisar gerar URLs HTTPS (ex.: links, redirects).
Atualize as URLs do OpenAPI/Swagger em cada app para os domínios em produção, por exemplo:
- Sul Plata: https://sulplata.integracao.xadm.com.br
- Onpetro Trading: https://onpetrotrading.integracao.xadm.com.br
Resumo¶
| Etapa | Ação |
|---|---|
| DNS | Criar registros A para sulplata.integracao e onpetrotrading.integracao com o IP do servidor |
| Servidor | Instalar Nginx e Certbot |
| Nginx | Configurar dois server (porta 80) com proxy_pass para 3001 e 3002 |
| Certbot | Rodar certbot --nginx -d ... para os dois domínios |
| Apps | Manter Sul Plata na 3001 e Onpetro Trading na 3002 em HTTP |
URLs finais: - https://sulplata.integracao.xadm.com.br → app Sul Plata (3001) - https://onpetrotrading.integracao.xadm.com.br → app Onpetro Trading (3002)
Dev
Guia do código¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15
Audiência: dev que abriu o repositório e quer entender como o código está organizado antes de ler classe a classe. O desenho do sistema está na Documentação Completa; o porquê da organização, nas decisões 0010, 0011 e 0012; rodar e testar, em Como rodar.
O retrato em uma frase¶
O ERP X-Adm do cliente exporta cada mudança como JSON, o agente on-premises a envia à API de ingestão
(/api/v1/xadm), o Integrador grava no Postgres da instância e o PowerSync replica o banco para o app
móvel. Em volta desse fio ficam a API CRUD /api/v1/*, as telas JTE de consulta, o push FCM das notas
e as mensagens de saída pelo outbox (watch de MDF-e para o int-sascar, poke para o tradutor da Thoms).
flowchart TB
ERP["ERP X-Adm"] -->|JSON| Agente["agente on-premises"]
Agente -->|"/api/v1/xadm"| Ingest["ingest — parser e handlers por tabela"]
Ingest --> Banco[("Postgres da instância")]
Banco --> PowerSync["PowerSync"] --> App["app móvel"]
Ingest -->|NotaAplicadaEvent| Push["push — FCM"]
Ingest -->|outbox| Webhook["webhook — watch e poke"]
Api["api — CRUD /api/v1/*"] --> Banco
Views["views — telas JTE"] --> Banco
Como o código é organizado¶
Organização package-by-feature sob br.com.xadm, sobre uma fundação de dados (core) e um kernel
(comum). O mapa abaixo é o de mais alto nível; o papel de cada pacote em detalhe, as camadas e a
escrita por chave natural estão em Estrutura de código.
| Pacote | É | Responsabilidade |
|---|---|---|
core |
base | entidades, repositórios, enums e eventos de domínio; não depende de nada do app |
comum |
base | config, regras de acesso de /api/** e das telas, erros RFC 7807, observabilidade, /health; depende só de core |
ingest |
feature-motor | ingestão do payload XADM, o manifesto de remessa e a leitura do retorno do X-Adm |
powersync |
feature-motor | rotas do PowerSync: write-back, reset e lastchange |
push |
feature-motor | push FCM disparado pela nota aplicada |
export |
feature-motor | exportação de tabela para XLS |
heartbeat |
feature-motor | repasse do heartbeat do integrador-client ao central-backend |
sascar |
feature | callback de encerramento de MDF-e vindo do int-sascar |
webhook |
feature | mensagens de saída pelo outbox da xadm-mensageria: watch de MDF-e ao int-sascar e poke ao tradutor da Thoms |
api |
edge | controllers CRUD por entidade em /api/v1/* |
views |
edge | telas JTE de consulta, a home e a tela de debug |
A infra idêntica entre apps vem das libs da casa: login e sessão da xadm-seguranca, /health e
Sentry da xadm-comum-web, outbox e relay M2M da xadm-mensageria.
As camadas¶
A ingestão entra pelo XadmController; o XadmService serializa as aplicações sob um lock e descarta
payload vazio; o XadmApplyService percorre o XadmTableRegistry e aplica cada XadmTableHandler na
ordem que respeita as chaves estrangeiras. A escrita por chave natural (upsert com revive de soft-delete)
tem um ponto único, o EscritaPorChaveNatural, que os controllers CRUD de api também usam.
Nas edges, o controller só traduz HTTP: api responde JSON e views monta o modelo e renderiza o JTE
dentro do kit/layout.jte (UI das telas). O acesso é decidido por duas regras de
comum: o ApiBearerSecurityRule exige o Bearer em /api/** e o ViewSecurityRule exige login nas
telas (Segurança).
ingest não conhece push: a nota aplicada publica um NotaAplicadaEvent (em core) e o listener de
push reage (decisão 0011).
Travas de arquitetura¶
O ArchitectureTest roda no ./gradlew check e guarda as fronteiras descritas em
Estrutura de código: core puro, comum dependendo só de core,
features-motor independentes entre si e nenhum ciclo entre pacotes. sascar e webhook não constam
das listas de pacotes do teste.
Por onde começar a ler¶
Application— inicializa o Sentry antes do Micronaut e declara o OpenAPI com o Bearer.ingest.XadmController→ingest.XadmService→ingest.XadmApplyService— o fio da ingestão.ingest.XadmTableRegistrye dois handlers: um simples (ingest.MunicipioHandler) e um com regra própria (ingest.ContratosHandler).comum.ApiBearerSecurityRuleecomum.ViewSecurityRule— quem entra em/api/**e nas telas.src/main/resources/db/migration/— o schema, migration a migration; o modelo consolidado está em Modelagem de dados.
Como rodar¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15
Audiência: dev que clonou o repo e quer subir o app, rodar o gate e gerar as imagens na própria
máquina. Como o código se organiza está no Guia do código; as variáveis de
ambiente, em Configuração; o que muda entre dev, test e produção, em
Perfis Micronaut.
Pré-requisitos¶
- JDK 25 no
JAVA_HOME: é a versão dotoolchain.javadodocs/app.json, a mesma do CI. - Docker rodando: o
runsobe o Postgres de dev, e os testes sobem o Postgres de teste via Testcontainers. - Build sempre pelo wrapper (
./gradlew; no Windows,.\gradlew.bat).
Subir o app¶
./gradlew run
O run ativa o perfil dev, a menos que MICRONAUT_ENVIRONMENTS já venha no ambiente, e depende da
task subirInfraDev, que sobe o Postgres 18 do docker-compose.dev.yml na porta 5432 e espera o banco
ficar saudável. O app responde em http://localhost:8080.
No perfil dev o Flyway recria o schema a cada boot, o Bearer de /api/** fica desligado, as telas
abrem sem login quando as envs de autenticação não estão definidas e o Sentry não inicializa sem
SENTRY_DSN.
Quem já tem um Postgres próprio exporta DATASOURCES_DEFAULT_URL (e DATASOURCES_DEFAULT_USERNAME/
DATASOURCES_DEFAULT_PASSWORD): a subida do Docker é pulada. Para parar o banco de dev:
docker compose -f docker-compose.dev.yml down.
Gate¶
./gradlew check # o mesmo comando do job gate do pipeline.yml
./gradlew coverage # testes e o percentual do JaCoCo no terminal
./gradlew test --tests "*XadmTableRegistryTest" # um teste só
O check compila, roda a análise estática, a suíte inteira (unit, integração com @MicronautTest e o
ArchitectureTest) e o piso de cobertura do JaCoCo. Os testes de integração rodam contra o Postgres de
teste descrito em Perfis Micronaut: basta o Docker no ar.
Jar e imagens¶
./gradlew shadowJar # build/libs/app.jar
docker build -t integrador-server:local . # imagem JVM
docker build -f Dockerfile.native -t integrador-server:native-local . # imagem native
O alvo de deploy é o do build.targets do docs/app.json. O caminho native (reachability metadata e
diagnóstico de boot) está em Native image; a implantação, em
Implantação — integrador no Coolify.
SQL de diagnóstico¶
scripts/sql/ guarda consultas de leitura para rodar à mão contra o banco de uma instância:
saldo_bl_por_lote.sql (saldo atual de cada BL) e diagnostico_bl_inconsistente.sql (BL com venda e
liberação dessincronizadas). Cada arquivo explica no cabeçalho o que devolve.
Site de docs¶
python -m mkdocs serve
Precisa do MkDocs Material e dos plugins listados no mkdocs.yml. O mkdocs build --strict reprova
página fora do nav: e link quebrado.
Configuração — application.yml, application-dev.yml e Coolify¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-10
Ficheiros no repositório (src/main/resources)¶
| Ficheiro | Função |
|---|---|
application.yml |
Produção (default do JAR). Tudo o que é comum e valores por omissão; dados sensíveis e por cliente vêm de variáveis de ambiente no Coolify. |
application-dev.yml |
Só com MICRONAUT_ENVIRONMENTS=dev — base local (Postgres em localhost:5432, Flyway com clean-schema, Bearer em /api desligado, Sentry não inicializa sem SENTRY_DSN). |
Não existe application-prod.yml: a produção é o próprio application.yml + ENV.
Ambiente Micronaut¶
| Execução | Variável / nota |
|---|---|
| Produção (Coolify) | Não é obrigatório definir MICRONAUT_ENVIRONMENTS. O JAR usa só application.yml + ENV. (Legado: MICRONAUT_ENVIRONMENTS=prod ainda pode existir; o código não depende dele para Sentry nem para bloquear a UI.) |
| Desenvolvimento local | MICRONAUT_ENVIRONMENTS=dev → carrega application-dev.yml sobre application.yml. |
| Testes | src/test/resources/application-test.yml (perfil test). |
Comportamento da aplicação (resumo)¶
- Sentry (
SentryInitializer): inicializa só se existir DSN válido (SENTRY_DSN/sentry.dsn) e o ambiente não fordevnemtest. Em dev local normalmente não há DSN → não envia eventos. - UI (link Debug, botões de edição na web): permitidos só com perfil
devoutest. Deploy só comapplication.yml(semdev) → interface somente leitura para alterações CRUD.
Coolify — variáveis de ambiente recomendadas¶
Segredos via secrets do Coolify; nomes seguem o mapeamento Micronaut (PROPERTY → ENV em MAIÚSCULAS com _).
Obrigatórias para produção útil¶
| Variável | Descrição |
|---|---|
CLIENTE |
Nome de exibição do cliente, como está no Coolify (ex.: Maxsul, Sul Plata Trading do Brasil, Vantroba). Não é identificador de máquina: onde o sistema precisa do slug (cliente_id do central, slug da Sascar) há env própria (CLIENTE_ID, SASCAR_SLUG) |
DATASOURCES_DEFAULT_URL |
JDBC PostgreSQL |
DATASOURCES_DEFAULT_USERNAME |
Utilizador da BD |
DATASOURCES_DEFAULT_PASSWORD |
Senha da BD |
INTEGRADOR_API_TOKEN |
Segredo fixo para Authorization: Bearer em /api/** — mesmo valor que o adapter CSV do tradutor manda no PUT/DELETE /api/v1/xadm. Sem fallback para o nome antigo API_BEARER_TOKEN, que nada lê: setar só ele deixa app.api-token vazio — fora de dev/test o BearerTokenGuard (xadm-seguranca ≥ 0.7.2) recusa o boot; em dev/test o validador fica dormente e /api/** responde 401 a tudo (nunca fica aberto) |
Recomendadas / opcionais¶
| Variável | Descrição |
|---|---|
MICRONAUT_ENVIRONMENTS |
Opcional em produção. Use dev só para desenvolvimento local. |
PORT |
Porta HTTP no container (muitos hosts definem automaticamente; default no YAML: 8080) |
PUSH_ENABLED |
true / false — notificações FCM. Com true, a credencial é conferida no boot e a instância não sobe se PUSH_CREDENTIALS_PATH não abrir (.ia/017) |
PUSH_EMPRESA |
sulplata | onpetrotrading (tópicos) |
PUSH_CREDENTIALS_PATH |
Caminho absoluto ao JSON Firebase no container (secret montado). A chave não viaja mais no jar: é segredo, e segredo não entra no build (.ia/017). Com o push ligado, path errado derruba o boot — deliberado: melhor falhar o deploy do que rodar meses sem push |
PUSH_NOTIFICATION_IMAGE_URL |
URL da imagem nas notificações (pode ficar vazio) |
PUSH_RETRY_MAX_ATTEMPTS |
Tentativas de envio em erro FCM transitório (INTERNAL/UNAVAILABLE/QUOTA_EXCEEDED). Default 3; 1 desliga o retry. Erro permanente nunca retenta |
PUSH_RETRY_BASE_DELAY_MS |
Base do backoff exponencial + jitter entre tentativas (ms). Default 1000 → ~1 s / 2 s / 4 s. 0 = sem espera. Só reenvio transitório esgotado alarma no Sentry — ver incidentes-comuns |
SENTRY_DSN |
Necessário para o Sentry em produção (não há DSN default no YAML por segurança). Mesmo valor em todos os clientes: projeto GlitchTip único (integrador) — ver dev/observabilidade.md |
SENTRY_ENVIRONMENT |
Ex.: production (default no YAML quando não definido) |
Webhook do tradutor WebStorm-ecom — só o deploy Thoms¶
Liga o poke de saída: quando o estoque muda, o integrador avisa o tradutor da Thoms para ele
varrer na hora (feature 008; ver decisão 0013). Off por
default — só o deploy da Thoms define estas variáveis. Sem elas (ou ENABLED=false/URL vazia),
o listener é no-op (sulplata/onpetro ignoram). Falha do poke não derruba o processamento XADM;
o @Scheduled do tradutor é a rede de segurança.
| Variável | Descrição |
|---|---|
WEBSTORM_WEBHOOK_ENABLED |
true só no deploy Thoms; false/ausente nos demais. É flag (não destino) → não muda de nome |
WEBSTORM_URL |
URL base do tradutor (ex.: https://webstorm.thoms.xadm.biz) — o cliente faz POST {url}/api/integrador/produto, corpo vazio. Fallback: WEBSTORM_WEBHOOK_URL (nome antigo) |
WEBSTORM_API_TOKEN |
Bearer app→app enviado no header Authorization — segredo compartilhado com o tradutor (secret do Coolify), tem que valer o WEBSTORM_API_TOKEN do tradutor. Fallback: WEBSTORM_WEBHOOK_TOKEN (nome antigo) |
Heartbeat do integrador-client — só onde há integrador-client instalado¶
O integrador-client do cliente manda o heartbeat para POST /api/v1/heartbeat desta instância, que
carimba o cliente_id e repassa ao central-backend (contrato). Sem
flag de liga/desliga: com CENTRAL_API_TOKEN vazio, ou sem nenhuma fonte de cliente_id, a rota
responde 503 heartbeat_desligado e loga ERROR a cada heartbeat — por isso elas se cadastram junto
com a instalação do client, e ficam vazias nas instâncias sem client (lá nenhum heartbeat chega).
O cliente_id tem duas fontes, nesta ordem: CLIENTE_ID explícito e, faltando ele, o 1º label
do COOLIFY_FQDN quando o fqdn casa int.<slug>.xadm.biz. Assim uma instância nova nasce
funcionando sem ninguém lembrar da env. O fqdn da perna native (int-native.<cli>.xadm.biz) não
casa de propósito: lá o CLIENTE_ID explícito é copiado da perna jar e vence. Fqdn em qualquer outro
formato deixa o cliente_id vazio e o heartbeat em 503 — melhor desligado do que carimbar o central com
o cliente errado. A resolução mora no HeartbeatController (default aninhado em ${...} não funciona no
Micronaut) e o boot loga, uma vez, de onde o cliente_id saiu.
| Variável | Property | Descrição |
|---|---|---|
CENTRAL_URL |
central.url |
URL base do central-backend. Default https://central-backend.xadm.biz; só muda em teste |
CENTRAL_API_TOKEN |
central.api-token |
Segredo. Bearer para o central — o mesmo valor em todas as instâncias e nos recursos do central (nome pelo destino, <DESTINO>_API_TOKEN) |
CLIENTE_ID |
central.cliente-id |
O cliente_id deste cliente na tabela clientes do central (ex.: maxsul). Faltando, cai no 1º label do COOLIFY_FQDN. Nunca cai no CLIENTE: o nome de exibição não passa na regra do central (^[a-z0-9-]{1,50}$) |
COOLIFY_FQDN |
central.fqdn |
Injetada pelo Coolify. Só é lida como fallback do cliente_id, e só no formato int.<slug>.xadm.biz |
Sentry em produção¶
- Definir
SENTRY_DSNno Coolify (HTTPS) — o DSN público está emdocs/app.json(features.glitchtip.dsn); é o mesmo para todo cliente. - Garantir
CLIENTEsetado: vira a tagclientede todo evento (é como se separa o tenant no projeto único). - Não usar perfil
devno container de produção.
Ficheiros em prod/<cliente>/app/¶
Os exemplos prod/*/app/application.yaml no repositório são referência histórica; a configuração ativa em servidor deve migrar para ENV no Coolify conforme a tabela acima. Não é obrigatório copiar application.yaml para a raiz do deploy se tudo estiver nas variáveis.
Resumo visual¶
Coolify (produção)
application.yml (default) + ENV (CLIENTE, BD, INTEGRADOR_API_TOKEN, SENTRY_DSN, …)
↓
java -jar app.jar → application.yml (defaults + ${ENV:...})
Desenvolvimento
MICRONAUT_ENVIRONMENTS=dev ./gradlew run
↓
application.yml + application-dev.yml
Referências¶
- Deploy e subdomínios: migration-phase4-coolify.md
- Perfis Micronaut (detalhe): micronaut-profiles.md
- API Bearer: migration-phase6-seguranca.md
Configuração de perfis — Micronaut¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15
O integrador distingue três situações práticas:
| Situação | Perfis ativos (típico) | YAML carregado |
|---|---|---|
Deploy padrão (Coolify, só application.yml) |
nenhum nome dev/test (lista vazia ou outros nomes legados) |
application.yml + ENV |
| Desenvolvimento local | dev |
application.yml + application-dev.yml |
| Testes JUnit | test |
application.yml + application-test.yml |
Nota: MICRONAUT_ENVIRONMENTS=prod não é necessário para produção. Se ainda existir em algum deploy legado, o comportamento continua “produção” desde que dev e test não estejam ativos.
Perfil de desenvolvimento (dev)¶
Características¶
- Conecta ao PostgreSQL do Docker (
localhost:5432/integrador_dev) - Permite limpar e recriar o banco do zero (
clean-schema: truenoapplication-dev.yml) - Bearer em
/apidesligado por omissão - Sentry não inicializa sem
SENTRY_DSN(em geral não configurado em local)
Como usar¶
Variável de ambiente
export MICRONAUT_ENVIRONMENTS=dev
./gradlew run
Argumento JVM
./gradlew run --args="-Dmicronaut.environments=dev"
IDE: VM options: -Dmicronaut.environments=dev
Limpar e recriar o banco¶
Em dev, o Flyway usa clean-schema: true: a cada arranque limpa o schema e reaplica migrações.
Importante: isso só ocorre com perfil dev. O application.yml de produção mantém clean-schema: false.
Produção (default do JAR)¶
O ficheiro application.yml define os defaults de produção (Flyway sem clean-schema, pool, etc.). Não existe application-prod.yml; dados por cliente vêm de variáveis de ambiente (Coolify).
Características¶
- BD e segredos via ENV (
DATASOURCES_DEFAULT_*,INTEGRADOR_API_TOKEN,CLIENTE,SENTRY_DSN, …) - Nunca limpa o banco automaticamente
- UI web (edição,
/debug): só liberada comdevoutest— em Coolify semdev, a interface é somente leitura para alterações mutáveis
Sentry em produção¶
Defina SENTRY_DSN no Coolify. O código não usa mais o perfil prod para decidir o Sentry; usa DSN válido + ausência de dev/test.
Lista completa de variáveis: application-config.md.
Verificar perfil ativo¶
Nos logs de arranque, o Micronaut mostra por exemplo:
Active environment(s): [dev]
ou, em deploy só com defaults:
Active environment(s): []
(ou outros nomes, desde que não seja dev/test para efeitos de UI/Sentry.)
Estrutura de ficheiros¶
src/main/resources/
├── application.yml # Produção (default do JAR) + placeholders ${ENV:...}
└── application-dev.yml # Apenas com MICRONAUT_ENVIRONMENTS=dev
Testes¶
# Linux/macOS
./gradlew coverage
# Windows
.\gradlew.bat coverage
Postgres de teste: os testes @MicronautTest rodam contra o singleton da xadm-comum-teste — um
postgres:18-alpine por JVM, subido pelo Testcontainers, com as migrations reais aplicadas pelo Flyway na
primeira classe. Basta o Docker no ar; sem ele, cada classe @MicronautTest falha com uma
IllegalStateException que diz que o Postgres de teste não subiu. Não é preciso instalar Postgres nem
apontar URL.
- A classe de teste estende
IntegracaoComPostgres, que injeta a URL do datasource e zera as tabelas antes de cada teste, menos o histórico do Flyway (flyway_schema_history, o nome default, como em produção). O teste semeia o que precisa no@BeforeEach, que roda depois da limpeza. - Classe que injeta propriedades próprias sobrescreve
getProperties()somando as suas aosuper.getProperties(). - O
application-test.ymlfixa o pool de teste (connection-timeout: 5000,maximum-pool-size: 4) e não declara URL. Os tetos de tempo da suíte estão em Diagnóstico de testes flaky. - Os testes de migration que aplicam o Flyway do zero (
*FlywayIntegrationTest,UpdatedAt*SchemaTest) e oTableExportServiceIntegrationTestsobem container próprio, fora do singleton.
Testes sem base (ex.: Mockito puros) podem correr isoladamente com --tests 'br.com.xadm.comum.AuthSupportTest'.
Dicas¶
- Desenvolvimento: use o perfil
devpara desenvolvimento local. - Produção: configure as variáveis de ambiente no Coolify;
SENTRY_DSNse quiser erros no Sentry. - Secrets: use secrets do Coolify ou um gestor dedicado.
- Migrações: teste sempre em staging antes de produção.
Estrutura de código — package-by-feature + core¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-10
O código é organizado por feature, sobre uma fundação de dados (core) e um kernel
(comum). O grafo de pacotes é um DAG (guarda ArchUnit no gate).
| Pacote | Papel |
|---|---|
core |
Fundação de dados: entidades (@MappedEntity), repositórios, enums (ETipo/EResultado) + conversores, ParsedXadmPayload, eventos de domínio (NotaAplicadaEvent). Não depende de nada da app. |
comum |
Kernel: config, policy de auth per-app (ViewSecurityRule, ApiBearerSecurityRule, whitelist do AuthSupport, StaticBearerTokenValidator local via @Replaces), exceção/RFC-7807, observabilidade, health (HealthController + ApiHealthController + auditoria ApiHealthRequestRecorder), e os serviços compartilhados por mais de uma feature (XadmResponse, PropriedadesNomeCurtoService). A infra transversal de auth (sessão JWT, Firebase, settings, SessionAuthenticationFetcher) vem da lib da casa xadm-seguranca (br.com.xadm.comum.seguranca, constituição, Engenharia) — não é mais recopiada aqui. Depende só de core. |
ingest |
Ingestão XADM (feature-motor): XadmController (/api/v1/xadm)/XadmService/XadmApplyService/parser + EscritaPorChaveNatural + RequestCleanupJob. |
powersync |
Controllers do PowerSync (reset, lastchange). |
push |
Push FCM: PushContextBuilder, PushNotificationService, FcmPushService, e o NotaAplicadaPushListener. |
export |
Exportação de tabelas (XLS). |
heartbeat |
Heartbeat do integrador-client (feature-motor): HeartbeatController (/api/v1/heartbeat) valida o corpo, carimba o CLIENTE_ID e repassa ao central-backend pelo CentralHeartbeatClient (cliente HTTP declarativo). Monta as próprias respostas de erro — ver erros. Não toca banco. |
api |
Camada edge (REST CRUD), ACIMA das features-motor: os 13 controllers CRUD por-entidade /api/v1/*. Pode depender das features (usa o EscritaPorChaveNatural de ingest) — não é feature-motor (decisão 0012). |
views |
Camada edge (apresentação server-render JTE, decisão 0025), ACIMA das features-motor: os *ViewController, IndexController, SwaggerController, DebugViewController. Pode orquestrar features (ex. o DebugViewController dispara push e reparo de coluna) — não é feature-motor. |
Camadas¶
core → comum → features-motor (ingest · powersync · push · export · heartbeat) → edges (api · views)
core é a fundação de dados; comum é o kernel; as features-motor constroem sobre os dois e
são independentes entre si; as camadas edge (api = REST CRUD, views = UI) ficam acima e
podem depender das features (decisão 0012).
Escrita por chave natural (upsert + soft-delete)¶
A regra de escrita das entidades de negócio — upsert com revive de soft-delete e soft-delete
por chave natural — tem um ponto único: o helper EscritaPorChaveNatural (ingest) sobre a
interface SoftDeletable (core, implementada pelas 22 entidades de escrita). Tanto o
XadmApplyService (aplica o payload XADM vindo do ERP) quanto os controllers CRUD /api/v1/*
delegam a ele — cada call-site só fornece o finder (busca por chave natural) e a cópia de
campos. Antes, esse esqueleto se repetia ~13× em cada lugar e divergia (ex.: a NotaCompl não
revivia no CRUD — corrigido ao unificar).
Exceção: Estoque, Propriedades e Veiculos mantêm lógica própria de nome-curto (campo
definido pelo mobile via PowerSync) nos seus controllers, fora do helper — de propósito, porque
essa parte não é o esqueleto genérico.
Registry de handler por tabela (ingest)¶
A aplicação do payload XADM (XadmApplyService) é dirigida por um registry de handlers, não por
código carimbado por tabela. Cada tabela do
espelho é um XadmTableHandler descoberto por DI; o XadmApplyService virou um driver puro
que só itera o XadmTableRegistry no PUT/DEL — não conhece as tabelas.
XadmTableHandler<E>— contrato de uma tabela:arrayKey(),listaDe(payload),ordemPut()/ordemDelete(),upsert/softDelete,describe(contexto do erro).AbstractXadmTableHandler<E>— base que delega upsert/soft-delete aoEscritaPorChaveNatural; o handler simples só declararepo(),buscar()(finder) ecopiaCampos(). Guarda de chave nula → sobrescrevechavePresente().- Regra própria (dual-key X-Adm × PIED:
propriedades,estoque,contratos,itensped; defaults/timestamps:mdf,notacompl,fones, grupo MDF-e) → o handler sobrescreveupsert/softDeleteinteiros, preservando o merge de domínio (mão única / write-back). - Ordem preservada:
ordemPut()eordemDelete()reproduzem as duas ordens FK-safe do driver antigo (a ordem do DEL não é o reverso do PUT — são ordens distintas). É o guardrail junto aoReentrantLockdoXadmService(anti-deadlock, incidente 2026-04-24). XadmTableRegistrytambém derivavazio(payload)(guarda no-op doXadmService) ebuildCounts(payload)(resumo de log) da lista de handlers — sem enumeração manual das 22 tabelas.
Tabela nova = 1 handler (mais o campo/parse no ParsedXadmPayload/parser, que guardam listas
tipadas): o driver, o isEmpty/buildCounts e a ordem não mudam. Coberto pelo teste de aceite
XadmTableRegistryTest (registra um handler fictício e prova que o driver o aplica sem editar o
XadmApplyService).
Fronteiras guardadas (teste de arquitetura no gate)¶
coreé puro — não depende decomumnem de features.comumdepende só decore— o kernel não conhece features.- features-motor são independentes —
ingest/powersync/push/export/heartbeatnão dependem umas das outras; o que é compartilhado por mais de uma sobe paracomum/core. (viewsfica de fora: é a camada de composição acima delas e pode orquestrá-las.) - sem ciclos entre pacotes.
O ciclo que a decomposição ingênua teria (ingest↔push, pois a nota aplicada dispara push) foi
quebrado por evento de domínio: o XadmService publica um NotaAplicadaEvent (em core) e o
NotaAplicadaPushListener (em push) reage — ingest não conhece push. Ver
push por evento e a decisão 0011.
Testes espelham as features¶
A árvore de testes (src/test/java/br/com/xadm) segue os mesmos pacotes do main — o teste de
uma classe vive no pacote da feature que ela exercita (core, comum, ingest, views,
powersync, push, export). Além desses, dois pacotes de teste:
arch— a guarda de arquitetura (ArchitectureTest), que roda no gate.support— infraestrutura de teste (helpers comoTestTransactionOperationse oCorsPreflightProbeController), não casos de teste.
Testes de integração cross-cutting ficam no pacote da feature que exercitam (ex. auth/CORS/health →
comum; rendering de tela → views) — não há um pacote integration por camada.
UI das telas — layout X-Adm e fragments do app¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15
As telas do integrador (Micronaut Views + JTE — templates compilados, decisão 0025) usam o layout padrão X-Adm, o kit mantido no repo central e comum a todo app da casa que renderiza HTML no servidor. Vale inclusive para as telas internas de admin/ops/dev: a consistência de marca e de UX do operador não abre exceção para tela interna. A norma está na constituição, Engenharia e a receita por stack em Java/Micronaut §UI.
O que é do central e o que é do app¶
| Arquivo | Dono | Regra |
|---|---|---|
src/main/jte/kit/layout.jte, src/main/jte/kit/pager.jte |
repo central | Não editar aqui. Cópia verbatim do template rastreado (o layout e o paginador das listagens); a /xadm-docs re-deriva quando o kit muda no central. |
public/css/custom-theme.css |
repo central | Idem. Carrega a paleta única da casa (royal #064490, accent #2a5b97, navy #042c5e, prata #939495) — a mesma do portal de docs. |
public/images/logo.png, public/images/favicon.ico |
repo central | Idem. |
src/main/jte/kit/navMenu.jte, src/main/jte/tag/*.jte |
este app | O que é só do integrador: o menu (navMenu.jte) e os tags reutilizáveis (exportExcelButton.jte, homeCard.jte). |
src/main/jte/**/list.jte, **/form.jte (as telas) |
este app | Cada tela escreve só o seu conteúdo e chama o kit/layout.jte passando-o. |
Precisa mudar a cor, o header ou a marca? A mudança é no repo central e chega aqui pela
/xadm-docs — editar o layout.jte ou o custom-theme.css neste repo faz o app divergir
da casa em silêncio e transforma todo re-pull futuro num merge à mão.
Como uma tela se monta¶
A composição JTE inverte a do Thymeleaf: em vez de a página puxar fragments do layout, a
página chama o layout e passa o próprio conteúdo (e, opcionalmente, o menu) como blocos
@`...`@:
@template.kit.layout(title = "Contratos - Integrador", appNome = appNome, ...,
navMenu = @`@template.kit.navMenu(showInternalMenus = showInternalMenus, ...)`,
content = @`
<div class="container mt-5">
... o conteúdo da tela ...
</div>
`)
O kit/layout.jte expõe os @param title, appNome, clienteXadm, appVersao, appModo,
authEnabled, currentUserEmail, currentUserName (vêm do model via GlobalViewModel) e dois
blocos Content: content (obrigatório — o corpo da tela) e navMenu (opcional). Sem navMenu
o header sai simples (só marca + sessão); com ele, o menu aparece. O bloco direito
(versão/modo/chip de sessão) é o kit/headerRight.jte, chamado uma vez pelo layout.
Todas as telas do integrador passam o navMenu do kit/navMenu.jte. Item de menu novo se
acrescenta lá, não no layout.
Menu por sessão¶
O menu é o mesmo em toda tela; o que muda é o que ele mostra. O GlobalViewModel
(um ViewModelProcessor) injeta em todo model:
appNome("Integrador") eclienteXadm(envCLIENTE) — o que o cabeçalho exibe;showInternalMenus— os dropdowns internos (Cadastros/Estoque/Pedidos/MDF-e/Sistema) só aparecem em dev/test ou, em produção com auth ativo, para usuário logado. Anônimo em produção vê só o Swagger e o botão Entrar;debugOperationsAllowed/crudWriteAllowed— gates de escrita e da entrada Debug, espelhando oUiProductionGuard(segurança);authEnabled,currentUserEmail,currentUserName— o chip de sessão (login Google).
A regra de visibilidade é exercitada por teste de rendering (pacote views), que confere
o HTML servido: logado vê os dropdowns, anônimo não.
Home: cards por seção com contagem¶
A home (index.jte) agrupa os cards nas mesmas seções do menu (Cadastros / Estoque /
Pedidos / MDF-e / Sistema) e cada card mostra, num badge, quantos registros ativos
(deleted = false) a tabela tem — dá pra ver de relance onde há dado e onde não há. O card
é a tag homeCard(href, titulo, desc, n) do kit (tag/homeCard.jte); o número vem de
HomeCountService, um contador JDBC genérico (SELECT count(*) … WHERE deleted = false,
ou bruto para request/push_enviada, que não têm soft-delete). Os nomes de tabela são
literais hardcoded no serviço — nunca vêm de request, então não há injeção. Tabela que
falhe a contagem (ex. migração não aplicada num ambiente) devolve null e o card mostra
— em vez de derrubar a página. Tabela nova na home ⇒ acrescente a linha em
HomeCountService.TABLES e o card no index.jte.
Listagens paginadas¶
Toda tela de listagem pagina no banco — nenhuma carrega a tabela inteira. O controller recebe o
Pageable que o Micronaut Data monta de ?page= (base 0) e ?size=, e o repositório devolve o
Page (ou Slice). O tamanho vem de micronaut.data.pageable no application.yml
(default-page-size: 50, max-page-size: 200); ?size= acima do teto é cortado pelo binder. O
?sort= da URL é descartado (pageable.withoutSort()): a ordem é a do método do repositório —
id desc nas tabelas do espelho (UUID: ordem estável, não cronológica), enviado_em desc no
/push-enviadas e id desc (identity, a ordem de chegada) no /request.
O controller põe no model a lista da página, na mesma chave de antes (ex. contratos), e o Page
em pagina; a tela chama o paginador do kit, @template.kit.pager(pagina = pagina, baseUrl = "/contratos",
filtros = showDeleted ? "&showDeleted=true" : ""): anterior/próxima e "Página X de Y · N registro(s)".
O filtros é a query já codificada que a navegação preserva — aqui, o showDeleted; tela sem filtro
omite o parâmetro. O /request usa Slice — o log de ingest não tem teto, e o count(*)
varreria a tabela a cada página —, então o paginador mostra só "Página X" e anterior/mais.
O export XLSX (/export/xlsx/{tabela}, TableExportService) não pagina: exporta a tabela toda.
A paginação é exercitada por render com Postgres real (ListagemPaginadaRenderTest), inclusive
toda listagem de tela com um ?sort= de propriedade inexistente, que tem de responder 200.
Espelho MDF-e: telas somente-leitura¶
As 7 tabelas do espelho MDF-e (mdf, mdfcompl, mdfitens, nfeevento, nfmdf,
nfcomplmdf, fretes — baixa por macro Sascar, .ia/009) têm cada uma um
*ViewController que lista os registros ativos (findByDeletedFalseOrderByIdDesc, paginado —
ver Listagens paginadas) numa list.jte
sem CRUD — são escritas só pelo ingest, então a UI não edita nem apaga. Todas entram no
menu MDF-e e na seção MDF-e da home, e estão na allowlist do TableExportService (botão
Exportar Excel funciona). Ao contrário das telas de cadastro (Fones etc.), não há form.jte
nem rota de save/delete.
Segurança (auth) — micronaut-security¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-07
O integrador usa o micronaut-security nativo (não mais filtros @Filter custom). Duas
credenciais coexistem, cada uma resolvida por um bean próprio. A infra transversal de auth
(sessão JWT, validação Firebase, settings) vem da lib da casa xadm-seguranca
(br.com.xadm.comum.seguranca, constituição, Engenharia — ver 0018);
a policy (whitelist, tri-estado, Bearer estático via @Replaces) fica local em br.com.xadm.comum.
/api/** — Bearer estático (integração server-to-server)¶
O agente on-prem X-Adm e o app autenticam em /api/** com um token fixo (não é JWT) em
Authorization: Bearer <token>, valor de app.api-token (env INTEGRADOR_API_TOKEN, nome por
destino). Sem fallback para o antigo API_BEARER_TOKEN — o ${INTEGRADOR_API_TOKEN:} do
application.yml não é aninhado, então setar só o nome velho deixa o token VAZIO.
Token vazio em produção = boot recusado. Declarar app.api-token é declarar a feature: desde a
xadm-seguranca 0.7.2 o BearerTokenGuard (bean @Context, eager) recusa o boot quando a
property está declarada e resolve vazio. Antes disso o app subia healthy e o /api/** respondia
401 a tudo (o validador é fail-closed), e o sintoma chegava horas depois como "a integração
parou". Em dev/test, ou com micronaut.security.enabled=false, o vazio só loga WARN — o
validador fica dormente e /api/** responde como se não houvesse Bearer. A guarda também recusa o
boot com placeholder ${...} não resolvido (desde a 0.5.1).
StaticBearerTokenValidator(TokenValidator, da libxadm-seguranca— o bean local com@Replacesda 0018 não existe mais, o app adotou o da lib e passou a declararapp.api-token): Bearer que confere →AuthenticationsystemcomROLE_API; senão, sem autenticação.ApiBearerSecurityRule(SecurityRule): para/api/**, exige autenticação — anônimo →REJECTED(401). Exceções:api.bearer.enabled=false(bypass, usado em teste) e o path públicoGET /api/v1/powersync/lastchange(só timestamp agregado, sem PII).
Views (/, MVC server-render JTE) — sessão Firebase¶
As telas server-side exigem sessão de login Google/Firebase (cookie xadm_session, JWT HS256,
8h). Ver o fluxo em Login Google.
SessionAuthenticationFetcher(AuthenticationFetcher, da libxadm-seguranca): lê o cookie, valida viaSessionTokenService(lib) e devolveAuthenticationcomROLE_VIEW+ popula os atributos de request (auth.email/auth.name) que oGlobalViewModelconsome. Só existe quandoauth.session-secretestá configurado. Oauth.issuer(claimiss) é obrigatório não-vazio no boot (defaultintegrador).ViewSecurityRule(SecurityRule) — tri-estado replicando o antigoAuthFilter:- enabled (envs de auth completas): view exige autenticação;
- bypass (dev/test sem envs): view liberada;
- fail-closed (prod sem envs): view rejeitada (503).
Rejeições — ViewRejectionHandler¶
@Replaces o DefaultAuthorizationExceptionHandler e traduz as rejeições preservando o
comportamento antigo:
| Situação | Resposta |
|---|---|
| autenticado sem permissão | 403 (problem+json) |
view anônima, browser (Accept: text/html ou sem Accept), com login configurado |
302 /login?from=… |
view anônima sem login configurado (dev e test, sem auth.*) |
401 (problem+json) — a lib não registra o /login, e o redirect daria 404 |
view//api anônimo, script (JSON) |
401 (problem+json) |
| view em fail-closed (prod sem envs) | 503 (problem+json) |
O corpo JSON é RFC 7807. Efeito preservado: uma rejeição de /api/health
(Bearer ausente/errado) é gravada no ApiHealthRequestRecorder (tabela request).
Paths públicos — intercept-url-map¶
Os paths verdadeiramente públicos (estáticos + /login) são isAnonymous() no
micronaut.security.intercept-url-map do application.yml: /health, /favicon.ico,
/swagger/**, /swagger-ui/**, /css/**, /images/**, /login, /login/**, /logout. A
ViewSecurityRule devolve UNKNOWN para os whitelisted (AuthSupport.isWhitelisted), deixando o
intercept-url-map/Bearer decidir.
Config¶
api.bearer.enabled/app.api-token— Bearer do/api/**(INTEGRADOR_API_TOKEN; sem fallback paraAPI_BEARER_TOKEN; vazio fora de dev/test recusa o boot).auth.firebase-*/auth.session-secret/auth.cookie-secure— sessão de views.micronaut.security.enabled: true,authentication: bearer,intercept-url-map.
Código e testes¶
- Beans locais (policy per-app),
br.com.xadm.comum.{ApiBearerSecurityRule, ViewSecurityRule, ViewRejectionHandler, AuthSupport, GlobalViewModel, LoginController}. - Beans da lib
xadm-seguranca(infra transversal),br.com.xadm.comum.seguranca.{StaticBearerTokenValidator, BearerTokenGuard, SessionAuthenticationFetcher, AuthSettings, SessionTokenService, FirebaseIdTokenValidator, MicronautProfiles, AuthException}— ver 0018. - Testes (pacote
br.com.xadm.comum, package-by-feature):SecurityRulesTest(regras/validador),AuthSupportTest(whitelist),LoginControllerTest,ApiBearerSecurityIntegrationTest,LoginRateLimitFilterTest, e os IT de auth end-to-endGoogleLoginFlowIT/ViewSessionAuthIntegrationTest(+ rendering emviews/{LoginPageRenderingIT,NavbarRenderingIT}).
/health e versão em runtime¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-07
O /health segue o shape plano da constituição ({status, versao}, Versionamento). O /health do
micronaut-management fica desligado (endpoints.health.enabled: false); quem serve é um
HealthController (br.com.xadm.comum).
{ "status": "UP", "versao": "1.0.3" }
- Liveness, não readiness: é "o app está no ar", não checa o banco (trade-off consciente).
Responde 200 sem autenticação (whitelisted no
intercept-url-map); o Dockerfile fazHEALTHCHECKnele. versaovem deversion.properties, gerado no build a partir deproject.version(task GradlegerarVersionProperties) — nunca um literal no código (literal mente e drifta a cada release). Lido porVersaoInfo(umInfoSource, expõe também no/info).- O
mainloga a versão na 1ª linha do startup (VersaoInfo.versaoAtual()).
⚠️ A versão do @OpenAPIDefinition (em Application) é o contrato HTTP da API, desacoplada
da versão de release — não confundir.
Código: comum/HealthController, comum/VersaoInfo, task gerarVersionProperties no
build.gradle.kts. Teste: integration/HealthControllerIntegrationTest (shape) +
comum/VersaoInfoTest.
Erros de API — RFC 7807 (application/problem+json)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-10
Todo corpo de erro gerado pelo framework (Bean Validation, 404 de rota, parse de corpo,
HttpStatusException) é formatado no formato RFC 7807 por um ponto único — substitui o
envelope custom antigo {status, error, message}.
{ "type": "about:blank", "title": "Not Found", "status": 404, "detail": "…", "code": "NOT_FOUND" }
ProblemDetail(br.com.xadm.comum.web, da lib da casaxadm-comum-web— decisão 0017) — record{type, title, status, detail, code, invalid-params}.codeé a extensão 7807 com o código estável legível por máquina;invalid-params(lista de{name, reason}) só aparece na falha de Bean Validation, que sai comcodeBAD_REQUEST.UnifiedErrorResponseProcessor(mesma lib) —@ReplacesoHateoasErrorResponseProcessordefault; formata o corpo (não decide o status) comContent-Type: application/problem+jsone loga todo erro: 5xx e 404 autenticado em ERROR (vão ao GlitchTip), o resto em DEBUG.- O
GlobalExceptionHandler(500; para browser serve página HTML, fluxo especial) e oViewRejectionHandler(auth) emitem o mesmo shape.
Exceções deliberadas ao processor¶
/api/v1/xadm. O endpoint XADM não usa esse processor: o 400 de JSON malformado é
produzido no controller (XadmResponse), e JSON válido sempre responde 200 com o resultado no
corpo — contrato com o agente on-prem, inalterado. Ver tratamento de erros XADM.
/api/v1/heartbeat. O HeartbeatController monta os próprios erros (503 heartbeat_desligado,
502 heartbeat_recusado, 502 central_indisponivel — contrato) como
ProblemDetail à mão, no molde do ViewRejectionHandler, em vez de lançar exceção. Duas razões: o
processor loga todo 5xx em ERROR — o 502 transitório (central fora) viraria uma issue por hora em cada
instância, e ele tem de ficar em WARN — e devolve o nome do status como code, não os códigos do
contrato. O 400 de validação do heartbeat continua sendo do processor. Porquê e alternativas:
decisão 0026.
Código: ProblemDetail e UnifiedErrorResponseProcessor na lib; comum/{GlobalExceptionHandler,
ViewRejectionHandler}; heartbeat/HeartbeatController. Teste:
comum/{UnifiedErrorResponseProcessorTest, GlobalExceptionHandlerTest}, heartbeat/*Test.
Observabilidade — Sentry/GlitchTip¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-13
Recipe da casa: captura todo log ERROR (inclui exceção não-tratada) sem tocar o negócio,
via o SentryAppender do logback. Não há mais o plugin io.sentry.jvm.gradle (era inerte —
source context e auto-install desligados).
- Dependência:
io.sentry:sentry-logback(traz osentry-coretransitivo; a APIio.sentry.Sentrycontinua disponível para capturas explícitas/breadcrumbs). logback.xml: um<appender name="SENTRY" class="io.sentry.logback.SentryAppender">comminimumEventLevel=ERROR, referenciado no<root>. Sem<dsn>no appender de propósito.SentryInitializer.initFromEnvironment()faz oSentry.init()nomain(), ANTES deMicronaut.run— a partir deSENTRY_DSN, só em prod (não em dev/test, viaMICRONAUT_ENVIRONMENTS). Sem DSN → Sentry desabilitado → o appender vira no-op (não spamma). Inicializar antes do contexto é de propósito: um crash de startup (ex.: validação Flyway ao criar oDataSource) precisa sair com a tagcliente. Quando era um@EventListener(StartupEvent), esses erros iam ao GlitchTip sem a tag (oStartupEventsó dispara se o contexto sobe).
Assim, em produção com DSN todo log.error(...) (com a exceção anexada) vira evento no
GlitchTip; em dev/local não. Capturas explícitas (Sentry.captureException) seguem valendo (ex.:
XadmService reporta erros de origens conhecidas). Evite log.error displicente — vira ruído no
GlitchTip.
Projeto único + tag de cliente (multi-tenant)¶
O integrador roda um recurso Coolify por cliente, mas há um único projeto GlitchTip
(integrador, provisionado pela /xadm-setup — DSN público em docs/app.json
features.glitchtip.dsn). Para distinguir os eventos por cliente sem projetos separados, o
SentryInitializer marca toda ocorrência com a tag cliente (fonte: env CLIENTE). No
GlitchTip, filtre/agrupe por cliente para isolar o tenant.
- DSN é o mesmo para todos os deploys → configure o mesmo
SENTRY_DSNem cada recurso de cliente no Coolify (a provisão não injeta sozinha: o app não temproduction_urlúnico). CLIENTEjá existe noapplication.yml(${CLIENTE:Cliente}); sem ele, os eventos ficam sem a tag (ou com o placeholder), sinalizando recurso mal-configurado.
Código: src/main/resources/logback.xml, comum/SentryInitializer (método configure).
Tratamento de erros do endpoint /api/v1/xadm¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06
Este documento descreve o que acontece quando um PUT/DELETE em /api/v1/xadm
falha: como o erro é persistido, quando o Sentry é acionado, e como a serialização de
requests preserva a ordem esperada pelo ERP.
Relacionado: src/main/java/br/com/xadm/service/XadmService.java,
XadmApplyService.java.
Contrato HTTP (inalterado)¶
O endpoint sempre devolve HTTP 200 quando o payload é JSON válido, mesmo em falha de persistência. O resultado real está no corpo:
| Corpo | Significado |
|---|---|
{"requestId": N, "resultado": "SUCESSO", "mensagem": null} |
Persistência comitou. |
{"requestId": N, "resultado": "ERRO", "mensagem": "..."} |
Persistência falhou. |
HTTP 400 é devolvido apenas para JSON malformado (JsonProcessingException). Esses
erros não vão para o Sentry (são considerados erros de cliente).
Serialização de requests XADM¶
O ERP especifica pares DELETE → PUT para limpar/reescrever estado. Se o integrador
processasse esses requests em paralelo, dois cenários quebrariam:
- Inversão de ordem: PUT comita primeiro, DELETE comita por cima → linha fica
marcada
deletedquando deveria ter o estado novo. - Deadlock PostgreSQL (
ERROR: deadlock detected, SQLState40P01): duas transações concorrentes fazendo UPDATE na mesma linha (ex.:itenspedamx.seq_n) se travam mutuamente — o Postgres aborta uma; o request quebra com 200 ERRO.
Solução: XadmService envolve as chamadas a applyPut/applyDelete em um
ReentrantLock(fair=true) global. Apenas um request XADM passa pelo trecho de
persistência por vez, na ordem (aproximadamente) FIFO de chegada. Casa com o
comportamento sequencial do integrador antigo.
Trade-off: throughput cai a 1 request-por-vez no trecho serializado. Volume do
XADM é baixo (eventos de negócio do ERP), aceitável. Parsing de JSON e gravação na
tabela request (audit) não estão sob o lock — podem acontecer em paralelo.
Limitação: o lock cobre apenas o caminho XADM. Outros endpoints que escrevem nas mesmas tabelas (PowerSync, controllers REST diretos) podem teoricamente colidir com o XADM. Hoje isso é raro o suficiente para não ser tratado; se virar problema, considerar serializar mais amplo ou retomar a estratégia de retry.
Whitelist KNOWN_ORIGENS para Sentry¶
Apenas erros em requests com Origem na whitelist disparam Sentry.captureException.
Origens fora da lista continuam sendo registradas no request (coluna resultado=ERRO,
mensagem com detalhe) mas não geram evento no Sentry — evita ruído de chamadas
espúrias ou origens internas.
Whitelist atual (XadmService.KNOWN_ORIGENS):
PPEDAMX, PPEDIDO, PBLDI, PNOTAI, PNOTAD, PCSTPROD, PREQUIS, INT-PIED
INT-PIED é a origem do fluxo de entrada PIED (ver decisão 0013).
Origens internas explicitamente fora da whitelist: API_HEALTH, powersync, "?"
(quando payload chega sem campo Origem).
Adicionando nova origem: edite XadmService.KNOWN_ORIGENS e recompile/redeploy.
Não há config externa.
Tabela de decisão: onde o erro é reportado¶
| Cenário | Log | Sentry issue |
|---|---|---|
| Happy path | INFO resultado=SUCESSO |
— |
| Falha de persistência, origem conhecida | WARN XADM PUT/DEL falhou |
✅ com origem/tipo/request_id |
| Falha de persistência, origem desconhecida | WARN XADM PUT/DEL falhou |
— |
| JSON malformado (HTTP 400) | WARN parse error |
— |
| Falha do SDK Sentry (DSN inválido, network) | DEBUG Sentry não disponível... |
— |
Como inspecionar em produção¶
Logs (Loki/stdout): procurar por
- XADM PUT processando requestId=... / XADM DEL processando requestId=... — entrada do request.
- XADM PUT concluído requestId=... resultado=SUCESSO — sucesso.
- XADM PUT falhou: requestId=... origem=... detalhe=... — request final que falhou.
- Sentry não disponível ou falha ao reportar erro XADM — indica problema no SDK Sentry.
Sentry: filtrar por
- Tag origem:PPEDIDO (ou outra conhecida) para ver falhas por origem.
- Tag tipo:PUT vs tipo:DEL.
Banco (request table): SELECT * FROM request WHERE resultado='ERRO' ORDER BY id DESC — mensagem contém tabela=... registro=(...) | mensagem_original=... formatado.
Histórico¶
- 2026-04-24: incidente
PPEDIDO seq_n=777 → deadlock_detectednoint.sulplatamotivou esta spec. - Solução inicial considerada: retry com backoff em deadlock. Descartada porque retry
pode inverter a ordem
DELETE → PUTesperada pelo ERP (correção de paralelismo não preserva ordering). Adotada serialização global no XADM. - Spec:
docs/ia/002-rest-ok-antigo-erro-novo-spec.md. - Plan:
docs/ia/002-rest-ok-antigo-erro-novo-plan.md.
GET /api/v1/powersync/lastchange¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06
Endpoint de stale-check consumido pelo app Flutter
(PowerSyncStaleChecker — RF-7). Retorna o timestamp da modificação
mais recente da tabela notacompl; o app compara com o
MAX(COALESCE(updated_at, created_at)) local para detectar bancos travados.
O COALESCE é necessário porque registros antigos (anteriores a V10) ou
importados sem upsert ficam com updated_at = NULL e apenas created_at
preenchido — sem o fallback o MAX do servidor ficaria menor que o do app e
o stale-check entraria em flapping.
Desde a decisão 0014, notacompl.updated_at
é auto-populado por @DateUpdated (Micronaut Data) em toda escrita de entidade —
antes vinha do cliente ou ficava NULL. Novas escritas passam a ter updated_at
confiável (server-time), o que fortalece o stale-check. A coluna permanece
nullable de propósito (sem backfill): manter as linhas legadas com NULL é o
que preserva o fallback COALESCE acima — um NOT NULL DEFAULT now() as
promoveria todas ao instante da migração e distorceria o MAX.
URL por cliente¶
- Sul Plata:
https://int.sulplata.xadm.biz/api/v1/powersync/lastchange - On Petro Trading:
https://int.onpetrotrading.xadm.biz/api/v1/powersync/lastchange
Autenticação¶
Público — nenhum token exigido.
O caminho está decidido pela ApiBearerSecurityRule (micronaut-security): /api/v1/powersync/lastchange é ALLOWED sem Bearer. Todas as outras rotas sob
/api/** continuam protegidas pelo Bearer estático.
A justificativa para abrir este endpoint:
- o payload é apenas um timestamp agregado, sem PII;
- o app Flutter precisa chamar mesmo em cenários de boot frio / sessão expirada, quando ainda não tem Bearer em mãos;
- a spec original mencionava "Firebase ID Token", mas o integrador não valida JWT; manter o token estático aqui só adicionaria acoplamento operacional sem ganho de segurança.
Response¶
200 OK¶
{ "last_changed_at": "2026-04-16T13:42:08.123Z" }
last_changed_at: ISO-8601 em UTC (Z), sempre com 3 casas decimais.- Quando
notacomplestá vazia: retorna"1970-01-01T00:00:00.000Z"(epoch) em vez denull.
500 Internal Server Error¶
{ "error": "internal_error" }
Falha de DB / erro inesperado. Stack é logada via SLF4J e reportada pelo
handler global (Sentry). O app trata qualquer status ≠ 200 como "endpoint
indisponível" → assume upToDate silenciosamente.
Implementação¶
- Controller:
PowersyncLastChangeController. - Query: JDBC direto via
DataSource→SELECT MAX(COALESCE(updated_at, created_at)) FROM notacompl. Usa JDBC puro em vez de Micronaut Data porque o annotation processor de Micronaut Data JDBC não suporta@Querysem parâmetros nem derived queries comfindFirst...OrderBy...em repositórios com UUID v7. OCOALESCEmantém simetria com o cálculo local do app (ver nota acima). - Formatação:
DateTimeFormattercom padrãoyyyy-MM-dd'T'HH:mm:ss.SSS'Z'fixado em UTC — independe de configuração global de Jackson/Serde.
Performance¶
- Latência alvo: < 200 ms p99.
- Stateless; cache no integrador é opcional e ainda não implementado (o app chama no máximo 1×/h via timer, ou burst de pull-to-refresh).
Evolução futura¶
A v2 pode retornar breakdown por tabela sem quebrar consumidores v1:
{
"last_changed_at": "...",
"by_table": { "notacompl": "...", "itens": "...", "vendas": "..." }
}
Basta estender LastChangeResponse com um campo adicional —
o app v1 ignora campos desconhecidos.
Testes¶
PowersyncLastChangeControllerTest
cobre:
- timestamp formatado em UTC
Zcom ms; - tabela vazia (MAX retorna NULL) → epoch;
- ResultSet vazio → epoch;
- normalização UTC independente de offset;
- erro de DB / connection refused → 500 com
{"error": "internal_error"}.
O bypass público é coberto em:
ApiBearerSecurityRuleTest— caminho exato e com barra final passam direto ao controller mesmo com Bearer habilitado;ApiBearerSecurityRuleIntegrationTest— chamada HTTP real retorna 200 sem headerAuthorization.
Baixa de MDF-e por macro Sascar¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-30
O Integrador é a ponte entre quem detecta o fim da viagem (o int-sascar, via macro no rastreador Sascar) e quem encerra a MDF-e no ERP (o integrador-client, no ZIM). Ele espelha as tabelas de MDF-e do X-Adm, avisa o int-sascar quais MDF-e vigiar, recebe o callback de encerramento e grava um comando de baixa que o client executa e reporta de volta.
Tudo é aditivo ao ingest existente: mesmo endpoint PUT /api/v1/xadm, sem rota nova de entrada.
As escolhas de desenho e o preterido estão na decisão 0015.
Fluxo ponta a ponta¶
X-Adm ──MDFE_PUT──▶ Integrador ──watch aberto──▶ int-sascar
(espelho mdf) │
(motorista fecha macro)
│
Integrador ◀──POST /api/sascar/encerramento───────────┘
│ grava situacao_baixa=BAIXA, cod_retorno=000
▼
PowerSync ──▶ integrador-client (ZIM) executa ENCERRA_MDFE, escreve cod_retorno=006
│
▼ (write-back PowerSync)
X-Adm ──MDFE_PUT com NFEEVENTO(110112,A)──▶ Integrador ──watch encerrado──▶ int-sascar
Passo final (watch encerrado) é obrigatório e temporal: o int-sascar alarma se não vier em
~6h (o callback 2xx só marca enviado; o mdfe_vigiado só fecha ao receber o encerrado).
Ingest do MDFE_PUT¶
Origem:PMDFE. O CNPJ/Razao_Social do emitente vêm da raiz do payload (não são colunas da
mdf). O ingest é tipado por tabela (como o resto do XADM): cada tabela espelha por upsert de chave
literal do ZIM (TRIM só na consulta). Tabelas espelhadas: mdf, mdfcompl, nfeevento,
mdfitens, nfmdf, nfcomplmdf, fretes (municipio/propriedades reusadas). MOTORISTAS/
FRETESMOT não são espelhadas (fora de escopo).
Tabela ausente é tolerada: uma MDF-e sem NFEEVENTO(110112,'A') = aberta (situacao_baixa=ATIVO).
situacao_baixa (coluna própria do Integrador)¶
Na mdf, não vem do X-Adm. Valores:
| valor | significado |
|---|---|
ATIVO |
vigiada; sem baixa solicitada |
BAIXA |
encerramento solicitado (comando pendente) — cod_retorno=000 |
ERRO |
falha reportada pelo client |
Só a transição ATIVO→BAIXA grava (idempotência do callback). O NFEEVENTO do espelho é fiel ao
X-Adm e só leitura — o comando de baixa não o sobrecarrega. A confirmação do encerramento
(NFEEVENTO 110112/A) é marcada em encerrado_em (não re-empurra o watch encerrado — idempotência).
O watch encerrado dispara na transição independente da situacao_baixa: o fluxo comum é
baixar no X-Adm primeiro — a MDF-e é encerrada lá (fica ATIVO, sem passar pela macro Sascar) e o
único efeito necessário é o int-sascar parar de vigiar a placa. Como o watch aberto vigia a placa
de toda MDF-e aberta, todo encerramento (via Sascar ou direto no X-Adm) precisa avisá-lo a parar.
Tela /mdf (visualização)¶
A tela distingue dois eixos por MDF-e, em colunas próprias: Status — o documento no ERP,
ENCERRADA quando encerrado_em está setado, ABERTA caso contrário (derivado, sem coluna
nova) — e Situação baixa — o situacao_baixa (ATIVO/BAIXA/ERRO) do comando Sascar. Uma MDF-e
encerrada direto no X-Adm (sem macro) aparece ENCERRADA + Situação baixa=ATIVO — antes lia só
ATIVO e parecia aberta. Só apresentação (derivação inline na view JTE); o export XLSX segue
SELECT * (leva encerrado_em cru, não o rótulo). Ver .ia/011.
cod_retorno (mesmo contrato do V18)¶
000 pendente (callback) · 006 executado (write-back do client) · 1XX/9XX erro. É o sinal que
o fluxo ENCERRA_MDFE do integrador-client observa.
Watch de saída (Integrador → int-sascar)¶
POST {sascar.watch.url}/api/integrador/mdfe/watch, Bearer = inbound_token do cliente. Só dispara
quando o PUT tocou MDF-e; não empurra MDF-e já encerrada.
Entrega garantida (desde .ia/012 — decisão 0016). O watch deixou de ser best-effort: agora é enfileirado no outbox (
xadm-mensageria) — no caminho do ingest, dentro da transação do apply (rollback dropa a linha); no writeback powersync, pós-commit via oMdfeWatchListener. ORelayM2mda lib entrega (retry durável,PAUSADO+ alarme no teto); o operador reenvia/cancela em/admin/mensageria. O corpo destilado (abaixo) é salvo comopayload; oWatchEnviadorchama o@Client(transporte provisório até o contrato 006). Idempotente (o receptor deduplica porchave_nota).
Corpo destilado (o int-sascar nunca vê JSON cru do ZIM):
| campo | origem |
|---|---|
slug |
sascar.slug (slug canônico do cliente na central; o int-sascar dá 400 se divergir do inbound_token) |
chave_nota |
mdf.chave_nota (trimada) |
cnpj |
raiz do payload |
filial |
mdf.vinc_filial |
placa_cavalo |
mdfcompl.docto |
finalidade |
mdfitens.finalidade do 1º item (M se os itens divergirem) |
status |
aberto / encerrado |
destino_cidade / destino_uf |
discriminante NFe×CTe (abaixo) |
Destino por nfmdf.modelo: NFe (55) → nfcomplmdf.loc_entrega → propriedades.cod_mun →
municipio; CTe (57) → fretes.mun_dest → municipio.
Callback de encerramento (int-sascar → Integrador)¶
POST /api/sascar/encerramento (path literal, não /api/v1/). Auth pelo ApiBearerSecurityRule
(rota sob /api/**): o int-sascar envia o Bearer da casa — o outbound_token dele é o
INTEGRADOR_API_TOKEN deste app (a casa tem um Bearer único de /api). Bearer inválido → 401.
Corpo: chave_nota, cnpj, filial, placa, data_pacote (ISO-8601 local sem offset), id_pacote,
cidade, uf, motivo (macro|geo). Localiza a mdf por chave_nota (TRIM), grava
situacao_baixa=BAIXA + cod_retorno=000 + proveniência (id_pacote/data_pacote/motivo).
Sempre 2xx (o int-sascar é dono do retry e trata não-2xx como transitório):
| resultado | quando |
|---|---|
BAIXA |
comando gravado (transição ATIVO→BAIXA) |
NOOP |
idempotente — já em BAIXA/ERRO (reenvio não regride) |
NAO_ENCONTRADO |
chave não ingerida ainda (não 404 — 404 giraria o retry infinito) |
O callback recebido é auditado como ENTRADA/RECEBIDO no outbox (registrarEntrada, .ia/012) —
best-effort, não altera o sempre-2xx nem a idempotência de negócio; visível em /admin/mensageria.
Segredos e configuração (ENV)¶
Nunca versionados — só ENV no Coolify (constituição, Duas fontes da verdade):
| ENV | uso |
|---|---|
SASCAR_WATCH_ENABLED |
liga o watch (off por default) |
SASCAR_WATCH_URL |
base do int-sascar (ex. https://sascar.xadm.biz) |
SASCAR_INBOUND_TOKEN |
Bearer que o Integrador envia no watch (segredo) |
SASCAR_SLUG |
slug canônico do cliente (fallback CLIENTE) |
INTEGRADOR_API_TOKEN |
Bearer que o int-sascar envia no callback (= Bearer da casa /api) |
PowerSync do cliente (entrega do comando)¶
O comando de baixa chega ao integrador-client pelo canal PowerSync existente (bucket global, sem
bucket novo). Recorte mínimo: só a mdf — ela carrega chave_nota, vinc_filial,
situacao_baixa, cod_retorno, msg_retorno, tudo que o ENCERRA_MDFE lê para executar e reportar.
O SchemaTabelas do client segue este recorte (o espelho define o client).
No config.yaml do cliente, sob sync_rules.global.data:
- SELECT * FROM mdf WHERE deleted = false
Não sincronizar as demais tabelas MDF-e (contexto interno do espelho), MOTORISTAS/FRETESMOT,
nem criar bucket novo. A publicação Postgres powersync é FOR ALL TABLES no caminho padrão (a mdf
entra sozinha); se o cliente usa FOR TABLE ..., rodar ALTER PUBLICATION powersync ADD TABLE mdf;.
O write-back do cod_retorno/msg_retorno sobe pelo POST /api/v1/powersync já existente (sem canal
novo; client_auth inalterado). Ver atualizar-syncrules.
Cobertura de teste¶
Os 3 JSON reais (docs/anexos/privado/MDFE_PUT_2026072*.json) cobrem só o estado ATIVO
(16433017 sem NFEEVENTO; 17053300/17294274 com NFEEVENTO(110112,'I')). O ramo encerrado/
reconciliação exige o fixture sintético MDFE_PUT_SINTETICO-encerrado.json (SitEvento I→A).
Integração em Postgres real: MdfeEspelhoIngestIntegrationTest; destilação: MdfeWatchDistillerTest.
Login Google (Firebase) — views do integrador¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06
Mecanismo: o como da auth (beans micronaut-security,
intercept-url-map) está em Segurança. Aqui foca o fluxo de login Google/Firebase das views.
Restringe acesso às telas server-rendered do integrador a usuários autenticados via
Google (Firebase Auth) com email @xadm.com.br. APIs em /api/** continuam protegidas
pelo Bearer estático do micronaut-security (StaticBearerTokenValidator + ApiBearerSecurityRule,
ver Segurança).
Visão geral¶
- Página
/loginserver-rendered (JTE) usa Firebase Web SDK v10.13.2 via CDN do gstatic. Botão "Entrar com Google" abre popup, obtém Firebase ID token e posta para/login/callback. - Backend valida o Firebase ID token (RS256 via JWKS público do Google), confirma
o domínio
@xadm.com.bre emite um cookie de sessãoxadm_session(JWT HS256, TTL 8h). ViewSecurityRule(MicronautSecurityRule, tri-estado) exige o cookie de sessão para qualquer path fora da whitelist; o cookie é lido peloSessionAuthenticationFetcher(libxadm-seguranca). Mecanismo em Segurança.- CSRF via double-submit cookie:
/logingera token aleatório → cookie HttpOnlyxadm_login_csrf(Path=/login) + meta<meta name="xadm-csrf">no DOM. JS lê o meta e posta no body. Backend compara cookie ↔ body em constant-time.
Diagrama do fluxo¶
sequenceDiagram
participant U as Usuário (browser)
participant App as Integrador (Micronaut)
participant FB as Firebase JS SDK
participant G as Google
U->>App: GET /contratos (sem cookie)
App-->>U: 302 /login?from=%2Fcontratos
U->>App: GET /login
App-->>U: 200 HTML + Set-Cookie xadm_login_csrf + meta xadm-csrf
U->>FB: click "Entrar com Google" → signInWithPopup
FB->>G: OAuth popup
G-->>FB: id_token
FB-->>U: cred.user.getIdToken()
U->>App: POST /login/callback {credential, csrfToken, from}
App->>G: GET JWKS (com cache)
G-->>App: chaves RSA
App->>App: verify signature + iss + aud + exp + domínio @xadm.com.br
App-->>U: 200 {redirect:"/contratos"} + Set-Cookie xadm_session
U->>App: GET /contratos (com cookie)
App-->>U: 200 HTML
U->>App: POST /logout
App-->>U: 303 /login + Set-Cookie xadm_session (Max-Age=0)
Variáveis de ambiente¶
Configurar no Coolify (produção) ou no shell de dev:
| Env | Descrição | Default | Obrigatório em prod |
|---|---|---|---|
AUTH_FIREBASE_PROJECT_ID |
ID do projeto Firebase (xadm-6ab81) |
vazio | ✅ |
AUTH_FIREBASE_API_KEY |
Web API key (público; vai no HTML) | vazio | ✅ |
AUTH_FIREBASE_AUTH_DOMAIN |
ex.: xadm-6ab81.firebaseapp.com |
vazio | ✅ |
AUTH_FIREBASE_APP_ID |
Web App ID (1:32869493670:web:...) |
vazio | ✅ |
AUTH_SESSION_SECRET |
Segredo HS256 ≥ 32 chars | vazio | ✅ |
AUTH_SESSION_TTL_SECONDS |
TTL do cookie de sessão | 28800 (8h) |
— |
AUTH_COOKIE_SECURE |
Força flag Secure no cookie mesmo sem X-Forwarded-Proto |
true em prod, false em dev |
— |
AUTH_XADM_EMAIL_DOMAIN |
Domínio aceito | xadm.com.br |
— |
Modo dev: se qualquer uma das envs obrigatórias estiver vazia, o ViewSecurityRule
entra em bypass total — todas as views ficam públicas. Log WARN único no startup
lista as envs faltantes. Para rodar local sem TLS, basta deixar as envs em branco.
Como obter as envs do Firebase¶
No console do projeto xadm-6ab81:
- Firebase Console → Project settings → General → Your apps → Web app → Firebase SDK
snippet → Config:
const firebaseConfig = {
apiKey: "AIza...",
authDomain: "xadm-6ab81.firebaseapp.com",
projectId: "xadm-6ab81",
appId: "1:32869493670:web:..."
};
Os valores são públicos (já estão no HTML do authui, ver
@C:\Work\Xadm\integracao\authui\lib\firebase_options.dart). Restrições reais ficam no
Firebase Auth (provedores permitidos, domínios autorizados).
Como gerar AUTH_SESSION_SECRET¶
openssl rand -hex 32
# Saída: 64 chars hex → ≥ 32 bytes, suficiente para HS256
Cada deploy deve ter seu próprio segredo. Não compartilhar entre clientes Coolify.
Como rotacionar AUTH_SESSION_SECRET¶
- Gerar novo segredo:
openssl rand -hex 32. - Substituir env no Coolify e reiniciar o container.
- Impacto: todas as sessões existentes ficam inválidas — usuários terão que logar de novo. Como TTL é 8h, vale planejar a rotação fora do horário operacional.
Whitelist (paths públicos)¶
ViewSecurityRule libera sem cookie:
Match exato: /health, /favicon.ico, /login, /logout
Match por prefixo: /health/, /swagger-ui, /swagger-ui/, /swagger/, /login/,
/css/, /images/, /api/
/api/** está na whitelist do ViewSecurityRule mas continua protegido pelo Bearer estático
(ApiBearerSecurityRule + StaticBearerTokenValidator, que validam Authorization: Bearer <INTEGRADOR_API_TOKEN>).
/debug não está na whitelist — exige sessão em produção. Em dev/test continua
acessível porque o ViewSecurityRule está em bypass.
Requisito do reverse proxy¶
Coolify (ou qualquer proxy TLS-terminator) deve repassar o header X-Forwarded-Proto
para que o helper ViewSecurityRule.isHttps(request) detecte HTTPS e emita o cookie com
Secure. Caso o proxy não envie o header, o default AUTH_COOKIE_SECURE=true ainda
garante que o cookie sai com Secure (fallback seguro). Em dev local com HTTP simples,
configurar AUTH_COOKIE_SECURE=false (já feito no application-dev.yml).
Componentes implementados¶
Infra transversal (br.com.xadm.comum.seguranca.*) vem da lib xadm-seguranca
(0018); a policy e os controllers ficam locais em br.com.xadm.comum.
| Classe / arquivo | Origem | Responsabilidade |
|---|---|---|
…comum.seguranca.AuthSettings |
lib | @ConfigurationProperties("auth") |
…comum.seguranca.AuthException |
lib | code ∈ {invalid_token, invalid_session, jwks_unavailable, csrf_mismatch, domain_not_allowed, ...} |
…comum.seguranca.SessionTokenService |
lib | Emite/valida JWT HS256 (claim iss = auth.issuer) |
…comum.seguranca.FirebaseIdTokenValidator |
lib | Valida Firebase ID token (RS256+JWKS) com cache e stale fallback |
…comum.seguranca.SessionAuthenticationFetcher |
lib | Lê o cookie, resolve a Authentication das views |
…comum.seguranca.XadmLoginController |
lib | GET /login, POST /login/callback, POST /logout |
…comum.seguranca.XadmViewModel |
lib | Injeta os globais core no model: appNome, appVersao, clienteXadm, currentUserEmail, currentUserName, csrfToken, authEnabled |
br.com.xadm.comum.ViewSecurityRule |
local | SecurityRule tri-estado + whitelist |
br.com.xadm.comum.GlobalViewModel |
local | Injeta só os gates de UI do app: showInternalMenus, crudWriteAllowed, debugOperationsAllowed |
xadm/login.jte |
lib | Tela de login (Firebase JS SDK), servida pelo jar desde a xadm-seguranca 0.7.1 (0032 do central). É autocontida: sem navbar, porque todo item do menu levaria de volta para ela. O jte/login.jte local foi apagado no bump |
jte/kit/layout.jte |
kit central | Navbar com email + form POST /logout |
Considerações de segurança¶
- Cookie
xadm_sessioné HttpOnly (JS não consegue ler) +SameSite=Lax+Secure(quando HTTPS). - CSRF via double-submit: ataque cross-site não consegue gravar cookie
xadm_login_csrfpor causa doSameSite=Lax, e cookie é HttpOnly (não pode ser lido pelo JS atacante). - Comparação CSRF em constant-time (
MessageDigest.isEqual). - Limite de tamanho de 8KB no Firebase ID token (rejeitado antes da validação).
isseaudvalidados; clock skew de 60s tolerado.- Stale cache do JWKS evita queda total quando Google estiver indisponível.
- Sessão sem revogação ativa — para invalidar todas, rotacionar
AUTH_SESSION_SECRET.
Limitações conhecidas¶
- TTL fixo 8h sem sliding session — reavaliar se incomodar.
- Popup do Firebase JS pode ser bloqueado pelo browser; mensagem explícita exibida.
- Sem fallback
signInWithRedirect(deferido). - Sem rate limiting em
/login/callback. - Sem multi-conta no mesmo browser.
- Em browsers sem suporte a
SameSite=Lax, o cookie degrada paraSameSite=None(default antigo). Aceitável para uso interno.
Testes¶
A mecânica de sessão/Firebase (SessionTokenService, FirebaseIdTokenValidator) é testada no repo
da lib xadm-commons. Aqui ficam os testes de policy e fluxo deste app (pacote br.com.xadm.comum):
LoginControllerTest— unit dos handlers/login,/login/callback,/logout.SecurityRulesTest— unit das regras/validador (Bearer + view tri-estado).AuthSupportTest— unit da whitelist de paths.GlobalViewModelTest— unit + Micronaut doViewModelProcessor.LoginRateLimitFilterTest— unit do rate-limit de/login.ApiBearerSecurityIntegrationTest—@MicronautTestdo Bearer em/api/**.ViewSessionAuthIntegrationTest—@MicronautTestHTTP do gate de sessão das views.GoogleLoginFlowIT—@MicronautTestjornada E2E (happy path, domínio errado, CSRF mismatch, bypass/api/**, paths públicos,/debug, matriz Secure).views/{LoginPageRenderingIT, NavbarRenderingIT}— rendering das telas de login/navbar.
./gradlew test --tests "*Auth*" --tests "*Login*" --tests "*Security*" --tests "GlobalViewModel*"
./gradlew test --tests "GoogleLoginFlowIT"
./gradlew coverage # JaCoCo
Smoke manual¶
export AUTH_FIREBASE_PROJECT_ID=xadm-6ab81
export AUTH_FIREBASE_API_KEY=AIzaSyBeQLwetHsi6XMQ89N1pbHq2hbw_zPvtMw
export AUTH_FIREBASE_AUTH_DOMAIN=xadm-6ab81.firebaseapp.com
export AUTH_FIREBASE_APP_ID=1:32869493670:web:e411e1fd9283c583fabd67
export AUTH_SESSION_SECRET=$(openssl rand -hex 32)
export AUTH_COOKIE_SECURE=false # dev local sem TLS
./gradlew run -t # ou java -jar build/libs/app.jar
- Abrir
http://localhost:8080/contratos→ redirect para/login. - Clicar "Entrar com Google" → popup Google → logar com conta
@xadm.com.br. - Confirmar redirect para
/contratos+ email/nome no navbar + botão "Sair". - Clicar "Sair" → cookie expira → próximo acesso a view redireciona de novo.
- Testar
auth/popup-blocked: configurar browser para bloquear pop-ups e tentar login — mensagem deve aparecer "Popup bloqueado pelo browser…".
Soft delete e coluna deleted¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06
As tabelas de negócio (contratos, estoque, fones, itens, itenslote, itensped, itenspedamx, itpedamxdi, loteest, municipio, notacompl, propriedades, tbpcoest, veiculos) possuem exclusão lógica:
deleted:BOOLEAN NOT NULL DEFAULT false— indica se o registro foi excluído (soft delete).deleted_at:TIMESTAMPTZ NULL— data/hora da exclusão.
Regra no banco¶
A coluna deleted é sempre NOT NULL e tem DEFAULT false. Isso evita null no mobile e em integrações:
- Novos registros passam a ter
deleted = falseautomaticamente. - Nenhum valor
NULLé permitido emdeleted.
A migration V7__deleted_not_null_default_false.sql garante isso em todas as tabelas (preenche NULL existentes com false, define default e aplica NOT NULL).
Uso na API¶
- DELETE (API v1): faz soft delete (
deleted = true,deleted_at = now()). - POST com a mesma chave: restaura o registro (
deleted = false,deleted_at = null). - Respostas da API sempre trazem
deletedcomo booleano (trueoufalse), nuncanull.
Dados pessoais na réplica (LGPD) — a definir¶
Status: Rascunho · Responsável: Gustavo Madruga · Atualizado em: 2026-08-11
Status: rascunho / placeholder. Este documento marca uma pendência de política, não uma decisão tomada. O conteúdo abaixo lista o que precisa ser definido — preenchimento é do dono do produto / jurídico, não derivável do código.
Fato (confirmado no código)¶
O integrador mantém uma réplica do banco do X-Adm na nuvem (banco por cliente, que o hub
possui). Notas e pedidos carregam CPF/CNPJ — e o fluxo de entrada PIED usa o documento
(CNPJ/CPF, só-dígitos) como chave natural (int-pied, PiedClienteRepository). Ou seja:
dado pessoal trafega e repousa na cópia em nuvem, por desenho (a integração é espelho do ERP,
decisão D0).
A definir (política — não é decisão de engenharia)¶
- Base legal: é do cliente (controlador). Registrar qual, por cliente.
- Retenção: por quanto tempo a réplica guarda dado pessoal; política de expurgo/anonimização.
- Acesso: quem pode acessar as consoles (views server-render) que expõem esses dados —
hoje o gate é login Google
@xadm.com.br+ SSO nas portas (ver Segurança). - Logs: o que dos dados pessoais aparece em log/observabilidade (Sentry/GlitchTip) e por
quanto tempo — hoje
send-default-pii: truenoapplication.yml(revisar). - Papéis: controlador (cliente) × operador (nós). Retenção, acesso e logs são responsabilidade nossa como operador.
Onde isso deveria morar¶
Quando a arquitetura-alvo estabilizar, um parágrafo de LGPD entra na spec do hub (repo central). Este stub existe só para a pendência não sumir. Ver a seção "Ainda abertas" em arquitetura-alvo.
Ordenação cronológica das notas de venda¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06
Problema¶
A listagem de vendas (notas) precisa ser ordenada cronologicamente em ordem descendente (mais recente primeiro). Hoje:
itens.dt_est(oudt_ext) é do tipo DATE — não há hora/minuto/segundo. Várias notas no mesmo dia não têm ordem definida.- num_nf_num (número da NF extraído da chave) é contador por empresa, não global. Usar esse campo para ordenar não reflete a ordem cronológica real entre notas de empresas diferentes nem mesmo dentro da mesma empresa de forma confiável.
Ou seja: não existe hoje nenhum campo nas tabelas de negócio (notacompl, itens) que forneça ordem cronológica real.
Campo novo em notacompl¶
Adicionar em notacompl:
created_atTIMESTAMPTZ — preenchido na criação do registro (insert).updated_atTIMESTAMPTZ (opcional) — preenchido em toda atualização do registro (update).
Assim toda nota (vinda de PNOTAI, PowerSync ou outro fluxo) tem um instante associado e a ordenação pode ser:
ORDER BY n.created_at DESC -- ou COALESCE(n.updated_at, n.created_at) DESC
Versionamento e release¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06
Fontes de versão¶
| Lugar | O que representa | Quem atualiza |
|---|---|---|
gradle.properties version=X.Y.Z |
Versão do projeto (SemVer) | Manual no bump |
| CHANGELOG.md | Histórico legível de mudanças | Manual no bump |
Tag Git vX.Y.Z |
Snapshot imutável da release | Manual no bump |
src/main/resources/application.yml openapi.info.version |
Versão do contrato HTTP (/api/**) |
Manual só quando há breaking change na API REST |
Env SENTRY_RELEASE (Coolify) |
Tag de release nos eventos Sentry | Manual no Coolify ao fazer deploy |
Decisão de design: openapi.info.version é desacoplado do project.version.
A versão da API REST tem semântica própria — só sobe quando o contrato HTTP
quebra (endpoint removido, schema de body alterado de forma incompatível, etc.).
Mudanças internas do app (UI, refactor, login) não mexem nela.
Convenção de commits¶
Conventional Commits leve (já em uso no projeto):
feat: ...— nova funcionalidade → minor bumpfix: ...— correção de bug → patch bumpchore: ...,docs: ...,test: ...,refactor: ...— sem bump (a menos que acumule)feat!: ...ouBREAKING CHANGE:no corpo → major bump
Decisão do tipo de bump (SemVer)¶
Versão atual: 1.0.0. Regras SemVer canônicas (post-1.0):
| Mudança | Bump | Exemplo |
|---|---|---|
| Bug fix | patch | 1.0.0 → 1.0.1 |
| Feature compatível | minor | 1.0.0 → 1.1.0 |
| Breaking change (API REST quebrada, schema DB incompatível, env obrigatória nova) | major | 1.0.0 → 2.0.0 |
Processo de release (passo-a-passo)¶
Suponha que vai sair da 1.0.0 para 1.1.0 (minor bump com feat: ...).
1. Branch limpa¶
git switch master
git pull --ff-only
git status # tem que estar limpo
(Ou, se a release sai de uma feature branch antes do merge, faça o bump na branch e merge — o processo é o mesmo.)
2. Atualizar gradle.properties¶
version=1.1.0
3. Atualizar CHANGELOG.md¶
- Mover entradas de
[Unreleased]para uma nova seção[1.1.0] - YYYY-MM-DD(data de hoje). - Limpar
[Unreleased](deixar vazio para o próximo ciclo). - Atualizar os links de comparação no rodapé:
[Unreleased]: https://fonte.xadm.biz/xadm/integrador-server/compare/v1.1.0...HEAD [1.1.0]: https://fonte.xadm.biz/xadm/integrador-server/compare/v1.0.0...v1.1.0 [1.0.0]: https://fonte.xadm.biz/xadm/integrador-server/compare/v0.1.0...v1.0.0
4. Commit + tag¶
git add gradle.properties CHANGELOG.md
git commit -m "chore(release): v1.1.0"
git tag -a v1.1.0 -m "v1.1.0 - <descrição curta>"
git push origin master
git push origin v1.1.0
5. Atualizar Sentry release no Coolify (opcional)¶
No painel do Coolify, app de cada cliente:
- Atualizar a env SENTRY_RELEASE=v1.1.0 (ou integrador@1.1.0 se preferir).
- Redeploy.
Eventos no Sentry passam a ser tagueados com a versão — facilita correlacionar erros com release.
6. (Opcional) Atualizar openapi.info.version¶
Apenas se houve breaking change na API REST. Bump em separado, sem
sincronizar com project.version. Ver tabela no topo.
Como saber o que entrou desde a última release¶
git log --oneline v1.0.0..HEAD
Use o output para preencher o CHANGELOG. Convenção: agrupar por tipo (Added /
Changed / Fixed / Security) — ler o type: do conventional commit ajuda.
Como inspecionar a versão em runtime¶
A versão do projeto não vai automaticamente para o JAR (o artefato chama-se
app.jar sem versão). Hoje, para confirmar a versão de um deploy, use:
- Tag git do commit deployado (Coolify mostra o SHA do deploy →
git tag --points-at <sha>). - Env
SENTRY_RELEASEno painel do Coolify. - Log de startup do Sentry:
Sentry inicializado (environment=production)— o release aparece como tag nos eventos.
Futuro (opcional, não implementado): expor
project.versionvia um endpoint/versionou no log de startup do app. Adicionar quando virar dor.
Notas históricas¶
O bump de 0.1 → 1.0.0 (release de 2026-05-19) marcou a primeira release
com processo formal: introdução do login Google, CHANGELOG, tag git e doc
neste arquivo. Antes disso, o version="0.1" em build.gradle.kts era apenas
informativo e nunca foi incrementado.
Diagnóstico de testes flaky¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15
Sintoma: o ./gradlew check do job gate do pipeline.yml falha sem alteração de código, e um re-run
passa. O build instrumenta a suíte para que a falha diga qual teste caiu e por quê, e para que uma trava
vire falha em vez de segurar o job.
Verbosidade de teste (sempre ligada)¶
build.gradle.kts — tasks.withType<Test> configura testLogging com:
events("failed", "skipped")— cada teste falhado ou pulado vai para o stdout.exceptionFormat = FULL— stack trace inteiro, sem truncar.showCauses = true— mostra osCaused by:das cadeias deJdbcException.
Sem isso o Gradle só diz There were failing tests, e o detalhe fica no HTML report do runner.
Retry automático no CI (test-retry)¶
build.gradle.kts — plugin org.gradle.test-retry 1.5.10.
Ligado só no CI, detectado pela env CI, que o GitHub Actions define em todo job. Configuração:
maxRetries=2— cada teste falhado é re-executado até 2 vezes antes de virar falha real.maxFailures=5— se mais de 5 testes distintos falharem, a suíte aborta: não é flakiness, é bug ou regressão.failOnPassedAfterRetry=false— retry que passa deixa a suíte verde, mas o teste sai marcado comoflakyno HTML report e no console.
O retry não esconde o defeito: quando passa, o log do job lista o teste que precisou dele, e esse é o teste a investigar offline (timing, corrida no banco, porta). Quando esgota os retries, é bug real, com o stack trace completo no log.
Travas viram falha¶
| Onde | Teto | Cobre |
|---|---|---|
build.gradle.kts — tasks.test { timeout = Duration.ofMinutes(15) } |
15 min | a task inteira, inclusive a subida do contexto Micronaut |
src/test/resources/junit-platform.properties — junit.jupiter.execution.timeout.default = 2 m |
2 min | cada teste e cada método de ciclo de vida; desligado sob debug (disabled_on_debug) |
application-test.yml — datasources.default.connection-timeout: 5000 |
5 s | cada pedido de conexão ao pool: container morto falha em 5 s, não em 30 s por teste |
printTestSummary (resumo e testes lentos)¶
Task do build.gradle.kts, finalizedBy do test: roda sempre depois dele, inclusive quando falha. Lê
os XMLs JUnit em build/test-results/test/*.xml e imprime:
>>> Test summary: 565 passed, 0 failed, 2 skipped (total 567) in 20,6s
>>> Slow tests (>5s, top 10):
5475ms - br.com.xadm.comum.ApiBearerSecurityIntegrationTest#apiPost_withoutBearer_401()
>>> CI mode: retry habilitado (até 2 retries por teste). ...
Teste acima de 5 s entra na lista: é candidato a estourar prazo no runner, que tem menos CPU que a
máquina do dev. O tempo somado é a soma do tempo de cada teste, não o relógio de parede. A task lê os
arquivos no doLast sem capturar estado do script, e por isso é compatível com o configuration cache.
Postgres de teste fora do ar¶
Os @MicronautTest rodam contra o Postgres singleton da xadm-comum-teste
(Perfis Micronaut). Com o Docker fora do ar, toda classe
@MicronautTest falha com IllegalStateException: Postgres de teste (postgres:18-alpine) não subiu:
confira se o Docker está no ar e acessível ao Testcontainers., com a causa original anexada. Os testes
unitários passam no mesmo run. Não há serviço nem estado em disco a limpar entre runs: o container vive
o tempo da JVM de teste e o Ryuk do Testcontainers o recolhe, inclusive depois de interrupção.
Como investigar quando voltar a acontecer¶
- No log do job
gate, procure>>> Test summary:(quantos falharam), as linhasbr.com.xadm.X > metodoY FAILED(quais) eflaked/retry(o que o plugin reexecutou). - Se o mesmo teste aparece nos retries de vários runs, é flaky determinístico: reproduza local com
./gradlew test --tests "ClasseX.metodoY" --rerun-tasks, repetido até falhar. - Se o teste muda a cada run, é infraestrutura do runner (Docker, recurso esgotado), não o código.
Para desligar o retry temporariamente¶
Se o retry estiver mascarando algo que precisa ser investigado:
// build.gradle.kts, dentro de tasks.withType<Test>
retry {
if (false /* ciMode */) { // desativa manualmente
...
}
}
Localmente ele já fica desligado (sem a env CI).
GraalVM native-image — build e diagnóstico¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-21
Como o binário native do integrador se builda, onde mora a metadata e como se diagnostica um gap. Por que o integrador roda native e jar, a medição e a ordem da troca estão na decisão 0028; a compilação nasceu na 0020. A regra da casa é a norma de build native.
Quem builda¶
- Produção: o job
build_nativedo.github/workflows/pipeline.ymlbuilda oDockerfile.nativenum runner do GitHub e publicafonte.xadm.biz/xadm/integrador-server:native-amd64; o Coolify só puxa. O job roda quandonativeestá nobuild.targetsdodocs/app.json(ou no trailerDeploy:da tag). - O container não lê o
~/.m2: versão de lib da casa ainda não publicada no registro quebra o build doDockerfile.native. Local, onativeCompileresolve pelomavenLocal.
Comandos locais¶
export JAVA_HOME="C:/Dev/graalvm-jdk-25.0.3" # GraalVM 25, com native-image
./gradlew nativeCompile -PnativeQuick # -Ob: loop de diagnóstico (~5 min, pico de ~6 GB)
./gradlew nativeCompile # -Os: o perfil da release
# → build/native/nativeCompile/application(.exe)
docker build -f Dockerfile.native -t integrador-server:native . # o que o CI builda (Linux)
O bloco configure<GraalVMExtension> do build.gradle.kts fixa o nome application, -Ob/-Os,
--gc=serial, -H:IncludeLocales=pt-BR e -march=compatibility; -PnativeCI limita o builder no
runner. O nativeCompile roda com o configuration cache.
Onde mora a metadata¶
- Views JTE: geradas no build pela extensão
NativeResourcesExtensiondo plugin JTE. Nada à mão, e opipeline.ymlconfere a cobertura. - Sentry e
version.properties: vêm daxadm-comum-web. src/main/resources/META-INF/native-image/br.com.xadm/integrador/:reflect-config.json— os appenders do logback que o boot instancia por reflexão (ConsoleAppender,RollingFileAppender,TimeBasedRollingPolicy,PatternLayoutEncoder) e oio.sentry.logback.SentryAppender, cujos<minimumEventLevel>/<minimumBreadcrumbLevel>o Joran resolve por reflexão. Sem o appender do Sentry registrado, o native logaCould not find an appropriate class for property [...]no boot e ignora a config do appender — oSENTRY_ENVIRONMENTdologback.xmldeixa de valer (medido no central-backend).resource-config.json— duas entradas, e a segunda é do próprio SDK, não nossa:firebase-admin-sdk.json, lido porgetResourceAsStreamem runtime. Desde a decisão 0030 a credencial chega porPUSH_CREDENTIALS_PATH(secret file), e esta entrada fica por cobrir o caminho de teste.admin_sdk.properties, recurso na raiz dofirebase-admin-9.2.0.jar, lido no<clinit>decom.google.firebase.internal.SdkUtils(só para carimbar a versão do SDK no header HTTP). Sem ele o primeiroFirebaseMessaging.send()morre comExceptionInInitializerError(NullPointerException: Failed to load: admin_sdk.properties) e todos os seguintes comNoClassDefFoundError: Could not initialize class FirebaseMessagingClientImpl— a JVM marca a classe como erroneous e nunca retenta. Medido em produção na v1.7.2 native (GlitchTip 1299/1300, Sul Plata): push 100% morto, do boot ao restart. OFirebaseNativeResourceConfigTestguarda as duas entradas, porque na JVM elas vêm do jar sem configuração nenhuma — nenhum teste normal falha quando somem da imagem.
reflect-config.json— família@Keydo firebase-admin, toda comallDeclaredFields. O google-http-client serializa e parseia por reflexão (ClassInfolê os declared fields, inclusiveprivate final); sem entrada, o native poda o campo e o dado some sem erro algum. Na JVM nada falha, então nenhum teste normal pega — oFirebaseNativeReflectConfigTestguarda a lista. Medido em produção na v1.7.3 native (GlitchTip 1310, Sul Plata). São duas pernas:- request —
Message,Notification,AndroidConfig,AndroidNotification,AndroidFcmOptions,ApnsConfig,ApsAlert,ApnsFcmOptions,Webpush*,FcmOptions,LightSettings*: sem elas o corpo sai{"message":{}}semtopice o FCM responde 400 INVALID_ARGUMENT"Recipient of the message is not set". Assinatura do gap: obuild()valida o tópico no cliente, então um 400 de destinatário ausente depois de umbuild()ok é campo podado, não tópico vazio. (Aps/CriticalSound/WebpushNotificationestão na lista só por simetria — não têm@Key, serializam viagetFields()numMap.) - response —
internal/MessagingService{,Error}ResponseeAbstractPlatformErrorHandler$PlatformError{,Response}. Sem a 1ª,send()devolvemessageIdnulo até em sucesso; sem a 2ª,getMessagingErrorCode()volta nulo, oFcmPushServicenunca classifica o erro como transitório e o retry não acontece; sem as duas últimas, a mensagem do Google se perde e sobra o dump genéricoUnexpected HTTP response with status: NNN— foi essa assinatura no 1310 que denunciou a poda, porque na JVM a mensagem seria a curta (Recipient of the message is not set.).
- request —
- Locale pt-BR — são duas coisas, e as duas precisam existir:
- os dados do locale vêm do binário, por
-H:IncludeLocales=pt-BRnobuild.gradle.kts(a GraalVM embarca sóen). Quem formata comLocaleexplícito já funcionava sem mais nada; - o default locale da JVM vem do ambiente. A imagem do jar (
eclipse-temurin) trazLANG/LANGUAGE/LC_ALL; a base do native (debian:12-slim) não traz nada, então quem formata sem locale explícito caía no default do sistema. Por isso oDockerfile.nativegerapt_BR.UTF-8e exporta as três envs — paridade com o jar. Sem elas o desvio é silencioso: número, data e moeda mudam de forma sem erro nenhum.
- os dados do locale vêm do binário, por
- Corpo JSON lido para record: pelo
JsonMapperdo Micronaut Serde (@Serdeable), nunca peloObjectMapperdo Jackson, que lê por reflexão e o AOT poda.
Diagnosticar um gap de runtime¶
- O compile passou e o boot quebra: quase sempre é
MissingReflectionRegistrationErrorou recurso ausente, e a stack trace nomeia a classe ou o recurso. - Reflexão: acrescente a classe ao
reflect-config.json. Recurso lido por caminho montado em runtime: acrescente opatternaoresource-config.json. Valide o JSON depois de editar:\Q...\Eé regex-quote e precisa da barra DOBRADA no arquivo ("\\Qnome\\E"); com uma barra só,\Qnão é escape de JSON válido e o native-image descarta o arquivo inteiro — você perde também as entradas que já funcionavam, e o binário sai pior do que sairia sem a edição. Guarde com um teste que faça parse do JSON, nãocontains()no texto cru: ocontains()passa verde exatamente no arquivo quebrado (é o que oFirebaseNativeResourceConfigTestfaz). - Rota com 404 só no native: o AOT podou. Compare com o jar pela mesma checagem antes de concluir.
- Gap dentro de um
<clinit>de lib tem assinatura própria: umExceptionInInitializerErrorcom a causa real (a classe ou o recurso que faltou) e depois NNoClassDefFoundError: Could not initialize class Xsem causa nenhuma. O primeiro evento é o único que diz o que faltou — se o GlitchTip mostra sóNoClassDefFoundError, procure o evento anterior do mesmotrace_id. catch (Exception)não pega nada disso — os dois sãoLinkageError. CaptureLinkageError(nãoThrowable:OutOfMemoryErroreStackOverflowErrordevem subir, engoli-los esconde a JVM morrendo) em dois lugares, e os dois importam:- na borda fire-and-forget (thread virtual solta, listener de evento) — senão o gap vira evento
FATAL pelo
UncaughtExceptionHandler, um por tentativa, em vez de log tratado; - em toda compensação
try/catchno caminho, e é aqui que o estrago é silencioso. NoDeduplicatingPushServicea compensação inteira — liberar a reivindicação de dedup e gravar a linha de erro empush_enviada— estava atrás decatch (RuntimeException). OLinkageErrorpulou tudo: os pushes perdidos não deixaram traço nenhum na auditoria do próprio app, nem como sucesso nem como erro, e a reivindicação ficou pendurada os 10 min da janela, fazendo um retry saudável da mesma mensagem pela perna jar ser descartado como duplicado. Alargar só a captura de fora e não a compensação de dentro mantém a assimetria que cria o estado sujo.
- na borda fire-and-forget (thread virtual solta, listener de evento) — senão o gap vira evento
FATAL pelo
- Não chame
Sentry.captureExceptionjunto deLOG.error. OSentryAppenderdologback.xmlpublica a partir deERROR; os dois juntos criam duas issues por ocorrência. - Rebuild com
-PnativeQuicke repita o smoke.
Smoke local do binário¶
Sobe contra o Postgres de dev, com a auth em bypass:
docker compose -f docker-compose.dev.yml up -d --wait postgres
MICRONAUT_ENVIRONMENTS=dev ./build/native/nativeCompile/application
curl -s localhost:8080/health # {"status":"UP",…,"flavor":"native"}
Rotas-chave, conferidas contra o jar: as views de listagem, PUT /api/v1/xadm com um payload de
docs/anexos/exemplos-payload/, POST /api/v1/powersync, POST /api/v1/heartbeat (sem central,
responde 503 heartbeat_desligado), GET /export/xlsx/{table} (ex. municipio) e GET /api/health.
O envio FCM real não roda em dev (push.enabled=false); prova-se na instância piloto da troca.
Foi exatamente esse buraco que deixou a v1.7.2 native ir a produção com o push morto (GlitchTip
1299/1300): compile, boot, health e rotas passaram — o <clinit> do FirebaseMessagingClientImpl só
roda no primeiro send(). Um smoke de push contra o canal isolado do e2e
(PUSH_EMPRESA=test → tópicos bl_liberado_test/nota_venda_emitida_test, que produção não assina —
ver a constante EMPRESA_TEST do FcmPushService) é o único jeito de exercitar esse caminho antes do
deploy. O roteiro de push com app real está em push-fcm-testar.
pAbast — tabelas pabast_empresa e pabast_senhas¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06
Este documento descreve apenas o esquema das tabelas usadas no ecossistema pAbast / X-Adm, persistidas pelo integrador via Flyway.
API REST, telas web e login JWT foram removidos do integrador na Fase 1. Para o impacto operacional, migração de apps e PowerSync, veja decisão 0001 — Remoção do pAbast.
Migrações¶
| Arquivo | Conteúdo |
|---|---|
V12__pabast_empresa_senhas.sql |
Cria pabast_empresa, pabast_senhas e índice em id_empresa. |
V13__pabast_senhas_drop_fk_id_empresa.sql |
Remove FK id_empresa → pabast_empresa (espelho operacional). |
Modelo (resumo)¶
| Tabela | Chave / campos relevantes | Notas |
|---|---|---|
pabast_empresa |
id VARCHAR(4) |
Alinhado ao conceito de ChaveEmp no X-Adm. |
pabast_senhas |
id VARCHAR(7) |
Alinhado a ChaveUsu; id_empresa VARCHAR(4), sem FK após V13. |
Detalhes de colunas estão nas migrações SQL acima.
Importante¶
A solução foi pensada como ponte operacional entre X-Adm / Zim e o banco do integrador; evolução de cadastro, credenciais e políticas de acesso devem convergir para serviços dedicados, conforme o plano de migração.
Push FCM
FCM – Payload enviado pelo integrador (para o app Flutter)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06
O integrador roda em duas instâncias (SulPlata e OnPetro Trading). Cada instância usa push.empresa e envia nos 4 tópicos (2 por empresa). O payload inclui data.tipo e data.empresa para o app abrir na tela correta e exibir a origem (SulPlata / OnPetro).
Tópicos (4 no total)¶
| Empresa | Tópico | Uso |
|---|---|---|
| SulPlata | bl_liberado_sulplata |
BL liberado |
| SulPlata | nota_venda_emitida_sulplata |
Nota de venda emitida |
| OnPetro Trading | bl_liberado_onpetrotrading |
BL liberado |
| OnPetro Trading | nota_venda_emitida_onpetrotrading |
Nota de venda emitida |
- Cada instância do integrador configura
push.empresa:sulplataouonpetrotrading. - O serviço envia no tópico correspondente (ex.: instância SulPlata →
bl_liberado_sulplataenota_venda_emitida_sulplata).
O que o integrador envia em data¶
tipo(obrigatório):"bl_liberado"ou"nota_venda_emitida"— o app usa para decidir a tela (Saldo BL ou Vendas).empresa(obrigatório):"sulplata"ou"onpetrotrading"— identifica a empresa para o app exibir "SulPlata: ..." / "OnPetro: ..." e tratar corretamente.click_action:"FLUTTER_NOTIFICATION_CLICK"(recomendado para Android antigos).- Demais chaves do contexto:
request_id,origem,title,body, etc.
Título e corpo por tipo de push¶
- BL liberado: título e corpo montados a partir do BL, navio e dados do contrato.
- Nota de venda emitida (apenas quando a nota altera saldo do BL — venda ao cliente final):
- Título:
Venda · <volume> m³ · <cliente:nome_curto|nome>— ex.: "Venda · 2.500 m³ · Fazenda ABC" (separador: bolinha ·). - Corpo (body):
R$ <preço unitário> · <filial> · R$ <prêmio> · R$ <total>— ex.: "R$ 15,01 · Filial Teste · — · R$ 1.500,50". - Volume = soma dos Qtde dos itens da nota (m³); cliente de NotaCompl.chaveProp → Propriedades; filial de Propriedades.codMun → Municipio.nomeMun; preço unitário e total a partir dos itens. Prêmio = soma, por item, de (preço unitário do produto − preço Petrobrás por m³) × volume (m³); Preço Petrobrás por m³ =
vlProd(TbpcoEst, em R$/L) × 1000; registro na data da nota (dt_estentreval_inieval_fin). - Quando envia: só se (1) a ChaveNota indicar tipo saída (contém "R" — venda) e (2) a nota alterar o saldo do BL (lote vinculado a ItensPedAmx no banco). ChaveNota com "P" = entrada, não dispara push. Avalia apenas as letras P e R (o "0" eventual faz parte de outra parte da chave).
- Imagem: não é enviada (apenas texto).
Corpo da notificação (body)¶
O integrador acrescenta ao final do corpo um sufixo por empresa, para o app exibir a origem:
- SulPlata: o body termina com
[Sul Plata] - OnPetro Trading: o body termina com
[On Petro Trading]
Exemplo: "Nota de entrada do BL #123 Navio X emitida. [Sul Plata]"
Notification, Android e APNS¶
notification: apenastitleebody(já com o sufixo por empresa). Sem imagem/logo (apenas texto).- Android:
priority: "high". - APNS: header
apns-priority: "10".
Rotas no app (Flutter)¶
data.tipo |
Tela no app | Rota |
|---|---|---|
bl_liberado |
Saldo BL | /saldo-bl-2 |
nota_venda_emitida |
Vendas | /vendas-2 |
O app usa data.empresa para rotular e lógica interna; a rota é decidida por tipo.
Configuração por instância¶
- SulPlata (ex.: porta 3001):
push.empresa: "sulplata"(ou omitir; é o padrão). - OnPetro Trading (ex.: porta 3002):
push.empresa: "onpetrotrading".
Valores aceitos: exatamente sulplata | onpetrotrading (minúsculas).
Fluxos que disparam o push¶
- Real: PUT XADM com origem PBLDI ou PNOTAI →
PushContextBuildermonta título/corpo →FcmPushServiceenvia no tópico da instância (*_sulplataou*_onpetrotrading) comdata.tipo,data.empresa,data.click_action, prioridades e imagem. - Teste: tela
/debug(botões BL Liberado e Nota de Venda) → mesmo serviço, com título/corpo de exemplo; a instância já define a empresa e o tópico.
Disparo (desacoplado por evento)¶
O push não é chamado direto pelo XadmService. Após aplicar um PUT XADM com sucesso, o ingest publica um NotaAplicadaEvent (em core); o NotaAplicadaPushListener (em push) reage, decide por Origem (PBLDI/PNOTAI) e dispara o FCM em thread virtual (fire-and-forget). Isso quebra o acoplamento ingest↔push — ver estrutura de pacotes.
FCM: imagem (logotipo) na notificação – avaliação e implementação¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06
Avaliação do documento¶
O doc que descreve o uso do campo image na notificação FCM está correto e alinhado com a API FCM HTTP v1:
notification.image: URL da imagem usada em vários clientes.android.notification.imageUrl: no SDK Java do Firebase Admin é exposto comoAndroidNotification.setImage(imageUrl)dentro deAndroidConfig.apns.payload.aps.mutable-content+apns.fcm_options.image: necessário no iOS para a imagem expandida; no Java:Aps.builder().setMutableContent(true)eApnsFcmOptions.builder().setImage(imageUrl).
O integrador foi implementado seguindo esse desenho: quando push.notification-image-url está configurado, a mensagem inclui:
notification.imageandroid.notification.image(viaAndroidConfig+AndroidNotification)apns.aps.mutable-content = 1eapns.fcm_options.image
Formato da imagem: SVG vs PNG/JPEG¶
A documentação do FCM indica suporte a JPEG, PNG e BMP para a imagem da notificação. SVG não está na lista de formatos suportados e pode não ser exibido em Android/iOS.
- URL usada por padrão no integrador:
https://navarro-app.web.app/logo-sulplata.png(PNG, hospedado no Firebase Hosting).
Configuração no integrador¶
Em application.yml (ou por variável de ambiente):
push:
enabled: true
credentials-path: "C:/caminho/para/firebase-admin-sdk.json" # fora do repo (decisão 0030)
# URL pública da imagem (expandida) na notificação. Preferir PNG/JPEG.
notification-image-url: "https://navarro-app.web.app/logo-sulplata.png"
Se notification-image-url estiver vazio ou omitido, a notificação é enviada sem imagem (só título e corpo).
Resumo¶
| Item | Status |
|---|---|
| Doc (payload FCM com image) | Correto |
| Implementação no integrador | Feita: notification.image + Android + APNS |
| URL padrão | https://navarro-app.web.app/logo-sulplata.png (PNG) |
| Config | push.notification-image-url (opcional) |
Como testar o Push FCM (integrador + app Flutter)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06
Pré-requisitos¶
- Mesmo projeto Firebase no integrador e no app Flutter (o
firebase-admin-sdk.jsondo integrador e ogoogle-services.json/ Firebase do app apontam para o mesmo projeto). - App Flutter já com FCM configurado e inscrição nos 4 tópicos (
bl_liberado_sulplata,nota_venda_emitida_sulplata,bl_liberado_onpetrotrading,nota_venda_emitida_onpetrotrading) e permissão de notificação concedida no celular.
1. Integrador no PC local¶
-
Ativar o push
Emapplication.ymlou via variável de ambiente:Ou defina na linha de comando ao subir:push: enabled: true empresa: "sulplata" # ou "onpetrotrading" na instância OnPetro (ex.: porta 3002) credentials-path: "C:/caminho/para/firebase-admin-sdk.json" # fora do repo notification-image-url: "https://navarro-app.web.app/logo-sulplata.png"Ou crie./gradlew run --args="--push.enabled=true"application-local.yml(e useMICRONAUT_ENVIRONMENTS=local) compush.enabled: true. -
Credenciais
Apontepush.credentials-path(ou a envPUSH_CREDENTIALS_PATH) para o caminho absoluto do JSON da conta de serviço no seu PC. Não coloque o arquivo emsrc/main/resources/: a credencial é segredo, não entra no repo nem no jar (decisão 0030). Compush.enabled=truee a credencial ilegível, o app não sobe — a mensagem de erro nomeia o path. -
Subir o integrador
Ou rode pela IDE. O servidor sobe (por exemplo em./gradlew runhttp://localhost:8080). -
Teste rápido pelo navegador (opcional)
Acesse http://localhost:8080/debug e clique em: - Enviar push BL Liberado
- Enviar push Nota de Venda Emitida
Isso envia uma mensagem de teste para cada tópico. Se o app estiver inscrito e em primeiro plano/background, a notificação deve aparecer no celular.
2. App Flutter (release) no celular¶
- Build release e instale no aparelho (USB ou build de distribuição).
- Login / uso normal para garantir que o app obtém o FCM token e se inscreve nos tópicos
bl_liberadoenota_venda_emitida. - Permissão de notificação concedida para o app no Android/iOS.
- Celular pode estar em qualquer rede (4G/5G ou outro Wi‑Fi); não precisa estar na mesma rede do PC. O integrador envia para os servidores do FCM e eles entregam no dispositivo.
3. Formas de testar¶
A) Pela tela de teste do integrador (recomendado primeiro)¶
- No PC: http://localhost:8080/debug
- Clique em um dos dois botões.
- No celular: deve chegar a notificação (título “BL Liberado” ou “Nota de Venda Emitida”) se o app estiver inscrito nesse tópico.
B) Por um PUT XADM real (PBLDI ou PNOTAI)¶
- Envie um PUT para o endpoint XADM do integrador (
PUT /api/v1/xadmcom body JSON de origem PBLDI ou PNOTAI). - Se o processamento der certo, o integrador dispara o push para o tópico correspondente (BL Liberado ou Nota de Venda Emitida).
- O app no celular deve receber a notificação da mesma forma.
4. Se não chegar notificação no celular¶
- Logs do integrador: verifique se aparece algo como
Push FCM enviado tópico=... messageId=.... Se aparecer, o envio até o FCM está ok. - App: confirme no código Flutter que, após o login/token, está sendo chamado
subscribeToTopic("bl_liberado")esubscribeToTopic("nota_venda_emitida"). - Firebase Console: em “Cloud Messaging” (e testes de mensagem) dá para enviar uma mensagem de teste por tópico e ver se o dispositivo recebe; isso ajuda a isolar se o problema é integrador → FCM ou FCM → app.
- Desabilitar otimização de bateria para o app no Android pode evitar atraso ou bloqueio de notificações em segundo plano.
Resumo¶
| Onde | O que fazer |
|---|---|
| PC (integrador) | push.enabled: true, credenciais no classpath ou em credentials-path, ./gradlew run |
| Navegador | Abrir http://localhost:8080/debug e usar os dois botões de push |
| Celular (app release) | App com FCM + inscrição nos 4 tópicos (sulplata + onpetrotrading) + permissão de notificação |
| Teste | Primeiro pela tela /debug; depois, se quiser, por PUT XADM com PBLDI/PNOTAI |
Sim: você pode rodar o integrador no PC local com push.enabled=true e o app em release no celular; desde que o app esteja inscrito nos tópicos e use o mesmo projeto Firebase, o teste funciona.
Público
Integrador — documentação pública¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-16
O integrador é o servidor de integração da X-Adm: recebe os dados do ERP X-Adm, guarda-os no banco do cliente e os serve aos apps e às integrações que consomem esses dados.
Esta é a parte pública da documentação — o que outro sistema precisa para falar com ele:
- API REST — contrato público — endpoints, corpo das requisições, respostas e autenticação. Gerado do código a cada build, então não envelhece em relação ao que está no ar.
O restante da documentação do integrador — desenho, decisões e runbooks — é interno.
Contratos do integrador¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-10
O que o integrador combina com quem o usa, nas duas direções: a ingestão (como um sistema entrega dados ao barramento — o ERP X-Adm e os verticais, ex. a integração PIED) e o retorno (como o vertical do fluxo de entrada descobre o que o X-Adm fez com o que ele mandou). Mais o heartbeat do integrador-client, que passa por aqui a caminho do central-backend.
Esta página é a fonte desses contratos: cada seção abaixo é um arquivo-fato deste repo, publicado também cru para que outro repositório o importe no build em vez de copiá-lo. Quem integra deve linkar para cá ou importar o cru — re-digitar o contrato do outro lado é o que produz o exemplo que mente: a cópia nasce certa e envelhece calada, sem nada apontando que ela divergiu.
| Fato | Cru, para importar |
|---|---|
| Envelope, autenticação, resposta e origens | https://docs.xadm.biz/aplicacoes/integrador-server/raw/xadm-ingest.md |
Tabela ESTOQUE |
https://docs.xadm.biz/aplicacoes/integrador-server/raw/xadm-ingest-estoque.md |
| Retorno do X-Adm (leitura + re-armar) | https://docs.xadm.biz/aplicacoes/integrador-server/raw/xadm-retorno.md |
| Manifesto de remessa (consulta + fechar à mão) | https://docs.xadm.biz/aplicacoes/integrador-server/raw/integracao-remessa.md |
| Heartbeat do integrador-client | https://docs.xadm.biz/aplicacoes/integrador-server/raw/heartbeat.md |
A forma completa de cada rota — parâmetros, schemas, exemplos — é o OpenAPI gerado do código.
Envelope, autenticação e resposta¶
Endpoint e autenticação¶
O integrador tem um endpoint de ingestão, com dois verbos sobre o mesmo corpo:
| Verbo | Efeito |
|---|---|
PUT /api/v1/xadm |
upsert — cria ou atualiza; registro que existia deletado é revivido |
DELETE /api/v1/xadm |
soft delete dos registros identificados pelas chaves de negócio do corpo |
Toda rota /api/** exige Authorization: Bearer <token>. O token é estático e por
instalação (há uma instalação por cliente), entregue por canal seguro — sem ele a
resposta é 401.
Envelope¶
O corpo é um objeto JSON com o campo Origem (string) e uma chave por tabela, cada
uma com um array de objetos:
{
"Origem": "PNOTAI",
"ESTOQUE": [ { "ChaveEst": "...", "Saldo": 10 } ],
"MUNICIPIO": [ { "CodMun": "4314902", "Estado": "RS", "NomeMun": "Porto Alegre" } ]
}
As chaves de tabela reconhecidas são 15:
CONTRATOS · ITENSPED · ITENSPEDAMX · ITPEDAMXDI · LOTEEST · ESTOQUE ·
MUNICIPIO · PROPRIEDADES · FONES · VEICULOS · NOTACOMPL · ITENS ·
ITENSLOTE · TBPCOEST · FORMULAS
O nome da chave é case-sensitive: chave com caixa errada não casa tabela nenhuma e o payload é tratado como vazio (ver Resposta). Tabela ausente do corpo é ignorada — só se envia o que mudou.
Resposta¶
O status HTTP não diz se deu certo. Falha de processamento responde 200 com
resultado: "ERRO" — o contrato foi mantido assim por compatibilidade com o integrador
antigo. Quem consome tem de olhar o campo resultado, não o status HTTP; um cliente
que trate 200 como sucesso perde toda falha em silêncio.
{ "requestId": 1234, "resultado": "SUCESSO", "mensagem": null }
| Campo | Valor |
|---|---|
requestId |
id do registro Request criado para esta chamada (rastreia o processamento) |
resultado |
SUCESSO, ERRO ou PENDENTE |
mensagem |
null em sucesso; o detalhe do erro quando resultado é ERRO |
| HTTP | Quando |
|---|---|
200 |
o corpo era JSON válido — inclusive quando o processamento falhou (resultado: "ERRO") |
400 |
JSON inválido ou malformado; corpo {"requestId": null, "resultado": null, "mensagem": "JSON inválido: …"} |
401 |
Bearer ausente ou inválido |
500 |
falha antes do processamento (ex.: o registro da chamada não gravou). Erro de processamento não é 500 — é 200 com ERRO |
Payload que não casa nenhuma tabela conhecida responde 200 com resultado: "ERRO" e a
mensagem "payload sem nenhuma tabela reconhecida (verifique as chaves de array por
tabela)". É guarda deliberada contra o no-op silencioso: sem ela, chave com caixa errada
voltaria SUCESSO sem gravar nada.
Origem¶
Origem identifica a rotina do X-Adm que originou a carga. Ela é gravada e usada para
rastreio, nunca validada: origem desconhecida é aceita e processada normalmente.
O que a lista de origens conhecidas muda é só o roteamento de erro ao Sentry — falha
de processamento numa origem conhecida vira evento com as tags origem/tipo/request_id;
numa origem fora da lista, a falha não é reportada. Ou seja, origem errada não quebra a
chamada, mas apaga o alarme: o erro fica invisível.
Origens conhecidas hoje: PPEDAMX · PPEDIDO · PBLDI · PNOTAI · PNOTAD ·
PCSTPROD · PREQUIS · INT-PIED.
FORMULAS — fórmula (BOM) do kit (fluxo de entrada PIED)¶
Tabela de entrada (Origem: INT-PIED): a fórmula de um pedido-kit — um objeto por
componente do kit. O produtor manda só três campos de input (caixa exata):
| Campo | Tipo | Conteúdo |
|---|---|---|
xPed |
string(15) | código do pedido do kit (casa com o xPed de CONTRATOS/ITENSPED) |
CodProd |
string(6) | código do produto componente |
Qtde |
decimal | quantidade do componente consumida na fórmula |
{ "Origem": "INT-PIED",
"FORMULAS": [ { "xPed": "260069986", "CodProd": "22162", "Qtde": 10 },
{ "xPed": "260069986", "CodProd": "16442", "Qtde": 2 } ] }
Chave natural (xPed, CodProd). É fluxo de entrada de mão única para o dado: o X-Adm cadastra
e devolve só o status (CodRetorno/MsgRetorno) pelo write-back (006 = gravado) — nunca
devolve chave, então a tabela não carrega colunas Chave*. Reenvio idempotente preserva o status já
gravado. Sem DELETE (pedido pago é imutável).
Manifesto de remessa — a unidade de entrega ao ERP¶
O ingest não grava só as linhas: ele declara quais precisam chegar juntas ao ERP. Isso nasce na mesma transação do apply, replica pelo PowerSync junto com as próprias linhas, e é o que permite ao coletor perguntar "tenho tudo desta remessa?" em vez de deduzir dependência entre tabelas.
Por que existe. O ERP rejeita filho sem pai, em silêncio e de forma terminal (1XX), e o
coletor lê só 000/9XX — a linha rejeitada fica invisível para sempre. O ingest é atômico no
Postgres, mas essa atomicidade se perde na fronteira do PowerSync: linhas da mesma transação
chegam ao cliente em checkpoints diferentes. Medido em produção, três formatos do mesmo defeito:
| pai ausente | filho rejeitado | mensagem do ERP |
|---|---|---|
propriedades (cliente) |
contrato → item → fórmulas | 120-Cliente CPF … não cadastrado. |
contratos (pedido) |
item, fórmulas | 120-Pedido (…) não cadastrado. |
estoque (produto) |
item, fórmulas | 120-Produto para o pedido (…) não cadastrado. |
As duas tabelas.
| tabela | o que é |
|---|---|
integracao_remessa |
a unidade: origem, chave_negocio, status, total_itens |
integracao_remessa_item |
as linhas dela: tabela, linha_id, bloqueado, resolvido |
Quando nasce. Uma remessa por grupo com dependência (duas ou mais linhas ligadas) — não por
push. Linha solta (propriedades avulsa, estoque avulso) não gera remessa e segue coletável
como antes: o manifesto acrescenta garantia, nunca remove. chave_negocio é o xPed no grupo de
pedido e o cgc_cpf no grupo de cliente. Entra na remessa só o que ainda precisa chegar ao ERP
(000, 9XX ou nulo); 006 e 1XX ficam de fora.
Uma remessa VIVA por chave (ABERTA ou TRAVADA) — invariante de banco. As ENTREGUE e
RESOLVIDA_MANUAL acumulam como histórico. Um re-push reusa a viva, inclusive travada; é o que faz o
resgate fechar.
Os estados, derivados no servidor a partir dos cod_retorno — exceto o último, que nasce de
comando:
status |
significa |
|---|---|
ABERTA |
ainda não entregue, sem rejeição terminal |
ENTREGUE |
todas as linhas aceitas (006) — vira histórico |
TRAVADA |
alguma linha em 1XX; só o re-arme a reabre |
RESOLVIDA_MANUAL |
o operador lançou o pedido à mão no X-Adm e fechou a remessa por comando; terminal |
As duas flags por item, e por que existem:
bloqueado— um pai desta linha, na mesma remessa, está1XX. Enviá-la repetiria o dano (uma ausência virou nove linhas terminais no incidente). O consumidor lê a flag; não deduz dependência. É também o que distingue rejeição real de colateral: quem reporta ao operador deve nomear a mensagem do item não bloqueado.resolvido— nada mais a esperar por esta linha:006, soft-deletada ou órfã. Existe porque, do lado do cliente, linha deletada e linha que ainda não chegou são o mesmo estado observável (a colunadeletednão sincroniza e a linha some do banco local). Sem a flag, a remessa ficaria retida para sempre lá.
total_itens não é cache de COUNT(*). É o que deixa o consumidor detectar lista de itens
rasgada: ele conta o que recebeu e compara com o declarado. Sem isso concluiria "tenho tudo" sobre
uma lista truncada e liberaria cedo — o mesmo modo de falha do incidente, um nível acima.
Tudo o que é derivado sai de uma função só, chamada de quatro lugares: o apply (PUT), o
soft-delete (DELETE), o write-back do PowerSync e o re-arme. Uma fonte só é o que impede o drift. A
única exceção é RESOLVIDA_MANUAL, que a função não escreve nem reabre.
Consulta: GET /api/v1/integracao/remessas?origem=&chave=&status= — chave aceita repetição.
Nas TRAVADA vêm também os itens em 1XX com codRetorno, msgRetorno e bloqueado. A resposta
completa e o fechamento à mão (POST …/remessas/resolver-manual) estão no fato próprio,
integracao-remessa.md.
Re-arme: POST /api/v1/xadm/retorno/pedido/reenfileirar?chave= re-arma todos os itens 1XX
da remessa viva — qualquer que seja a tabela, inclusive o pai. Sem isso, remessa cujo 1XX é um
pai não sairia de TRAVADA por caminho nenhum. xPed segue valendo como alias.
A tabela ESTOQUE¶
Tabela ESTOQUE¶
Cada item do array ESTOQUE é um produto. Todos os campos são opcionais no envelope —
o que não vier não é escrito.
| Campo | Tipo | Nota |
|---|---|---|
ChaveEst |
texto | identidade do produto. É a chave de join do espelho |
codProd |
texto | identidade alternativa, usada onde ChaveEst chega vazio. Aceita CodProd (PascalCase) |
NomeProd |
texto | descrição. Aceita nomeProd |
EAN13 |
texto | código de barras. Aceita ean13 |
CodProdAlt |
texto | código alternativo do produto no X-Adm |
CodigoAlt |
texto | código do produto na PIED — rastreabilidade de origem externa, gravado em pied_codigo_alt. Não é identidade nem critério de join |
Venda |
decimal | preço de venda. Aceita venda |
VendaPz |
decimal | preço a prazo. Aceita vendaPz |
Saldo |
decimal | saldo em estoque. Aceita saldo |
Identidade. O produto é identificado por ChaveEst; onde ele chega vazio (o caso da
instância Thoms), a identidade é o codProd. A chave do parceiro (CodigoAlt) nunca
é identidade — entra prefixada no espelho (pied_codigo_alt) e serve só para rastrear de
onde o dado veio. Promover chave de parceiro a identidade de primeira classe acopla o
espelho ao vocabulário de um terceiro.
Write-back. CodRetorno e MsgRetorno não são lidos deste payload: são escritos
pelo X-Adm no sentido inverso. Mandá-los aqui não tem efeito.
Caixa dos campos. Onde a tabela diz "aceita", o parser lê as duas grafias; nos demais
campos a grafia é exata. Campo com caixa fora do previsto é ignorado em silêncio — o
registro é gravado sem ele, e a chamada volta SUCESSO.
Retorno do X-Adm: leitura e re-armar¶
Endpoint de leitura e autenticação¶
GET /api/v1/xadm/retorno/{tabela}?<chave natural>
Fecha o loop do fluxo de entrada: quem escreveu por PUT /api/v1/xadm pergunta aqui o
que o X-Adm fez com aquela linha. As colunas de retorno (CodRetorno, MsgRetorno,
Chave*) são do X-Adm — ele as grava pelo write-back, e este endpoint as lê.
{tabela} é case-insensitive e aceita cinco valores — as tabelas do fluxo de
entrada, não as 14 da ingestão:
{tabela} |
Chave natural obrigatória |
|---|---|
contratos |
xPed |
estoque |
codigoAlt |
itensped |
xPed e codigoAlt |
propriedades |
cgcCpf |
fones |
cgcCpf |
A chave é a mesma que se enviou na ingestão: o code do PIED (~9 chars), nunca o
id/ObjectId. Chave de outra tabela vai junto? É ignorada — só a obrigatória daquela
tabela conta, e se ela faltar (ou vier branca) a resposta é 400.
?origem= é aceito e ignorado: não filtra nada (a chave natural já é única). Existe
por conveniência de quem chama.
Toda rota /api/** exige Authorization: Bearer <token> — o mesmo token estático por
instalação da ingestão. Sem ele, 401.
O GET é read-only: não grava auditoria em request (é poll de alta frequência —
divergência consciente do GET /api/health, que audita). A única escrita deste contrato é o
POST .../pedido/reenfileirar, mais abaixo.
Resposta da leitura¶
Linha ausente não é 404. Consulta válida responde 200 sempre — inclusive
quando não há linha alguma. Quem consome decide pelo campo status, não pelo status HTTP:
poll que trate 404 como "ainda não chegou" nunca vai vê-lo.
{ "tabela": "fones", "status": "CONFIRMADO", "codRetorno": "006", "chaveXadm": "FONE-1" }
| Campo | Valor |
|---|---|
tabela |
a tabela consultada, em minúsculo (sempre presente) |
status |
NAO_ENCONTRADO, PENDENTE, CONFIRMADO ou REJEITADO (sempre presente) |
codRetorno |
o CodRetorno cru do X-Adm, sem o padding do CHAR |
msgRetorno |
a MsgRetorno crua do X-Adm |
chaveXadm |
a Chave* que o X-Adm gerou (ChaveCP/ChaveEst/ChaveItem/ChaveProp/ChaveFone) |
Os três campos crus somem do corpo quando não têm valor — não vêm como null. No
exemplo acima não há msgRetorno porque o X-Adm não gravou mensagem; em PENDENTE e
NAO_ENCONTRADO os três somem juntos. Cliente que exija os cinco campos quebra no caso
mais comum do poll.
| HTTP | Quando |
|---|---|
200 |
consulta válida — inclusive sem linha (NAO_ENCONTRADO) ou sem resposta do X-Adm (PENDENTE) |
400 |
{tabela} fora das cinco, ou chave natural obrigatória daquela tabela ausente/branca |
401 |
Bearer ausente ou inválido |
Como o status é derivado¶
O status é do integrador (derivado); o codRetorno é do X-Adm (cru). Os dois voltam
porque o derivado perde informação — veja REJEITADO.
status |
Quando | O que fazer |
|---|---|---|
NAO_ENCONTRADO |
nenhuma linha viva com essa chave natural | reenviar a linha |
PENDENTE |
a linha existe, mas o X-Adm ainda não respondeu — codRetorno branco ou 000/00X (≠006) |
esperar e repolar |
CONFIRMADO |
codRetorno = 006 — gravado no X-Adm |
reconciliar e parar de polar |
REJEITADO |
codRetorno não começa com 0 — 1XX (erro terminal) ou 9XX (erro de processamento) |
olhar o codRetorno cru: 1XX não adianta reenviar; 9XX é reprocessável |
NAO_ENCONTRADO é o único status ambíguo: linha soft-deleted responde igual a linha
que nunca chegou. Quem apagou sabe que apagou — mas não espere o endpoint distinguir.
Os códigos são do X-Adm (doc-mãe MAXSUL2025_610110), não deste repo: um código novo que
não comece com 0 cai em REJEITADO por padrão, e é o codRetorno cru que conta a
história.
Re-armar as linhas 1XX de um pedido¶
POST /api/v1/xadm/retorno/pedido/reenfileirar?xPed=<code do pedido no PIED>
A operação simétrica da leitura, e a única escrita deste contrato: devolve a 000 (e
apaga a MsgRetorno) as linhas do pedido que morreram em erro terminal — cod_retorno
começando com 1. É o que faz o integrador-client voltar a coletá-las.
Corpo vazio, comando na query string. A rota não lê corpo — aceita qualquer
Content-Type (inclusive nenhum, e text/plain, que é o que o int-pied manda). Não
responde 415.
Por que ela precisa existir. O upsert do PUT /api/v1/xadm preserva o retorno de
propósito (mão única): sem isso, todo re-push de rotina apagaria o retorno de um pedido já
gravado no ERP e o cliente o mandaria de novo — pedido duplicado. Consequência: reimportar
o pedido no painel do PIED nunca destrava um 1XX, por mais vezes que se clique. Este
endpoint é a exceção sob comando, para depois que o operador corrigiu o dado na origem.
Desde o manifesto de remessa, o re-arme é POR REMESSA, não por tabela. Ele alcança
todos os itens 1XX da remessa viva daquela chave — as seis tabelas do fluxo de entrada,
inclusive o pai (propriedades, fones, estoque).
Antes cobria só contratos/itensped/formulas, herança de quando a única chave era o
pied_x_ped. Como a remessa passou a declarar a cadeia inteira, isso virou um beco: remessa
cujo 1XX era um pai não saía de TRAVADA por caminho nenhum — o botão Reimportar
chamava um endpoint incapaz de resolver. Era o caso do 260079421, onde o pai que faltou foi
uma propriedades.
| parâmetro | efeito |
|---|---|
chave |
a chave da remessa: pied_x_ped do pedido ou cgc_cpf do cliente |
xPed |
alias histórico de chave — segue valendo |
Remessa de cliente (cadastro com contato, chaveada por cgc_cpf) também é re-armável por
aqui. Antes ela não tinha caminho de recuperação nenhum.
Pedido sem remessa viva — legado anterior ao backfill — continua re-armado pelo caminho
antigo: por pied_x_ped, nas três tabelas chaveadas por pedido. Some quando o backfill roda.
Linha de pedido resolvido à mão não volta. Mesmo com remessa viva, a linha de contratos/
itensped/formulas que também é item de uma remessa RESOLVIDA_MANUAL não é re-armada: o pedido
já foi lançado direto no X-Adm. O pai (propriedades/fones/estoque) segue re-armável — é
compartilhado entre pedidos.
Linha soft-deletada também não entra: a contagem é o que o operador lê no painel, e ela não pode incluir linha que, para ele, não existe mais.
O que NÃO é tocado — é o que torna a operação segura:
cod_retorno |
Por quê |
|---|---|
006 |
já gravado no ERP; re-armar faria o cliente reenviar cadastro que o ERP já tem — duplicaria |
9XX |
erro de processamento, já retenta sozinho |
000/00X |
já pendente; não há o que re-armar |
O efeito é uma transação só para as três tabelas: o PowerSync replica no commit, então o cliente nunca vê as três em estado intermediário.
Quem chama: o botão Reimportar do painel do int-pied, sempre depois do push do
pedido — a linha só volta a ser coletável já com o dado corrigido. O curl do runbook é o
mesmo comando na mão, para diagnóstico.
Uma ressalva honesta: um write-back do integrador-client em voo pode recarimbar 1XX logo
depois do re-armar. Janela estreita, e a operação é idempotente — o operador vê a contagem e
repete. Não há trava contra isso, de propósito: cobrir esse caminho serializaria o write-back
de todos os clientes.
Resposta do re-armar¶
{ "xPed": "260079421", "contratos": 1, "itensped": 3, "formulas": 2, "total": 6 }
| Campo | Valor |
|---|---|
xPed |
a chave recebida, sem espaços nas pontas |
contratos, itensped, formulas |
linhas re-armadas em cada tabela |
total |
a soma — o número que interessa ao operador |
| HTTP | Quando |
|---|---|
200 |
operação válida — inclusive pedido inexistente ou sem nada em erro (tudo zero) |
400 |
xPed ausente ou branco |
401 |
Bearer ausente ou inválido |
409 |
a remessa da chave foi resolvida à mão (RESOLVIDA_MANUAL) |
Pedido resolvido à mão não se re-arma. Se o operador lançou o pedido direto no X-Adm e fechou a
remessa pelo resolver-manual (ver o fato integracao-remessa.md), re-armar reenviaria ao ERP o que
já foi lançado. O pedido duplicaria. Por isso o 409, e não o fallback legado por pied_x_ped.
Zero não é erro. Pedido que não existe e pedido cujas linhas estão todas em 006
respondem igual: 200 com as contagens zeradas. Quem chama mostra a contagem ao operador —
total: 0 significa "não havia nada preso", não "falhou".
A operação é idempotente por natureza: chamar duas vezes seguidas devolve total: 0 na
segunda, porque a primeira já tirou as linhas do 1XX.
Manifesto de remessa: consulta e fechar à mão¶
Consultar remessas¶
GET /api/v1/integracao/remessas?origem=<origem>[&chave=<chave>]...[&status=<status>]
Fora do namespace /xadm de propósito: remessa é dado de controle do Integrador, não espelho do
ERP. Toda rota /api/** exige Authorization: Bearer <token>; sem ele, 401.
| parâmetro | efeito |
|---|---|
origem |
obrigatória — hoje só INT-PIED tem manifesto |
chave |
pied_x_ped do pedido ou cgc_cpf do cliente. Aceita repetição (&chave=A&chave=B) — uma chamada por rodada, não uma por pedido |
status |
filtra por ABERTA, ENTREGUE, TRAVADA ou RESOLVIDA_MANUAL |
Chave inexistente não é 404: é 200 com lista vazia. Quem consome decide pelo conteúdo.
[
{ "id": "0199…", "chaveNegocio": "260079421", "status": "TRAVADA", "totalItens": 5,
"criadoEm": "2026-09-08T14:02:11Z",
"itensRejeitados": [
{ "tabela": "propriedades", "linhaId": "0199…", "codRetorno": "120",
"msgRetorno": "Cliente CPF 072.508.149-07 não cadastrado.", "bloqueado": false },
{ "tabela": "contratos", "linhaId": "0199…", "codRetorno": "120",
"msgRetorno": "Pedido (10738.24) não cadastrado.", "bloqueado": true } ] },
{ "id": "0199…", "chaveNegocio": "260081234", "status": "RESOLVIDA_MANUAL", "totalItens": 4,
"criadoEm": "2026-09-09T10:40:02Z", "itensRejeitados": [],
"resolucaoManual": { "em": "2026-09-10T16:20:00Z",
"observacao": "Lançado manualmente no X-Adm: kit sem produto cadastrado.",
"operador": "fulano@maxsul.com.br" } }
]
| campo | valor |
|---|---|
id |
id da remessa |
chaveNegocio |
pied_x_ped ou cgc_cpf |
status |
ver a tabela de estados abaixo |
totalItens |
quantos itens a remessa declara — a coluna, não um COUNT(*); é o que detecta lista rasgada |
criadoEm |
quando a remessa nasceu |
itensRejeitados |
os itens em 1XX, só nas TRAVADA. Nas demais vem [] — nunca ausente |
resolucaoManual |
só nas RESOLVIDA_MANUAL: em, observacao e operador (este some quando não foi informado). Nas demais, o campo some |
Em cada item rejeitado vêm tabela, linhaId, codRetorno, msgRetorno e bloqueado. O
bloqueado separa a causa do colateral: true quer dizer que um pai desta linha, na mesma
remessa, também está em 1XX. Quem reporta ao operador nomeia a mensagem do item não
bloqueado. No exemplo, a causa é o cliente; o "Pedido não cadastrado" é consequência.
Os estados¶
status |
significa | viva? |
|---|---|---|
ABERTA |
ainda não entregue, sem rejeição terminal | sim |
TRAVADA |
alguma linha em 1XX; só o re-arme a reabre |
sim |
ENTREGUE |
todas as linhas aceitas (006) — histórico |
não |
RESOLVIDA_MANUAL |
o operador lançou o pedido à mão no X-Adm e fechou a remessa por comando — histórico | não |
Uma remessa viva por (origem, chave): é invariante de banco. As não vivas acumulam. Os três
primeiros estados são derivados dos cod_retorno. RESOLVIDA_MANUAL é o único que nasce de
comando e que a derivação nunca reabre.
Um consumidor que encontre um status que não conhece deve tratá-lo como não travado e não enviável. É o que mantém a adição de um estado não quebrante.
Fechar à mão uma remessa travada¶
POST /api/v1/integracao/remessas/resolver-manual?origem=INT-PIED&chave=<xPed>
Content-Type: application/json
{ "observacao": "Lançado manualmente no X-Adm: kit sem produto cadastrado.",
"operador": "fulano@maxsul.com.br" }
Serve para o pedido que não se resolve reenviando (ex.: o produto-kit que o ERP não criou) e que o
operador lançou direto no X-Adm. Sem isto, a remessa seguiria TRAVADA para sempre. Quem chama é o
botão "Importado manualmente" do painel do maxsul-pied.
| corpo | regra |
|---|---|
observacao |
obrigatória, até 2000 caracteres — é o que sobra para reconstruir o caso |
operador |
opcional, até 100 caracteres. O Bearer é estático e não identifica pessoa |
{ "chave": "260079421", "resolvidas": 1 }
| HTTP | quando |
|---|---|
200 |
fechou (resolvidas: 1), ou não havia remessa viva (resolvidas: 0: nunca existiu, já entregue ou já resolvida) |
400 |
origem ausente ou diferente de INT-PIED; chave ausente; observacao ausente, branca ou longa demais; operador longo demais |
401 |
Bearer ausente ou inválido |
409 |
a remessa viva está ABERTA (ainda em entrega), está TRAVADA com linha ainda pendente (000/001/9XX), ou mudou de estado durante o comando — consulte e tente de novo |
Só TRAVADA. Fechar uma ABERTA enquanto o cliente pode estar mandando as linhas arrisca
duplicar no ERP.
E sem linha pendente. Numa TRAVADA o filho de um pai 1XX fica 000, bloqueado. Fechar a
remessa a tira do manifesto, e o cliente passaria a mandar esse filho por presença — duplicando no
ERP o que o operador lançou à mão. Nesse caso o caminho é corrigir o dado e re-armar.
Não mexe nas linhas. As 1XX continuam 1XX e o cliente não as coleta. Muda só a remessa.
Idempotente: a segunda chamada devolve resolvidas: 0, porque a remessa já não está viva.
Depois dele, o re-arme da chave responde 409. Re-armar reenviaria ao ERP o que o operador já
lançou. E se a chave ganhar uma remessa viva nova, o re-arme dela não reabre as linhas de pedido
(contratos/itensped/formulas) que também são itens da resolvida.
Heartbeat do integrador-client¶
Heartbeat: o integrador-client avisa que está vivo¶
POST /api/v1/heartbeat
Cada instalação do integrador-client manda um heartbeat no startup e a cada 60 min (versão, fluxos
ativos, hostname). Este integrador-server carimba o cliente_id da instância (env CLIENTE_ID) e
repassa ao central-backend em POST {CENTRAL_URL}/api/integrador/heartbeat, com o Bearer
CENTRAL_API_TOKEN. O que o central grava, o alerta de silêncio e a lista de instalações são do
central: contrato do repasse na
API REST do central-backend e o
porquê na decisão local 0028 do central.
- URL: esquema + host + porta de
powersync.upload.url, com o path/api/v1/heartbeat; ouheartbeat.urlquando definida. - Headers:
Authorization: Bearer <powersync.upload.token>(é oINTEGRADOR_API_TOKENda instância) eContent-Type: application/json.
Amostra canônica A:
{"versao":"0.2.0","fluxos":["pied"],"hostname":"SRV-ERP01","motivo":"inicio","iniciado_em":"2026-09-10T08:00:03-03:00"}
| Campo | Tipo no fio | Regra (validada pelo integrador-server) |
|---|---|---|
versao |
string | obrigatório, 1–40 caracteres |
fluxos |
array de string | sempre enviado pelo client; ≤ 20 itens, cada um ^[a-z0-9-]{1,50}$ (os nomes de Fluxo: pied, abastecimento, encerra-mdfe) |
hostname |
string ou null |
≤ 255; null quando o client não consegue resolver |
motivo |
string | inicio | periodico |
iniciado_em |
string | DateTimeFormatter.ISO_OFFSET_DATE_TIME sobre um OffsetDateTime truncado a segundos, no offset local da máquina; offset zero sai Z (ex. no CI em UTC). Sempre com segundos (o toString() do Java os omite quando são zero, por isso não serve). Não validado pelo integrador-server (repassa opaco) |
Respostas (corpo problem+json; o client só lê o status):
| Status | code |
Quando |
|---|---|---|
204 |
— | central aceitou |
400 |
BAD_REQUEST |
viola a tabela acima (versao, hostname, motivo, fluxos) |
401 |
— | Bearer ausente ou errado (ApiBearerSecurityRule) |
502 |
heartbeat_recusado |
central devolveu 4xx (inclusive corpo que não é problem+json) |
502 |
central_indisponivel |
central 5xx, timeout ou rede |
503 |
heartbeat_desligado |
CENTRAL_API_TOKEN ou CLIENTE_ID vazio no integrador-server |
Repasse sem transformação. versao, fluxos, hostname, motivo e iniciado_em seguem ao
central como vieram (iniciado_em é string opaca — quem a valida é o central); só entra o
cliente_id. Campo ausente vai como null, fluxos ausente vai como [], e campo que este contrato
não conhece é ignorado e não é repassado — é o que deixa o client ganhar campo sem quebrar o server.
API REST — contrato público¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-16
Esta é a referência pública da API do integrador: endpoints, corpo das requisições, respostas.
O spec é gerado do código a cada build de docs (o micronaut-openapi emite o OpenAPI a
partir dos controllers ao compilar) — é a fonte da verdade sobre o que este app expõe. Para
experimentar ao vivo contra uma instância, o app também serve /swagger-ui em runtime; a
fonte publicada é esta página.
Quem integra com o integrador linka esta página. Re-digitar o contrato do outro lado é o que produz o exemplo que mente: a cópia nasce certa e envelhece calada.
Como autenticar¶
As rotas /api/** exigem Authorization: Bearer <token>. O token é acordado por instalação
(há uma por cliente) e entregue por canal seguro — nunca publicado aqui.
O contrato¶
Decisões
Fase 1 — Remoção de pAbast no integrador (API e auth)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06 · Decidido em: 2026-07-06
Este documento descreve o que foi removido do JAR do integrador na Fase 1 da migração, mantendo apenas as tabelas PostgreSQL pabast_empresa e pabast_senhas (sem migrações DROP TABLE).
Objetivo¶
- Retirar da aplicação UI, REST, domínio, repositórios e serviços ligados ao fluxo pAbast.
- Retirar login JWT, JWKS e dependência Nimbus JOSE deste repositório, para que autenticação e assinatura de tokens passem a um serviço de auth dedicado (planejado nas fases seguintes).
- Manter os dados nas tabelas existentes para sincronização (PowerSync) e consumo futuro por outros serviços ou apps.
O que foi removido (código)¶
- Controllers MVC e API para empresa/senhas pAbast e rotas de autenticação (
/pabast/*,/api/pabast/*,/api/auth/pabast/login,/.well-known/jwks.json,/api/auth/jwks). - Entidades JPA, repositórios e serviços associados.
- Configuração
pabast-auth:emapplication.yml(histórico),application-test.ymle exemplos emprod/*/app/application.yaml. - Dependência Gradle
com.nimbusds:nimbus-jose-jwte a taskgenPabastTestKeys.
O que permanece (banco)¶
- Migrações Flyway inalteradas (incluindo
V12__pabast_empresa_senhas.sqleV13__pabast_senhas_drop_fk_id_empresa.sql). - Tabelas
pabast_empresaepabast_senhascontinuam no PostgreSQL; o conteúdo não é apagado por esta fase.
Resumo do modelo de dados: docs/pabast-pAbast.md.
Impacto em clientes (PowerSync / apps)¶
PowerSync (powersync/*/config.yaml)¶
Os arquivos Vantroba e On Petro ainda declaram client_auth.jwks_uri apontando para o host do integrador (ex.: http://172.17.0.1:3003/.well-known/jwks.json). Esse endpoint não existe mais neste JAR.
Ação necessária: apontar client_auth.jwks_uri para o JWKS do serviço de auth que passar a emitir JWT com audience compatível (vantroba, onpetro, etc.), e garantir que os apps obtenham o token desse serviço (não do integrador).
Até essa migração de infraestrutura, o PowerSync pode falhar na validação de cliente se depender exclusivamente do JWKS antigo.
Apps Flutter / clientes HTTP¶
Qualquer fluxo que chamava POST /api/auth/pabast/login ou lia JWKS do integrador deve ser atualizado para o novo provedor de identidade e URLs documentadas nesse serviço.
Documentos legados que descreviam o fluxo antigo foram reduzidos a avisos com link para este arquivo:
- pabast-auth-pem-secrets.md (histórico)
- flutter-pabast-login-powersync.md (histórico)
Testes automatizados¶
O projeto inclui testes de integração que verificam que as rotas removidas não são atendidas pelo integrador (resposta HTTP consistente com rota inexistente, tipicamente 404).
Referências¶
- Plano de migração (workspace): ver ficheiro de plano associado à migração integrador v2.
- Fase 2 (CORS + confirmação JWT removido): migration-phase2-cors-jwt.md.
- Operação PowerSync geral: docs/servidor/powersync.md.
Fase 2 — JWT removido do integrador e revisão de CORS¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06 · Decidido em: 2026-07-06
Esta fase do plano de migração “integrador v2” (ficheiro de plano no Cursor / documentação interna do time) tem dois eixos: confirmar que não há geração de JWT neste JAR (entregue na Fase 1) e alinhar CORS para clientes browser / Flutter Web e para a Fase 6 (Authorization: Bearer).
1. JWT / Nimbus / pabast-auth¶
A remoção de código e dependências foi feita na Fase 1. Resumo e impacto em clientes: pabast-removal-phase1.md.
Checklist de verificação (Fase 2)
| Item | Estado esperado |
|---|---|
Sem com.nimbusds:nimbus-jose-jwt no build.gradle.kts |
Sim |
Sem bloco pabast-auth: nos YAMLs da aplicação |
Sim |
Rotas /api/auth/pabast/login, /.well-known/jwks.json, /api/auth/jwks inexistentes |
PabastEndpointsRemovedIntegrationTest |
2. CORS após remoção do login¶
Comentários em prod/vantroba e prod/onpetro deixaram de citar apenas o login HTTP; o CORS continua necessário para chamadas cross-origin (Flutter Web, ferramentas no browser) e, na Fase 6, para o header Authorization nas rotas da API.
Alterações no application.yml base¶
micronaut.server.dispatch-options-requests: true— encaminhaOPTIONSpara o pipeline (preflight no browser).micronaut.server.cors.localhost-pass-through: true— mitigação “drive-by” do Micronaut quando o servidor resolve como localhost e a política de origens usa padrões amplos; avaliar em produção com hostname real / proxy.- Configuração nomeada
defaultcom: allowed-origins-regex: ".*"— em Micronaut, uma listaallowed-origins: ["*"]não é o wildcard interno (CorsOriginConfiguration.ANY); origens reais não casam. Regex explícito ou lista de origens concretas evita esse erro.allow-credentials: false— coerente com política ampla de origens (evita combinação inválida no CORS).allowed-methodseallowed-headers(incluindo*) para preflight com vários headers (Fase 6).
Exemplos em prod/*/app/application.yaml¶
Os ficheiros Vantroba / On Petro ainda usam allowed-origins: ["*"]. Se no ambiente real os cabeçalhos CORS não aparecerem para origens concretas, alinhar ao application.yml base (regex ou lista explícita de origens).
3. Testes automatizados¶
CorsPreflightProbeController— rota só emsrc/testpara exercitar CORS sem depender da API v1.CorsPreflightIntegrationTest—GET /_test/cors-probecom headerOrigine verificação deAccess-Control-Allow-Origin. Não usamos um teste de preflightOPTIONSisolado: oCorsFilterdo Micronaut valida preflight contra o router (CorsFilter#validatePreflightRequest) e pode responder 403 em cenários sem correspondência explícita; o fluxo “simples” (GET +Origin) cobre o comportamento relevante após o preflight no browser.
Referências¶
- Fase 1: pabast-removal-phase1.md
- Fase 6 (Bearer estático em
/api/**+/health): migration-phase6-seguranca.md · roadmap migration-roadmap-phases-6-9.md - Micronaut CORS: guia oficial
Fase 3 — UUID v7 (PostgreSQL 18+)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15 · Decidido em: 2026-07-06
Objetivo: migrar identificadores de UUID v4 (gen_random_uuid() no id) para UUID v7 (ordenável no tempo, RFC 9562), com baixo risco e janelas de migração controladas.
Por que esta ordem (recomendada)¶
- Flyway (este repositório) adiciona
id_v7 UUIDnullable em todas as tabelas com PKid UUID, sem alterarid, PKs nem FKs. - Job / ferramenta banco → banco (fora deste JAR) preenche
id_v7com a estratégia de negócio (ex.: derivar timestamp de colunas existentes, mapeamento 1:1 doidantigo para um v7 gerado de forma reproduzível, etc.). - Migração Flyway V15 (passo 3): após dados estáveis e backfill, renomear a coluna
id(v4) paraold_id(deixa de ser PK; mantém índiceidx_*_old_id), renomearid_v7→id(nova PK comDEFAULT uuidv7()). O UUID legado permanece consultável emold_id(ex.: PowerSync resolve poridouold_id). - Migração Flyway V16 (passo 4): a V15 deixa
old_idNOT NULL (herdado da antiga PK); a V16 fazALTER COLUMN old_id DROP NOT NULLnas tabelas de domínio, para permitir novos inserts apenas comidv7 (sem UUID legado emold_id).
Assim o schema permanece válido entre os passos; rollback é mais simples (colunas extras vs. troca de PK no meio do caminho).
Escopo das tabelas (id UUID)¶
| Tabela | Notas |
|---|---|
contratos, estoque, itens, itenslote, itensped, itenspedamx, itpedamxdi, loteest, municipio, notacompl, propriedades, tbpcoest, veiculos |
PK id UUID desde V1+ |
Fora deste passo: request e push_enviada usam BIGINT identity; pabast_* usam VARCHAR como PK — não recebem id_v7 nesta fase.
Migração Flyway¶
V14__uuid_v7_add_id_v7_columns.sql(emdb/migration):ALTER TABLE ... ADD COLUMN IF NOT EXISTS id_v7 UUID+ comentários.V15__uuid_v7_id_old_id_swap.sql(emdb/migration): troca de PK conforme acima; cópia de referência em docs/dev/sql/V15__uuid_v7_swap_id_rename_to_id.sql.V16__old_id_nullable.sql(emdb/migration):old_idpassa a aceitar NULL; cópia em docs/dev/sql/V16__old_id_nullable.sql.
Ordem operacional e deploy: runbook-migration-uuid-v7.md.
Não define DEFAULT uuidv7() na V14: enquanto o backfill não estiver completo, defaults automáticos poderiam gerar v7 “novos” por linha de forma indesejada; a V15 fixa DEFAULT uuidv7() após o rename.
Runbook (operacional)¶
Passo a passo: backup, V14, sincronização de dados, V15, deploy — runbook-migration-uuid-v7.md.
PostgreSQL 18 e uuidv7()¶
A função uuidv7() está disponível no PostgreSQL 18+. Use essa versão (ou superior) no servidor e nos testes de integração que validam uuidv7().
Migrações que apenas adicionam coluna UUID sem chamar uuidv7() no SQL rodam em versões anteriores; qualquer migration futura que use uuidv7() no DDL exige PG 18+.
Testes¶
O projeto inclui teste de integração com Testcontainers (postgres:18) que aplica todo o histórico Flyway e verifica:
- com Flyway até V14: presença de
id_v7nas tabelas listadas; com histórico completo (V15–V16): colunasideold_idem tabelas de domínio, semid_v7, eold_idanulável (V16); SELECT uuidv7()executa com sucesso.
Requisito: Docker disponível na máquina / CI ao rodar esses testes.
Para ignorar o teste Testcontainers (ex.: ambiente sem daemon Docker): variável de ambiente SKIP_DOCKER_TESTS=true.
Correção 2026-09-15: o
SKIP_DOCKER_TESTSnão é lido por nenhum teste. O teste da linha do tempo de migration (Postgres18UuidV7FlywayIntegrationTest) sobe um container dedicado e leva o@IntegracaoDockerdaxadm-comum-teste: roda por padrão e só pula com o Docker ausente, com o motivo emERRORno log e no relatório do JUnit (micronaut-profiles).
Referências¶
- Plano de migração “integrador v2” (workspace).
- Fases anteriores: migration-phase2-cors-jwt.md, pabast-removal-phase1.md.
- Deploy (Coolify, subdomínios, porta 8080): migration-phase4-coolify.md.
- PowerSync (compose, volumes, restart seguro): migration-phase5-powersync-runbook.md.
- Segurança API / roadmap (Fases 6–9): migration-roadmap-phases-6-9.md.
Fase 7 — Flyway V15 + integrador legado → integrador novo (sincronização)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06 · Decidido em: 2026-07-06
Objetivo: durante a transição, manter dois integradores alinhados: o legado continua a ser o ponto de entrada do ERP; o novo (este repositório / stack alvo) recebe uma réplica das mesmas operações via HTTP.
1. Base de dados (integrador novo)¶
1.1 Migração V15 (V15__uuid_v7_id_old_id_swap.sql)¶
Após V14 (coluna id_v7) e backfill completo de id_v7 (NOT NULL, único por linha), a V15:
- Renomeia
id(UUID v4, antiga PK) →old_id(não é PK; o índice secundárioidx_*_idpassa aidx_*_old_id). - Renomeia
id_v7→id(nova PK comDEFAULT uuidv7()).
Assim não se perde o identificador v4: permanece em old_id, indexado. A V15 deixa old_id ainda NOT NULL (herdado da antiga PK); a V16 (V16__old_id_nullable.sql) faz ALTER COLUMN old_id DROP NOT NULL nas 13 tabelas, para que novas linhas criadas após a migração possam ter old_id NULL.
Documentação operacional: runbook-migration-uuid-v7.md · SQL de referência: sql/V15__uuid_v7_swap_id_rename_to_id.sql · sql/V16__old_id_nullable.sql.
1.2 PowerSync — resolução do UUID no PATCH¶
O endpoint POST /api/v1/powersync recebe no JSON o campo id (UUID). O servidor procura a linha em propriedades, estoque ou veiculos por:
id(PK v7), ou se não existir,old_id(UUID v4 legado).
Implementação: findById(id).or(() -> findByOldId(id)) nos repositórios correspondentes.
A decisão 0013 estendeu esse
PATCHpara o write-back de venda (Chave*/CodRetorno/MsgRetorno) também emcontratos,itenspedefones—fonesresolve só porid(tabela nova, semold_id).
1.3 Health — tabela request¶
Cada GET /api/health bem-sucedido (após o filtro Bearer) grava uma linha na tabela request com origem = API_HEALTH, tipo GET, JSON com metadados do pedido (path, method, query opcional, timestamp), alinhado ao padrão de registos de outras APIs (ex.: PowerSync, XADM).
2. Integrador antigo (projeto original)¶
- Continua a receber as chamadas do ERP como hoje.
- Para os fluxos acordados pelo time, após processar localmente (ou em paralelo), o legado deve chamar o integrador novo:
- Método e path: alinhados ao contrato REST do novo (ex.: mesmo path relativo que o ERP usaria no novo, ou tabela de mapeamento 1:1).
- Corpo: exactamente o mesmo JSON (e restantes dados) recebidos do ERP — reencaminhamento fiel.
- Headers: incluir
Authorization: Bearercom o token estático partilhado (o mesmo configurado no novo integrador — ver Fase 6).
3. Resultado esperado¶
- ERP → legado → novo: os dois backends aplicam a mesma carga de dados/API, mantendo-se sincronizados até se desligar o legado ou remover o encaminhamento.
- Falhas na chamada ao novo devem ter estratégia definida (retry, log, alerta, fila — fora do âmbito mínimo deste documento, mas obrigatório no desenho de deploy).
- Paridade com o legado (ordem + deadlock): o ERP especifica pares
DELETE → PUTque precisam ser aplicados na ordem de chegada. O novo integrador serializa o trecho de persistência do/api/v1/xadmcom umReentrantLock(fair=true)global — só um request XADM por vez. Isso preserva ordering FIFO e elimina deadlocks40P01emitenspedamxentre transações XADM concorrentes (incidente de 2026-04-24). Ver xadm-error-handling.md.
Fora de âmbito desta fase¶
- Login de browser / auth.xadm.biz nas telas — Fase 8.
- Auditoria e remoção de guards de prod — Fase 9.
Referências¶
- Fase anterior (Bearer + health): migration-phase6-seguranca.md
- Fase seguinte (sessão auth): migration-phase8-session-auth.md
- UUID v7 / V14–V15: migration-phase3-uuid-v7.md
- Índice: migration-roadmap-phases-6-9.md
Fase 8 — Sessão com auth.xadm.biz; rotas MVC protegidas¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-06 · Decidido em: 2026-07-06
Esta fase introduz autenticação de utilizador para a UI e rotas que não são API, em conjunto com o serviço auth.xadm.biz. Corresponde ao que antes estava numerado como Fase 7 no roadmap “só segurança” antes da inserção da Fase 7 dual integrador.
Modelo de acesso (alvo)¶
| Rotas | Comportamento |
|---|---|
/, /health, /swagger/** e /swagger-ui/** (e o que fizer sentido para OpenAPI estático) |
Públicas — sem login. |
/api/** |
Inalterado em relação à Fase 6 — apenas Bearer estático (ENV), sem JWT de sessão neste contrato. |
| Demais rotas (MVC, etc.) | Protegidas — apenas utilizadores autenticados. |
Fluxo desejado (visão)¶
- Quando o utilizador precisar de login, a aplicação redireciona para auth.xadm.biz, onde o auth trata o método de login (OAuth2 ou outro, definido lá).
- Após sucesso, o auth devolve ao integrador uma sessão válida do utilizador (mecanismo concreto — cookie de domínio, token, etc. — a fechar em conjunto na implementação).
- Com essa sessão, o utilizador pode aceder a todas as rotas protegidas do integrador.
Nota: Os detalhes de protocolo (redirect URIs, nome de cookie, validação de token) ficam para o desenho/implementação desta fase; o presente documento fixa apenas o recorte do roadmap.
Referências¶
- Fase anterior (V15 + dual integrador): migration-phase7-v15-dual-integrator-sync.md
- Fase seguinte (auditoria / guards): migration-phase9-audit-guards.md
- Índice: migration-roadmap-phases-6-9.md
0006 — Java 25 + Gradle 9.5.1 + Micronaut 5¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-07 · Decidido em: 2026-07-07
Contexto. A constituição adota Java 25 (LTS da casa, homologado na fábrica de imagens de CI). O integrador estava em Java 21 / Gradle 8.14.3 / Micronaut 4.10.
Decisão. Subir a stack para Java 25. Isso força Gradle 9.5.1 (o Kotlin DSL do 8.14 não
parseia a versão do JDK 25) e, por consequência, Micronaut 5.0.2 (o plugin micronaut.application
5.0 alinha ao Gradle 9). Espelha o app-ref bi-transporte-xls.
Consequências / trade-offs.
- Jackson 2 → 3 (
tools.jackson.*,JsonMapper, exceções uncheckedJacksonException): 9 arquivos migrados; as anotaçõescom.fasterxml.jackson.annotation.*permanecem. - OpenAPI por anotação (
@OpenAPIDefinition) no lugar do bean programático (removido). ViewModelProcessor<T,R>eTransactionOperationsganharam membros no MN5 — ajustados.micronaut.aotremovido: ooptimizedJitJarAllcolide com o shadow 8.3.10 no Gradle 9, e o deploy embarca o shadowJar (não o jar AOT) — sem impacto.
Verificação. Suíte completa verde (566 testes no momento da migração).
0007 — Auth nativo micronaut-security (remove os filtros custom)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-07 · Decidido em: 2026-07-07
Contexto. A auth vivia em dois @Filter/HttpServerFilter custom (AuthFilter para a sessão
das views, ApiBearerTokenFilter para o Bearer de /api/**). A constituição manda
micronaut-security nativo (piso de segurança).
Decisão. Migrar para micronaut-security: StaticBearerTokenValidator + ApiBearerSecurityRule
(Bearer /api/**), SessionAuthenticationFetcher + ViewSecurityRule (sessão das views,
tri-estado) e ViewRejectionHandler (302/401/403/503, corpo RFC-7807). Paths públicos no
intercept-url-map. Os dois filtros custom foram removidos. Detalhe: Segurança.
Consequências / trade-offs.
- Comportamento preservado: Bearer estático, redirect de view anônima, o público
/api/v1/powersync/lastchange, e a gravação de/api/healthnão-autorizado (agora noViewRejectionHandler). - ~40 testes de filtro reescritos como testes de regra/validador (unit) + integração end-to-end.
- Comparação do Bearer em tempo constante (
MessageDigest.isEqual) — nãoequals(timing attack).
Verificação. Suíte completa verde; os integration/* de auth exercitam Bearer, login, CORS e views.
0008 — Erros de API em RFC 7807 (application/problem+json)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-07 · Decidido em: 2026-07-07
Contexto. Cada erro tinha um handler próprio montando um corpo ad-hoc
({status, error, message}) — divergente entre endpoints e fora da norma da casa.
Decisão. Um ponto único, UnifiedErrorResponseProcessor (@Replaces
HateoasErrorResponseProcessor), formata todo erro do framework (Bean Validation, 404, parse,
HttpStatusException) como RFC 7807 (ProblemDetail, application/problem+json). Ele só
formata o corpo, não decide o status. Detalhe: erros RFC-7807.
Consequências / trade-offs.
- Muda o shape do corpo de erro dos endpoints de framework (
{type,title,status,detail,code}). - Carve-out deliberado: o
/api/v1/xadmnão passa por aqui — o 400 de JSON malformado é produzido no controller (XadmResponse) e JSON válido sempre responde 200. O agente on-prem X-Adm parseia esse corpo; o contrato dele não muda. - Handlers custom (
ServerException/Unauthorized/Global) emitem o mesmoProblemDetail.
0009 — Observabilidade via sentry-logback (SentryAppender)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-07 · Decidido em: 2026-07-07
Contexto. O erro só ia ao Sentry/GlitchTip por captura explícita (Sentry.captureException
espalhado). O plugin io.sentry.jvm.gradle estava aplicado mas inerte (source context e
auto-install desligados).
Decisão. Adotar o recipe da casa: io.sentry:sentry-logback + um SentryAppender no
logback.xml (minimumEventLevel=ERROR) que captura todo log ERROR (inclui exceção
não-tratada) sem tocar o negócio. O plugin io.sentry.jvm.gradle foi removido. Detalhe:
observabilidade.
Consequências / trade-offs.
- O appender não leva
<dsn>: oSentryInitializerfaz oSentry.init()no start (só em prod, comSENTRY_DSN). Sem DSN o Sentry desabilita e o appender vira no-op (não spamma). - O
GlobalExceptionHandlerdeixou de chamarcaptureException(olog.error+ appender já capturam) — evita evento duplicado. - Capturas explícitas seguem válidas onde há semântica (ex.: origens conhecidas no
XadmService).
0010 — Package-by-feature + core + evento de domínio¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-07 · Decidido em: 2026-07-07
Contexto. O código era package-by-layer (controller/service/repository/domain/...) — a
geração anterior. A constituição prefere package-by-feature.
Decisão. Reorganizar em features (ingest, views, powersync, push, export) sobre uma
fundação de dados core (entidades, repositórios, enums, conversores, ParsedXadmPayload,
eventos) e um kernel comum (config, auth, exceção, observabilidade, auditoria). Detalhe:
estrutura de código.
Consequências / trade-offs.
- A decomposição ingênua teria dois acoplamentos:
comum→ingest(auditoria) eingest↔push(a nota aplicada dispara push). Resolvidos assim:- a auditoria (
ApiHealthRequestRecorder) foi paracomum→comum→core(permitido); ingest→pushquebrado por evento: oXadmServicepublica umNotaAplicadaEvent(emcore) e oNotaAplicadaPushListener(empush) reage —ingestnão conhecepush.
- a auditoria (
- Resultado: o grafo de pacotes é um DAG. Guarda ArchUnit no gate: core puro, comum só depende de core, sem ciclos.
- §6 (destilado para a constituição): package-by-feature num app com entidades compartilhadas só fecha em DAG com core separado + eventos de domínio para as reações cross-feature.
Verificação. Suíte completa verde; arch/ArchitectureTest valida as três regras.
0011 — Features-motor independentes + views como camada de composição¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-07 · Decidido em: 2026-07-07
Contexto. A decisão 0010 reorganizou o código em
package-by-feature sobre core+comum, guardado por ArchUnit (core puro, comum só depende de core,
sem ciclos). A doc vendia "features constroem sobre core+comum", implicando independência entre
features. Uma auditoria mostrou que a prática divergia: powersync importava serviços de ingest
(PropriedadesNomeCurtoService, XadmResponse) e views importava de ingest e push. Como tudo
apontava para ingest (um DAG), a guarda "sem ciclos" passava — mas não havia regra de
independência, então ingest virou uma camada compartilhada de-facto sem ninguém perceber. A
guarda era mais fraca do que a doc sugeria.
Decisão. Reafirmar a independência, mas distinguindo dois tipos de pacote de feature:
- Features-motor (
ingest,powersync,push,export) — constroem sobrecore+comume são independentes entre si. O que for compartilhado por mais de uma sobe paracomum/core. views— a camada de apresentação/composição, ACIMA das features-motor. Server-rendered (Thymeleaf), ela compõe telas a partir das features e por isso pode orquestrá-las legitimamente (ex.: oDebugViewControllerdispara push de teste e reparo de coluna para diagnóstico).viewsnão é uma feature-motor.
Ações tomadas:
- Mover para
comumos serviços/tipos compartilhados que moravam emingest:PropriedadesNomeCurtoService(usado porpowersynceviews) eXadmResponse(usado porpowersync). TambémApiHealthController→comum(health é concern de kernel;comumjá abrigava oHealthControllere oApiHealthRequestRecorder). - Adicionar a guarda ArchUnit
featuresMotorSaoIndependentes: nenhuma das quatro features-motor depende de outra.viewsfica de fora por ser a camada de composição.
Consequências / trade-offs.
- A aresta real
powersync→ingestdesaparece; o grafo das features-motor passa a ser um anti-grafo (sem arestas entre elas), guardado no gate — bem mais forte que "sem ciclos". comumcresce um pouco: além de kernel puramente técnico, passa a hospedar serviços de negócio compartilhados (ex.PropriedadesNomeCurtoService). É um custo aceito — o critério é "usado por mais de uma feature-motor", não "é técnico".- Alternativa rejeitada (B): declarar
ingestcomo camada compartilhada e só corrigir a doc. Rejeitada por perpetuar um "kernel de negócio" implícito e não-guardado dentro de uma feature. - Limite da guarda: o ArchUnit lê bytecode. Uma dependência cross-feature que exista só via
constante
static final(inlinada pelo compilador) não é detectada — foi o caso do antigocomum→ingest.ApiHealthController.ORIGEM_API_HEALTH, invisível ao gate. Acoplamento real (tipo, injeção, chamada) é detectado normalmente.
Nota (decisão 0025 — JTE). O motor de render das views mudou de Thymeleaf para JTE (templates compilados, native-first). A divisão de camadas desta decisão —
viewscomo camada de composição ACIMA das features-motor — permanece válida; só o mecanismo de template foi substituído.
0012 — API CRUD como 2ª camada edge (ao lado de views)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-08 · Decidido em: 2026-07-08
Contexto. O pacote ingest era um pacote-deus: misturava o pipeline de ingestão XADM
(XadmController /api/v1/xadm → XadmService → XadmApplyService → parser + helpers) com os 13
controllers CRUD REST por-entidade (/api/v1/estoque, /contratos, …). Duas responsabilidades
distintas no mesmo pacote. A decisão 0011 já havia
estabelecido views como uma camada edge (apresentação, acima das features-motor, podendo
orquestrá-las).
Decisão. Extrair os 13 controllers CRUD para um pacote próprio api, classificado como uma
2ª camada edge — ao lado de views:
- Camadas edge (
views= UI Thymeleaf;api= REST CRUD): ficam acima das features-motor e podem depender delas. Ex.: os controllers deapiusam oEscritaPorChaveNaturaldeingest. XadmControllere o pipeline de ingestão permanecem emingest(não é CRUD por-entidade; é o endpoint de ingestão em massa, acoplado aXadmService).- Guarda ArchUnit:
apientra no arrayFEATURES(portantocore/comumnão dependem deapi), mas fora deENGINE_FEATURES— a regra de independência vale só para as 4 features-motor (ingest/powersync/push/export); edges podem depender delas.
Consequências / trade-offs.
ingestfica com uma responsabilidade (a ingestão XADM); a API CRUD tem casa própria.- Aceita-se
api→ingest(o helperEscritaPorChaveNaturalfica emingest) — coerente com a classificação edge. Alternativa preterida: tratarapicomo feature-motor peer e mover o helper paracomum; rejeitada por ser mais movimentação e porque a CRUD por-entidade não "compõe" features como aviewsfaz — é uma superfície de dados, apresentação. - As rotas (
@Controller("/api/v1/…")) não mudam — a extração é reorganização de pacote Java pura, sem mudança de contrato/comportamento (gate: 572 testes verdes). - Surge um segundo caso de "edge depende de engine" não-guardado por regra dedicada (o inverso, engine→edge, hoje não é violado). Uma guarda "engine ⊥ edge" foi deferida (custo×valor).
0013 — Fluxo de entrada PIED no espelho do X-Adm (chave dupla + write-back)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-15 · Decidido em: 2026-07-15
Atualização 2026-07-15 — identidade do produto. A identidade do produto no espelho é
ChaveEst(eCodProd, versão virtual/substring doChaveEst, usada na instância Thoms ondeChaveEstchega null) — confirmado com a equipe ERP. Ocodigo_alt(código do produto na PIED), adotado abaixo como chave da entrada, foi reenquadrado como transporte e renomeado parapied_codigo_alt(migração V19) — identidade nunca é vocabulário do parceiro. Oestoqueda Thoms vem do PUT de saída do X-Adm (não do fluxo de entrada PIED); o apply resolve a saída porChaveEstou, quando null, porCodProd. Onde o texto abaixo dissercodigo_altcomo chave, leiapied_codigo_alt(transporte) + identidadeChaveEst/CodProd. Fase 2 (V20) fará o mesmo rename emx_ped/itensped.codigo_alt. Ver.ia/007-identidade-produto-espelho-spec.md.Atualização 2026-07-15 — sinal de mudança de estoque (feature 008). O tradutor WebStorm-ecom da Thoms (
clientes/thoms/int-produtos-ecom) consome oestoquedeste espelho. Duas entregas do integrador destravam o v1 dele (migração V21): (1) colunaestoque.updated_at(TIMESTAMPTZ) como chave-versão, mantida no Java por Micronaut Data (@DateUpdated) na entidadeEstoque— auto-populada em toda escrita de entidade (save/update), por qualquer escritor que passe pelo repositório (apply XADM, tela adminEstoqueViewController, API REST, write-back PowerSync). Cobre os 4 escritores porque todos usam Micronaut Data; o@Querycru que só mexe emnome_prod_curtonão passa por entidade e (de propósito) não bumpa. Sem dirty-check: bumpa também em reenvio idêntico do X-Adm e no write-back decod_retorno/chave_est→ o tradutor reenvia à WebStorm com mais frequência (inofensivo — WebStorm é idempotente, o tradutor coalesce). Alternativa preterida: trigger Postgres comIS DISTINCT FROM(dava o dirty-check, mas o time preferiu manter a lógica em Java, sem trigger). (2) Webhook de saída (poke):EstoqueMudouEvent(publicado pós-commit peloXadmServicequando o payload tocou estoque) →EstoqueWebhookListener(virtual thread fire-and-forget) →@ClientPOSTvazio + Bearer ao tradutor, config-gated off (webstorm.webhook.enabled, só o deploy Thoms liga — env vars do Coolify emdev/application-config.md). Best-effort: falha do poke não derruba o XADM — o@Scheduleddo tradutor é a rede de segurança (oupdated_atjá está persistido). Escritas via admin/API não pokam, mas bumpamupdated_at→ chegam pelo sweep. Ver.ia/008-sinal-estoque-tradutor-spec.md. Evolução aberta (novo prompt.ia/009): estenderupdated_ata todas as tabelas espelho do X-Adm (não sóestoque).Atualização 2026-08-11 — FORMULAS (fórmula/BOM do kit). Nova tabela-espelho de entrada (
.ia/013): a fórmula de um pedido-kit PIED — uma linha por componente (pied_x_ped,pied_cod_prod,qtde). Distinta das demais tabelas de entrada em três pontos: (1) o id do X-Adm (pied_formula_id, sequencial) é gerado por nós (GENERATED ALWAYS AS IDENTITY, coluna DB não-mapeada na entidade — a réplica WAL do PowerSync a carrega), não pelo X-Adm — as outras tabelas recebem a identidadeChave*do X-Adm; (2) sem colunasChave*— o X-Adm nunca devolve chave neste fluxo (confirmado no staging: 0/62 itensped PIED têm chave), sócod_retorno/msg_retorno(006); (3) sem DELETE — a PIED só envia pedido pago, imutável. Upsert single-key (sem dual-key). Contrato público em xadm-ingest; migraçãoV25__formulas.sql.
Contexto. O Integrador é um espelho do ERP X-Adm — todas as tabelas são tabelas do X-Adm,
num modelo único. Até aqui só havia o fluxo de saída: X-Adm → Integrador → PowerSync → apps
mobile (projetos Sul Plata / OnPetro). O X-Adm é o produtor e empurra por /api/v1/xadm com a
Chave* (identidade do ERP) já preenchida.
A integração PIED (Maxsul) introduz o fluxo de entrada: PIED → INT-PIED → Integrador →
PowerSync → X-Adm. Agora uma fonte externa (PIED, pedidos de venda) é o produtor. As mesmas
tabelas do espelho passam a receber mais colunas (as mesmas tabelas do ERP, com o subconjunto
de colunas que a Sul Plata não usava). A Chave* chega vazia — quem a gera é o X-Adm, que
a devolve com o status pelo write-back. Schema autoritativo: documento MAXSUL2025_610110 (repo
clientes/maxsul/int-pied); o dono do Flyway é este repo.
Decisão. Trabalho aditivo ao espelho compartilhado (mesmo modelo, sem schema por cliente, sem tabelas paralelas). Quatro escolhas centrais:
- Chave dupla no upsert (rastro L12). O
upsert*/softDelete*resolvem a linha pelaChave*do X-Adm quando o produtor a fornece (fluxo de saída, comportamento intacto) e caem para a chave natural da fonte externa quando ela chega vazia (fluxo de entrada) —contratos→pied_x_ped,estoque→pied_codigo_alt(oucod_prodna saída Thoms),itensped→(pied_x_ped,pied_codigo_alt),propriedades/fones→cgc_cpf(nomespied_*após V19/V20 — ver nota de atualização no topo). Materializado nofinderde cada método (oEscritaPorChaveNaturalnão muda). Alternativa preterida: métodos de upsert paralelos por fluxo — mais código, mesma lógica. - Colunas de retorno são preenchidas só pelo write-back (L12). No fluxo de entrada, a fonte
(PIED) é dona das colunas de dado; o X-Adm é dono das colunas de retorno (
Chave*,CodRetorno,MsgRetorno). Elas são read-only no caminho de entrada e chegam pelo write-back (POST /api/v1/powersync). NocopiaCamposdo ramo de entrada elas são preservadas (não sobrescritas com o branco que a fonte envia) — senão o reenvio idempotente apagaria o retorno recém-gravado. Mesmo padrão donomePropCurto. As telas admin mostram essas colunas read-only. - NOT NULL relaxado + crítica em runtime (L15). O fluxo de entrada deixa vazias tanto colunas de
dado do fluxo de saída (
seq_veic,cod_mun,nome_prod…) quanto as colunas-chave (chave_cp/chave_est/chave_prop/chave_item,NOT NULLantes — o X-Adm só as gera depois). A migração V18 relaxa todas para nullable; a garantia de cada fluxo passa à crítica na camada de apply (rejeita registro sem chave natural resolvível). Alternativa preterida:CHECKcondicional no schema — acopla o banco à noção de fluxo. - Write-back reusa o PowerSync (L16), sem endpoint novo. O transporte (insert/update local →
uploadData→POST /api/v1/powersync) já existia. Só a aplicação de campos doPowersyncControllerfoi estendida: somarcontratos/itensped/fonesao switch e cadapatchX(inclusivepatchPropriedades/patchEstoque, que só aplicavam nome-curto) gravarcod_retorno/msg_retorno/chave_*.fonesresolve a linha só porid(tabela nova, semold_id).
Princípio arquitetural — colunas de retorno do X-Adm¶
Toda tabela que volta ao X-Adm (fluxo de entrada) carrega CodRetorno + MsgRetorno — princípio
universal, sem exceção. O X-Adm atualiza essas duas colunas com o status do processamento de cada
linha que recebe (junto com as Chave* que ele gera). Códigos (doc-mãe MAXSUL2025_610110, ver
clientes/maxsul/int-pied/docs/projeto/mapeamento.md): 006 = gravado no X-Adm (sucesso);
000/00X = novo/em processamento (pendente, ainda não cadastrado); 1XX = erro terminal;
9XX = erro de processamento. Consequências:
- Fluxo de saída (ex. Sul Plata): as tabelas não voltam ao X-Adm —
CodRetorno/MsgRetornoexistem no schema compartilhado mas ficam semprenull. - Fluxo de entrada (ex. PIED):
CodRetorno/MsgRetornosão preenchidas pelo write-back e seguem a especificação do fluxo (clientes/maxsul/int-pied).fonestambém carregaCodRetorno(o princípio é universal) — isso diverge da doc-mãe MAXSUL2025_610110, que especificoufonessó comMsgRetorno; a divergência é intencional e deve ser reportada à equipe X-Adm / maxsul-pied.
Leitura do retorno — reconciliação (fecha o loop)¶
O int-pied precisa saber o resultado real do X-Adm para reconciliar o próprio estado. O Integrador
expõe GET /api/v1/xadm/retorno/{tabela} (por chave natural do PIED: contratos?xPed,
estoque?codigoAlt, itensped?xPed+codigoAlt, propriedades/fones?cgcCpf; {tabela}
case-insensitive; ?origem= opcional/ignorado). Devolve um DTO enxuto { tabela, status, codRetorno,
msgRetorno, chaveXadm } — HTTP 200 sempre (poll não toma 404; 400 só para tabela/chave
inválida; Bearer herdado do ApiBearerSecurityRule). O status é derivado dos códigos do X-Adm:
006→CONFIRMADO, 000/00X(≠006) ou branco→PENDENTE, 1XX/9XX→REJEITADO, linha ausente
(ou soft-deleted)→NAO_ENCONTRADO; o codRetorno cru também volta (para distinguir 1XX×9XX).
Read-only — não grava em request (poll de alta frequência). Mantém o acoplamento na API (o
int-pied não lê o Postgres direto). Código: ingest/XadmRetornoController + StatusRetorno.derive.
Tipos/nomes (L13/L14/L17): convenção da casa (JSON PascalCase — com o pedido literalmente
xPed, x minúsculo — → DB snake_case → Java camelCase); tipos casando com o repo
(CHAR/VARCHAR/NUMERIC/DATE). fones é 1 contato por cliente (cgc_cpf UNIQUE). chave_prop
ampliado a CHAR(11). Índices unique das chaves da fonte são parciais (WHERE … IS NOT NULL) —
não colidem com as linhas do fluxo de saída. INT-PIED entra em XadmService.KNOWN_ORIGENS.
Conformidade à largura fixa do espelho (o espelho é refém do X-Adm)¶
As colunas do espelho têm a largura fixa do X-Adm real — nunca são alargadas (o valor não caberia no X-Adm no sync). Então a fonte se ajusta ao espelho, não o contrário (achados do e2e local):
- Chave = código, não ObjectId. A chave natural da entrada (
xPed,CodigoAlt) é odata.codedo pedido PIED (número, ~9 chars, cabe noCHAR(15)), não odata.id(ObjectId de 24, que estoura). O de-para do int-pied e a reconciliação (GET /retorno) usam ocodedos dois lados. No kit, oitensped.CodigoAlt= o mesmodata.codedoestoque(o item referencia o produto; o apply resolveitenspedpor(xPed, CodigoAlt)— vazio não resolve). - Texto trunca no ingest; chave erra. O
EscritaPorChaveNaturaltrunca cadaStringao@Size(max)declarado na própria entidade (fonte única = o domain, lido por introspection) antes de persistir, comlog.warn. As chaves cabem por construção (code≤15,cgcCpf=14) e, se estourassem, erram no persist — chave que não cabe é violação de contrato, não texto a cortar.
Consequências / trade-offs.
- O fluxo de saída segue idêntico (o ramo
Chave*do upsert não mudou); a garantia de chave saiu do schema (NOT NULL) e virou crítica em runtime — inclusive o CRUD admin perdeu a validação de chave em branco (o admin não passa pela crítica do ingest). Aceito: admin é operador confiável. - Cobertura de teste: o round-trip real-DB (idempotência, preservação do retorno, write-back)
roda via
fones(repo real); a chave dupla das demais tabelas fica em unit (Mockito). Motivo: os@Factory @Replacessem@RequiresemPushContextBuilderIntegrationTest/PowersyncControllerTestmockam esses repositórios globalmente em todo@MicronautTest. Guardá-los com@Requiresé um follow-up de test-infra. - V17-fantasma (resolvido): a migração nova é V18 (pula V17). O
V17__request_identity_sequence_syncfoi adicionado e removido do classpath; comvalidate-on-migrate: true, se aplicado em produção o boot do Flyway falharia. Endereçado porflyway...ignore-migration-patterns: ["*:missing"]noapplication.yml(tolera migração aplicada-mas-ausente; no-op em banco novo). V18 vingou e as migrações seguiram por cima até V25 em produção — o número está consolidado. chave_prop=11 (confirmado): validado com o PIED rodando em staging sem erro de persist de chave — a largura 11 cabe a chave real da fonte. Pendência de aval encerrada.- Fora de escopo: a fase 2 do
maxsul-pied(produtor real dos campos); a config de down-sync do PowerSync (carregar as colunas novas ao cliente/X-Adm); múltiplos endereços por cliente (assumido 1).
Detalhe de mecânica vive no código (ingest/XadmApplyService, ingest/XadmPayloadParser,
powersync/PowersyncController, migração V18__fluxo_entrada_pied.sql) — aqui fica só o durável.
0014 — updated_at (chave-versão) nas tabelas do espelho X-Adm¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-20 · Decidido em: 2026-07-20
Contexto. A decisão 0013 introduziu estoque.updated_at (V21) como
chave-versão do tradutor Thoms e deixou como evolução aberta estender updated_at às demais
tabelas espelho. O gatilho concreto veio do integrador-client (PowerSync on-premise), que
processa os pendentes na ordem dos fatos com ORDER BY updated_at dentro de cada tabela.
Para isso o servidor (dono das tabelas) precisa de uma chave-versão uniforme por linha.
Decisão. Dar a toda tabela do espelho X-Adm uma chave-versão updated_at uniforme, para
qualquer consumidor externo (integrador-client, tradutores, sweeps) ordenar os fatos por
ORDER BY updated_at em qualquer tabela — sem uma feature nova por tabela. Feito em duas migrações
na mesma decisão:
- V22 — tabelas do fluxo de entrada:
contratos,propriedades,itensped,fones(novas) + alinharestoque(V21 era nullable →NOT NULL). - V23 — demais tabelas de negócio:
veiculos,municipio,loteest,itens,itenslote,itenspedamx,itpedamxdi,tbpcoest.
Fora: as tabelas de infra (request, pushenviada, pabast_*) — têm timestamps próprios e
nenhum consumidor as ordena por updated_at.
- Mecanismo =
@DateUpdated(Micronaut Data), herdado do estoque/0013. Bumpa em toda escrita de entidade (repo.save/update): apply XADM (EscritaPorChaveNatural) e write-back PowerSync (PowersyncController) passam por repositório. Coluna de controle do servidor:@DateUpdatedsobrescreve qualquerupdated_atrecebido de fora (PUT/api/v1/xadmou write-back) — o pedido de "não aceitarupdated_atexterno" sai de graça, sem código de guarda. - Coluna
TIMESTAMPTZ NOT NULL DEFAULT now()+ índiceidx_<tabela>_updated_atem cada uma.
Exceção notacompl. Ganha @DateUpdated na entidade (o updated_at, antes só do cliente/NULL,
passa a ser server-controlled — mais confiável para o lastchange), mas a coluna permanece NULLABLE
(V10, sem backfill): o /powersync/lastchange e a ordenação de vendas dependem de
MAX(COALESCE(updated_at, created_at)) — linhas legadas com updated_at NULL caem no created_at.
Um NOT NULL DEFAULT now() backfillaria as legadas para o instante da migração e quebraria esse
fallback. O created_at (ordenação cronológica) continua manual, fora deste mecanismo.
Consequências / trade-offs.
@DateUpdatedvsnow()manual no upsert. Escolhido@DateUpdated: uniforme com o 0013, zero plumbing, rejeição de valor externo gratuita. Custo: bumpa também em reenvio idêntico / write-back (aceito — o consumidor é idempotente porupdated_at). O@Querycru que não passa por entidade (de propósito) não bumpa.NOT NULL DEFAULT now()vs nullable. Escolhido NOT NULL para ordenação limpa no cliente (semNULLS FIRST/LAST). Custo: o backfill doestoque(linhas anteriores →now()da migração) faz o tradutor Thoms vê-las como "mudadas" uma vez no delta-sweep seguinte — re-sweep único e idempotente (ver comentário da V21).- Fora deste repo (operação humana): o
integrador-clientdeclaraupdated_atno schema local do SDK; o repopowersync(maxsul) precisa de rebuild da imagem para a coluna entrar nas sync rules (SELECT *).
Verificação. ./gradlew check verde (Testcontainers, Postgres real): schema NOT NULL + índice
das 5 tabelas (UpdatedAtEntradaSchemaTest, EstoqueUpdatedAtIntegrationTest), @DateUpdated
presente por reflexão, e round-trip real do bump via fones (FluxoEntradaIngestIntegrationTest).
0015 — Baixa de MDF-e por macro Sascar: espelho + watch + callback¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-30 · Decidido em: 2026-07-30
Contexto. O X-Adm passou a emitir MDF-e e o negócio quer encerrá-la sozinha quando o motorista fecha uma macro na Sascar. Quem detecta a macro é o int-sascar; quem encerra a MDF-e no ERP é o integrador-client (ZIM). Faltava a ponte no Integrador: espelhar a MDF-e, dizer ao int-sascar quais vigiar, receber o encerramento e virar um comando que o client execute. O como está em baixa-mdfe-sascar; aqui ficam as escolhas e o preterido.
Decisão. Quatro capacidades aditivas ao espelho, sem quebrar o fluxo PIED.
- Reusar
PUT /api/v1/xadm(não rota nova) para oMDFE_PUT(Origem:PMDFE). Mesmo contrato de resposta (200 comresultado). Ingest tipado por tabela (como o resto): 7 tabelas novas (mdf/mdfcompl/nfeevento/mdfitens/nfmdf/nfcomplmdf/fretes),municipio/propriedadesreusadas. Preterido:/api/v1/mdfededicado — duplicaria auth/erro/request sem ganho. - Comando de baixa em coluna própria na
mdf, não tabela nova:situacao_baixa(ATIVO/BAIXA/ERRO) +cod_retorno/msg_retorno(contrato do V18) + proveniência (id_pacote/data_pacote/motivo). Preterido: tabelamdfe_comando(mais junção, sem ganho) e sobrecarregarNFEEVENTO(que fica fiel ao X-Adm, só leitura). Ocod_retornoé o sinal que oENCERRA_MDFEobserva; a coluna própria dá visibilidade humana. - Watch de saída =
@Clientdeclarativo +@Retryable(padrão do poke 008), best-effort em virtual thread, idempotente. Corpo destilado (o int-sascar nunca vê JSON cru do ZIM): status já interpretado, destino resolvido por discriminante NFe(55)×CTe(57).status=encerrado, nãoDELETE: achave_notatem$/+e não cabe em path. Oencerradosó dispara na transição (marcadorencerrado_em), independente dasituacao_baixa— o watchabertovigia a placa de toda MDF-e aberta, então todo encerramento precisa mandá-lo parar. Revisão 2026-07-31: o gatesituacao=BAIXAcaiu — a baixa comum é encerrar no X-Adm primeiro (ficaATIVO, sem macro Sascar), e aí basta parar de vigiar; receberencerradosem baixa pendente não alarma o int-sascar (só remove domdfe_vigiado). - Callback
POST /api/sascar/encerramento(path literal, não/api/v1/). Sempre 2xx (o int-sascar é dono do retry): chave inexistente = no-op, não 404 (404 giraria o retry infinito); reenvio = no-op idempotente (só a transiçãoATIVO→BAIXAgrava). - Auth do callback = Bearer único da casa. O
outbound_tokenque o int-sascar envia é oINTEGRADOR_API_TOKEN(o Bearer de/api/**, viaApiBearerSecurityRule). Preterido: um 2ºTokenValidatorpara um token separado — dariaROLE_APIglobal ao token do int-sascar (rebaixaria a segurança de todas as rotas/api). Um Bearer, um validador. - Entrega PowerSync = recorte mínimo
mdfno bucketglobal(sem bucket novo). Amdfcarrega tudo que oENCERRA_MDFElê; oSchemaTabelasdo client segue o espelho. Decisão do rollout (2026-07-30): não editar osconfig.yamlde cliente neste PR — só documentar o recorte; a edição por cliente é operação de rollout.
Consequências. MDF-e ativa aparece ATIVO, é vigiada, e — fechada a macro — vira BAIXA/000;
o client executa e escreve 006; o re-PUT com NFEEVENTO(110112,A) dispara o watch encerrado.
Chaves empacotadas do ZIM: literal na gravação, TRIM só na consulta (matching do callback e das
junções depende disso). Segredos (SASCAR_INBOUND_TOKEN) só por ENV (§1.9).
Fora de escopo: detector de macro/registry (int-sascar); ENCERRA_MDFE no ZIM
(integrador-client); geo/shadow; tela de baixas; espelhar MOTORISTAS/FRETESMOT.
0016 — Entrega garantida M2M: adota xadm-mensageria (outbox transacional)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-07 · Decidido em: 2026-08-07
Contexto. O Integrador troca mensagens server-server nas duas pontas e nenhuma garantia hoje:
o watch (→ int-sascar) e o poke de estoque (→ tradutor Thoms) disparavam fire-and-forget
(virtual thread + @Client, falha logada e engolida); o encerramento recebido do int-sascar não
tinha auditoria simétrica. Se o destino ficava offline, a mensagem se perdia até um novo ingest
reincidir. A lib da casa xadm-mensageria (outbox transacional + relay + SPI + tela) resolve isso de
forma reusável. Esta é a adoção da lib no Integrador. O como está em
baixa-mdfe-sascar; aqui ficam as escolhas e o preterido.
Decisão.
- Enfileirar DENTRO da transação do ingest, não pós-commit. O
XadmApplyServiceabre a tx programaticamente (txOps.executeWrite) e commita antes de retornar; o enfileiramento (via o colaboradorMensageriaEnfileirador) roda dentro desseexecuteWrite—MensageriaService.enfileiraré@Transactional(REQUIRED) e junta na tx corrente. Rollback do apply → a linha do outbox não nasce (outbox transacional real).applyPut/applyDeleterecebem ocorrelationId(= request-id). Preterido: manter o listener pós-commit e só gravar a linha ali — perderia a garantia (a linha nasceria fora da tx de negócio). - watch: destilar no enfileiramento, salvando o
MdfeWatchBodycomopayload— reenvio reprodutível sem reconsultar o espelho (que pode ter mudado). Preterido: salvar o item cru e re-destilar no envio. - Dois produtores de watch, um sender. O
MdfeMudouEventtem dois produtores: o ingest (XadmService) e o writeback powersync (PowersyncController, writeback-006). O ingest enfileira in-tx; oMdfeWatchListenerdeixou de enviar e passou a enfileirar (pós-commit) para cobrir o produtor powersync. ORelayM2mda lib vira o único sender; o disparo@Clientà mão sai do caminho. Preterido: deixar o powersync best-effort (inconsistente) ou enfileirar in-tx no writeback (mais escopo/risco fora do que a feature pedia). - poke: payload mínimo (origem/slug/sinal) por ser corpo vazio; enfileira in-tx no
doApplyPut/doApplyDeletequando o apply toca estoque.EstoqueMudouEvent/EstoqueWebhookListenerremovidos (produtor único = ingest → órfãos após mover o enfileiramento pra tx). - encerramento é só ENTRADA/auditoria. O
SascarEncerramentoControllerchamaregistrarEntrada(ENTRADA/RECEBIDO) — best-effort (try/catch): nunca altera o sempre-2xx nem a idempotência de negócio (o int-sascar é dono do retry; a baixa segue emEncerramentoService, ver 0015). - Dispatch tipado (SPI
EnviadorMensagem), transporte provisório. Um bean portipono app (WatchEnviador,PokeEnviador) desserializa opayloade chama o transporte. Enquanto o contrato OpenAPI 006 não mergeia, os enviadores envolvem os@Clientà mão atuais (SascarWatchClient/TradutorWebhookClient) — já sob o relay durável; troca-se pelo@Clientgerado quando o 006 landar, sem reabrir a spec. Bearer injetado no envio, nunca persistido na linha. - Tela
/admin/mensageria+ gate@xadm.com.brvêm prontas na lib; o app só habilita e reusa o principal do 003 via oSessionAuthenticationFetcher(que popula aAuthenticationcom o atributoemail). À época, local, sem adotarxadm-seguranca— o fetcher migrou para a lib depois, ver 0018. Distinta da tela/mdf(011): request/response das mensagens M2M × status ENCERRADA/ABERTA da MDF-e. - Schema owned pelo app. A lib não embarca Flyway; o DDL canônico (resource do jar
db/mensagem_m2m.postgres.sql) foi copiado paraV26__mensagem_m2m.sql(V25 já ocupado pelaV25__formulas.sqldo kit PIED, em produção). Dep:br.com.xadm:xadm-mensageria:0.1.0(Forgejo Packages, leitura pública). Depende da adoção daxadm-comum-web(0017).
Consequências. Derrubar int-sascar/thoms e mudar uma MDF-e/estoque → a MensagemM2m nasce
PENDENTE junto do commit do ingest; o relay tenta, marca ERRO/incrementa tentativas, e no teto
vira PAUSADO + alarme; o operador reenvia/cancela em /admin/mensageria. Semântica AT-LEAST-ONCE
(sob N instâncias o relay pode despachar em dobro — os receptores já deduplicam por chave natural;
watch idempotente, encerramento por chave_nota). Um erro do enfileirar (ex. DB) agora propaga
como qualquer escrita do ingest — não é engolido como no best-effort; é o preço de mover a garantia
pra dentro da tx.
Fora de escopo: a lib xadm-mensageria em si (repo xadm-commons); o @Client gerado do 006
(transporte provisório à mão até lá); a migração da dir.B do encerramento (lado int-sascar); a tela
/mdf (011); o XADM-push legado (segue sempre-200 sem outbox).
0017 — Adota xadm-comum-web: dedup da infra web da casa¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-07 · Decidido em: 2026-08-07
Contexto. Adotar a xadm-mensageria (0016) arrasta
transitivamente a xadm-comum-web, que duplica infra que o Integrador tinha local desde
antes da extração das libs da casa (o app predata a extração). No boot o contexto falhava com
NonUniqueBeanException: UnifiedErrorResponseProcessor (ambos @Replaces(Hateoas)),
HealthController (/health), VersaoInfo (InfoSource). É a Fase 1 da iniciativa libs+contrato
aplicada ao Integrador — o app passa a consumir a infra web compartilhada em vez de espelhá-la.
Decisão.
- Adotar a lib, não isolar. As 4 classes locais (
ProblemDetail,UnifiedErrorResponseProcessor,HealthController,VersaoInfo) eram verbatim idênticas às da lib (só pacote/javadoc diferiam) — removidas; imports repointados parabr.com.xadm.comum.web.*. Preterido:@Replacesapp-side pra neutralizar a colisão — deixaria duas cópias divergindo; a colisão é sinal de que o app deve consumir a lib. - Não migrar o que não colide/tem overlap. Os handlers/exceptions app-specific (
GlobalExceptionHandler,ServerException/UnauthorizedException) não têm equivalente na lib (conjunto diferente: a lib tem BadRequest/NotFound/Conflict/Forbidden) — ficam locais, só repoint doProblemDetail. Regra: mesmo nome ≠ mesmo corpo — caracterizar antes de deduplicar. SentryInitializer: evoluir a lib, não regredir. O da lib 0.1.0 divergiu (não tinha a tagclientemulti-tenant nem o skip dev/test que o app usa). Em vez de adotar o divergente (regressão de observabilidade), axadm-comum-websubiu para 0.2.0 com paridade; o app pina0.2.0direto (Gradle resolve acima do transitivo 0.1.0 da mensageria — sem re-release dela). Pendência (lib): os 3 casos de tagclientedo antigoSentryInitializerTestdo app devem virar teste de regressão no repoxadm-commons(o app não os tem mais — sem eles, uma mudança futura da lib derruba a tag em silêncio para todos os apps).- Guarda de model imutável no
GlobalViewModel. OViewModelProcessordo app roda em todo render e faziamodel.put(...); a tela/admin/mensageriada lib devolve um model imutável (Map.of) →UnsupportedOperationException→ 500. Corrigido: muta in-place e, se o model for imutável, refaz numa cópia mutável e a substitui (setModel). Armadilha reusável (qualquer app que adote uma view de lib comViewModelProcessorglobal).
Consequências. ./gradlew check sobe o contexto com a xadm-mensageria no classpath sem colisão;
/health ({status, versao}) e os corpos RFC-7807 seguem idênticos (a suíte existente é a rede, sem
mudar asserção — prova de paridade). Consumo por versão: correção na casa chega ao app por bump.
Fora de escopo: a xadm-comum-web em si (repo xadm-commons); adotar xadm-seguranca (o fetcher
de sessão local do 003 já basta para o gate da tela, ver 0016).
Revisto: a xadm-seguranca foi adotada depois — ver 0018. E os
handlers app-specific (ServerExceptionHandler/UnauthorizedExceptionHandler) que este DR manteve
locais foram dropados no 0019: a comum-web 0.5.0 passou a logar no
UnifiedErrorResponseProcessor, então as exceções viram PortadoraDeProblema e a lib renderiza o
corpo (sem handler dedicado).
0018 — Adota xadm-seguranca: infra transversal de auth vem da lib¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-09 · Decidido em: 2026-08-11
Contexto. O Integrador foi a fonte da extração da xadm-seguranca — o
SessionAuthenticationFetcher local (sessão JWT das views, login Firebase) é o código de
onde a lib nasceu. Enquanto a lib não existia, o app carregava essa infra transversal em
br.com.xadm.comum; 0016/0017
declararam explicitamente que adotar a xadm-seguranca estava fora de escopo (o fetcher local
bastava para o gate da tela). Com a lib publicada e endurecida, essa infra vira recópia de código
da casa — constituição §5 Baseline (app Micronaut consome as libs transversais, não as espelha). É a
Fase 2 da iniciativa libs+contrato (a Fase 1 foi a xadm-comum-web, 0017). Este DR revoga a
nota "fora de escopo" de 0016/0017.
Decisão.
- Adotar
xadm-seguranca:0.3.0; dropar a infra transversal local. Seis classes de auth —AuthException,AuthSettings,MicronautProfiles,FirebaseIdTokenValidator,SessionTokenService,SessionAuthenticationFetcher— eram equivalentes às da lib (só pacote/javadoc diferiam) e saíram debr.com.xadm.comum; imports repointados parabr.com.xadm.comum.seguranca.*. Os testes que exercitavam API package-private da lib (SessionTokenServiceTest,FirebaseIdTokenValidatorTest,MicronautProfilesTest) foram removidos — a cobertura dessa mecânica agora é responsabilidade do repoxadm-commons. - Política de auth fica local (por desenho). A lib traz a infra; a policy é per-app e
permanece em
br.com.xadm.comum:ViewSecurityRule(tri-estado enabled/bypass/fail-closed),ViewRejectionHandler(302/401/403/503),ApiBearerSecurityRule, e oAuthSupport(whitelist de paths + atributos de request). Mesmo nome de conceito ≠ mesma decisão — a whitelist e o tri-estado são escolhas deste app. StaticBearerTokenValidatorfica local via@Replaces. A lib 0.3.0 já endureceu a comparação (constant-timeMessageDigest.isEqual+ guard de vazio — o feedback §6 do ciclo anterior landou na lib), mas lê${app.api-token}. Este app lê${api.bearer.token}(envINTEGRADOR_API_TOKEN, já injetada por tenant no Coolify) — adotar a da lib exigiria renomear o secret de deploy. O@Replaces(br.com.xadm.comum.seguranca.StaticBearerTokenValidator.class)neutraliza o bean da lib sem duplicar validação divergente. Paridade de segurança preservada.- SUPERADO em 2026-08-21 (
d2c7d60): o bean local foi removido e o app adotou oStaticBearerTokenValidatorda lib, renomeando a property deapi.bearer.tokenparaapp.api-token— a env de deploy continuaINTEGRADOR_API_TOKEN, que era o custo que esta decisão queria evitar; ele não existia, porque o que precisava mudar era o nome da property, não o do secret. O@Replacesdeixou de existir junto. Manter cópia local de validador só para conservar um nome de property contrariava o próprio Baseline que este DR adotou. Com isso o app passou a herdar as guardas de boot da lib: placeholder${...}não resolvido (0.5.1) e, desde a 0.7.2,app.api-tokendeclarado e vazio (BearerTokenGuard) — fora dedev/test, os dois recusam o boot. Estado atual em dev/seguranca.md. auth.issuervirou config. A lib generalizou oISSUERantes hardcoded ("integrador") para${AUTH_ISSUER:integrador}e exige não-vazio no boot (oSessionTokenServiceda lib falha o contexto senão). O default preserva o valor histórico — deploy existente não muda de comportamento.
Consequências. ./gradlew check sobe o contexto com a xadm-seguranca no classpath e a suíte de
auth existente (SecurityRulesTest, AuthSupportTest, LoginControllerTest, GoogleLoginFlowIT,
ViewSessionAuthIntegrationTest, LoginPageRenderingIT, NavbarRenderingIT) segue verde — prova de
paridade. O feedback de timing-attack do StaticBearer está resolvido na lib 0.3.0. Correção de
segurança na casa passa a chegar ao app por bump de versão, não por edição local.
Fora de escopo: a xadm-seguranca em si (repo xadm-commons); adotar CSRF da lib (o
SessionAuthenticationFetcher da lib popula o atributo auth.csrf, mas a verificação segue onde
está — sem mudança de comportamento de CSRF neste ciclo).
Revisto: o 0019 (seguranca 0.4.0) adotou o CSRF de sessão via
CsrfTokens e deduplicou o AuthSupport local (constantes/isHttps vêm da lib; a whitelist
virou ViewWhitelist).
0019 — Bumps comum-web 0.5.0 + seguranca 0.4.0: dropa handlers, dedup AuthSupport, adota CSRF de sessão¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-19 · Decidido em: 2026-08-19
Contexto. As libs da casa evoluíram cobrindo o que o app ainda espelhava localmente. A
xadm-comum-web 0.5.0 fez o UnifiedErrorResponseProcessor logar no ponto único (5xx em
ERROR com a causa-raiz → pega o SentryAppender; 4xx em DEBUG) — feedback §6 do ciclo do
0017 que landou na lib. A xadm-seguranca 0.4.0 extraiu o
contrato byte-idêntico do AuthSupport (constantes de atributo/cookie + isHttps/
deveRecusarAnonimo) e trouxe o CsrfTokens (token CSRF derivado da sessão, exposto pelo
SessionAuthenticationFetcher no atributo auth.csrf). É a continuação da iniciativa libs+contrato
(0017/0018).
Decisão.
- Dropar
ServerExceptionHandler/UnauthorizedExceptionHandler. Com o log no processor da lib, os@ExceptionHandlerdedicados viraram recópia.ServerException/UnauthorizedExceptionpassaram aextends HttpStatusException implements PortadoraDeProblema(carregam status +codede máquina); oUnifiedErrorResponseProcessorrenderiza oproblem+jsone loga o 5xx→Sentry. Corpo byte-idêntico (o default PT de mensagem nula migrou para dentro da exceção); provado porExceptionRenderingIT. Nuance aceita: 401 loga emDEBUG(eraWARNno handler) — erro de cliente, esperado.GlobalExceptionHandler(página HTML de erro p/ browser) eViewRejectionHandler(policy de auth) ficam — a lib só emiteproblem+json. - Dedup do
AuthSupportlocal. Constantes (AUTH_EMAIL_ATTR/AUTH_NAME_ATTR/AUTH_CSRF_ATTR/SESSION_COOKIE) +isHttpsvêm da lib (br.com.xadm.comum.seguranca.AuthSupport). A whitelist de views (policy de rota do app) saiu para uma classe própriaViewWhitelist— mesmo nome de conceito ≠ mesma decisão. OdeveRecusarAnonimolocal (código morto) foi removido. - Adotar CSRF de sessão nas escritas das views.
CsrfTokens.verifyfoi dobrado noUiProductionGuard(assertCrudWriteAllowed(csrf)/assertDebugAllowed(csrf)) — o mesmo call-site que todo@Postde escrita já invocava.GlobalViewModelexpõecsrfToken(do atributoauth.csrf); todo form de escrita (form.htmlsave +list.htmldeletar/restaurar + os POST JS do/debug) renderiza o campo oculto_csrf. Overifyé no-op em dev/test (auth em bypass, o atributoauth.csrfnão é setado) e exige o token em produção (403 em divergência). Provado porCsrfProtectionIT(sem token → 403, token errado → 403, token correto → passa). O login CSRF (double-submitxadm_login_csrfdoLoginController, fase pré-sessão) é outro mecanismo e fica como está. - Corrige gap pré-existente de guard. 5 handlers de escrita (
ItensLote.delete,ItensPed.save,NotaCompl.delete,TbpcoEst.save,Veiculos.save) não chamavam oUiProductionGuard— aViewSecurityRulejá barrava anônimo em prod, mas a defesa-em-profundidade faltava e sem ela não haveria verificação CSRF nesses endpoints. Agora todos os 42 POST de escrita das entidades (14 × 3) - os 3 do
/debugpassam pelo guard com CSRF.
Consequências. ./gradlew check sobe com comum-web:0.5.0 + seguranca:0.4.0. Correção de
segurança/erro na casa chega ao app por bump. A superfície de escrita das views deixou de ser
CSRF-vulnerável. A PortadoraDeProblema é o seam de erro do app daqui pra frente (novas exceções de
domínio só a implementam).
Fora de escopo: as libs em si (repo xadm-commons); CSRF do endpoint GET /propriedades/sync-nome-curto
(escrita disparada por GET — anomalia pré-existente, sem form/token; fica para um saneamento próprio
do verbo).
0020 — GraalVM native-image (CE 25)¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-20 · Decidido em: 2026-08-20
Contexto. O integrador roda como shadowJar na JVM em 6 deployments Coolify (um por cliente,
mesmo binário). Compilar para GraalVM native-image derruba RAM e boot por instância — economia
×6 — e move o build spike para off-host. O Micronaut já é nativo por design; os bloqueadores eram
duas libs sujas em native no grafo de alcance: Apache POI (export XLSX, arrasta XMLBeans +
java.awt.Font) e firebase-admin (push FCM, gRPC/reflexão).
Decisão.
- Export POI → FastExcel writer (
org.dhatim:fastexcel:0.20.2, StAX, sem XMLBeans/AWT).TableExportServicereescrito comWorkbook/Worksheet.value/finish. POI removido por completo — inclusive de teste: o teste de caracterização reabre o XLSX comorg.dhatim:fastexcel-reader(ReadableWorkbook), não POI. OruntimeOnly log4j-to-slf4jsaiu junto (existia só porque o POI trazia log4j-api). Padrão da casa (norma 0023, mesmo dobi-transporte-xls). - Largura de coluna heurística. FastExcel writer não tem
autoSizeColumn(o do POI usavajava.awt.Font, morto em native). Substituído porws.width(col, maxChars)commaxChars= maior contagem de char da coluna, teto 60. Mudança cosmética explícita (constituição §6/REGRA 3): as larguras não batem 1:1 com o autoSize antigo; o conteúdo tem paridade (provada no teste de caracterização). O autoSize antigo já era best-effort (try/catchignore). - Perfil de build native. Bloco
graalvmNative { binaries { named("main") } }:-PnativeQuick→-Ob(quick build, loop de diagnóstico) / sem a flag →-Os(otimiza tamanho, release);--gc=serialsempre (menor footprint de RAM). Reaproveita otoolchainDetection = falsee odockerfileNative.jdkVersion = "25"já presentes. - Reachability metadata só onde faltou. O
nativeCompilepassou de primeira, sem reflect-config manual: Micronaut + o GraalVM reachability-metadata repository cobrem firebase-admin (grpc-netty- shaded + protobuf entram na imagem) e o Sentry. O gap era de runtime, no boot: o logback instancia appenders por reflexão. reflect-config emsrc/main/resources/META-INF/native-image/br.com.xadm/integrador/registrandoConsoleAppender,RollingFileAppender,TimeBasedRollingPolicy,PatternLayoutEncoder,SentryAppender. E resource-config incluindofirebase-admin-sdk.json— carregado viagetResourceAsStreamem runtime, não auto-detectado; sem ele o push desabilitaria silenciosamente (default seguro: incluir mesmo que em prod a credencial venha de disco). - FCM: metadata bastou, sem rewrite. O
send()do FCM é HTTP v1 REST por baixo, não gRPC (o poço gRPC é do Firestore, que o app não usa). O fallback previsto — reescreverFcmPushServicepara FCM HTTP v1 cru — não foi acionado: compile inclui firebase-admin e o boot não quebra (init lazy).
Consequências / trade-offs.
- Imagem native
integrador:latest251MB (vsintegrador:preJVM 624MB). Boot limpo em native (/health→{"versao":"1.3.5","status":"UP"}), smoke do export verde (GET /export/xlsx/municipio→ 200, XLSX válido; tabela inexistente → 404). dockerBuildNativeexige--no-configuration-cache. Com o config-cache ligado (gradle.properties) a taskdockerfileNative/generateResourcesConfigFilefalha na serialização (bug Micronaut+Gradle). OnativeCompilenão encadeia essa task e passa com o cache. O build do Coolify deve passar a flag (ou desligar o config-cache) no caminho native.- Rollout incremental por instância (as 6 são o mesmo binário): subir 1 piloto — a de menor tráfego — validar, depois as demais. Rollback por instância.
- WARNINGs de grpc-netty-shaded no build (negotiators internos não encontrados) são benignos: os caminhos gRPC não são exercitados (FCM é HTTP v1).
Verificação. ./gradlew check verde (checkstyle + Testcontainers + piso JaCoCo). nativeCompile
-PnativeQuick verde; binário boota limpo e passa smoke dos caminhos quentes; dockerBuildNative
--no-configuration-cache gera a imagem. Envio FCM real em native não exercitado (dev tem
push.enabled=false; exige credencial + FCM de prod) — fica para o smoke da instância piloto.
Fora de escopo: rewrite do FcmPushService para HTTP v1 (fallback não acionado); app central
notificacao.xadm.biz (centralizaria email/push/telegram/wa; longo prazo); o e2e funcional (vive e
roda em outro repo). Ver o runbook de build em dev/native-image.
0021 — Views em JTE no lugar de Thymeleaf¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-08-26 · Decidido em: 2026-08-26
Contexto. As telas server-render do integrador (estoque, fretes, fórmulas, itens de
pedido, contratos…) rodavam em Thymeleaf (micronaut-views-thymeleaf). Sob GraalVM
native-image (decisão 0020) o Thymeleaf/OGNL resolve todo acesso
a modelo por reflexão → cada tipo alcançado por um template exige @ReflectiveAccess, a falha
aparece em runtime (500), tela por tela, e o conjunto drifta a cada view nova. É
whack-a-mole de reflexão. A casa fixou o alvo na ADR central
0025 — Views server-render com JTE.
Decisão. Adotar JTE (gg.jte) como motor das views server-render, substituindo o Thymeleaf, seguindo a central 0025. Template compilado, type-check no build, zero reflexão de modelo em runtime.
Consequências (específicas do app).
- Plugin
gg.jte.gradle3.2.4 casado ao runtimemicronaut-views-jte; templates emsrc/main/jte/**,generateJteantes docompileJava. generate()mode — os.javasão emitidos embuild/generated-sources/jtee compilados junto do app (native-safe; sem.bincarregado por reflexão em runtime).contentType = Html(escape HTML por padrão).reflect-config.jsonde reachability-metadata dos templates gerados emsrc/main/resources/META-INF/native-image/jte-views/.- Checkstyle e JaCoCo excluem
**/gg/jte/generated/**(código gerado, não humano). - Composição de layout via
kit/JTE; os globais de view continuam noGlobalViewModellocal (br.com.xadm.comum).
Fora de escopo: o motivo e as alternativas da migração (vivem na central 0025).
0022 — Adota o smoke de produção pós-deploy¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-09 · Decidido em: 2026-09-09
Contexto¶
Este RD não decide nada novo: o smoke pós-deploy é norma da casa (constituição §9, ADR central 0031, página smoke-producao). O que faltava aqui era o rastro local — o que este app declarou como crítico, e por quê.
O problema alcança este repo em cheio: o POST /api/ci/deploy é assíncrono, então o pipeline
ficava verde antes de o container novo servir. E /health verde é liveness — prova que o
processo subiu e o Flyway passou, não que o app responde o que deveria. A frota já entregou, com
o container healthy, tela nova em 500 e <APP>_API_TOKEN ausente que só apareceu como 401 no
cliente.
Decisão¶
Adota o smoke declarando o manifesto no docs/app.json:
"smoke": {
"bases": [
"https://int.maxsul.xadm.biz", "https://int.onpetrotrading.xadm.biz",
"https://int.pontual.xadm.biz", "https://int.sulplata.xadm.biz",
"https://int.thoms.xadm.biz", "https://int.vantroba.xadm.biz"
],
"glitchtip": { "org": "x-adm", "project": "integrador" },
"routes": [ { "path": "/health", "class": "public", "status": 200 } ]
}
Três escolhas locais, com o porquê:
baseslista as SEIS instâncias. Um repo, uma imagem, seis recursos Coolify (integrador.<cli>-jar-pull) — é exatamente o caso que a norma chama de "onde deploy parcial se esconde". Declarar só uma faria o smoke ficar verde com cinco clientes na versão velha.- Divergência entre bases NÃO reverte, e isso é deliberado. O
POST /api/ci/deployancora por imagem e não tem eixo de instância: uma reversão reverteria as seis. Se as bases divergirem no commit anterior, o smoke reporta drift e falha sem reverter — achado operacional, não detalhe a resolver escolhendo uma base. - Só
/healthnesta onda. A whitelist anônima do app tem/health,/login,/logout,/swagger/**,/css/**,/images/**; toda a API/api/v1/**é M2M. Rotam2mexigiriaSMOKE_M2M_TOKEN= oINTEGRADOR_API_TOKENdo callee no secret do repo — e foi a ausência desse token que produziu o 401 nopied.maxsulem 2026-08-29. Vale ligar, mas como passo próprio: o token é credencial de produção, não detalhe de manifesto.
Consequências¶
GLITCHTIP_API_TOKEN(read-only) é obrigatório nos secrets do repo no GitHub. Declararsmoke.glitchtipsem o secret REPROVA o smoke — é defeito de configuração, não degradação: sem ele a camada 3 não roda e o job ficaria "verde provando 3 de 4".- Bump
xadm-comum-web0.6.1 → 0.9.0: emite ocommitno/healthe resolve oreleasea partir deXADM_COMMIT. Sem ele a camada 1 degrada paraversaoe o rollback fica sem alvo. - O
/healthde cada uma das seis instâncias é lido antes doPOSTde deploy. Instância fora do ar entra sem commit e vira aviso — a instrumentação do smoke não derruba o deploy. - O
commitdo/healthpassa a ser o discriminador de entrega:XADM_COMMITentra como build-arg no pipeline e viraARG/ENVtarde no Dockerfile (declarado no topo, invalidaria o cache de toda camada abaixo a cada commit). Um identificador só atravessa pipeline, imagem,/healthe tag imutável. - Reprovar reverte para
<imagem>:<sha anterior>-<target>e confirma a reversão repolando o/health. Isso torna a tag imutável um ativo operacional: podar a tag que está no/healthde um recurso em produção transforma o rollback em "não confirmado" no pior momento. - Este RD é ponteiro: divergência de mérito sobre o mecanismo se resolve no ADR central 0031, não aqui. O que se decide localmente é o manifesto — quais rotas não podem quebrar.
0023 — Exceção sob comando à mão única do write-back¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-09 · Decidido em: 2026-09-09
Contexto¶
A decisão 0013 fixou a mão única do fluxo de entrada PIED: o
upsert do PUT /api/v1/xadm nunca sobrescreve o que o X-Adm gravou pelo write-back —
cod_retorno, msg_retorno, chave_*. A regra existe por um motivo caro: sem ela, todo re-push
de rotina apagaria o retorno de um pedido já gravado no ERP, e o integrador-client o mandaria
de novo — pedido duplicado.
O efeito colateral só apareceu em produção. 1XX é erro terminal: o integrador-client coleta
cod_retorno = '000' OR LIKE '9%' e nada mais. Junte as duas coisas e o resultado é que
reimportar o pedido no painel do int-pied nunca destrava um 1XX — os pushes chegam, são
aplicados, e o cod_retorno continua o mesmo. Do lado do operador, o botão dizia "ENFILEIRADO" e
nada saía: pedido 260079421, 2026-09-08, três cliques, zero reenvios.
A saída era UPDATE na mão no banco de produção, por quem tem acesso — o que não escala e não
deixa rastro.
Decisão¶
Abrir uma exceção sob comando à mão única: POST /api/v1/xadm/retorno/pedido/reenfileirar?xPed=
devolve a 000 (e limpa msg_retorno) as linhas do pedido que morreram em 1XX.
Sob comando é o ponto: a mão única continua valendo para todo caminho automático. O que muda é que passa a existir um caminho explícito, autenticado e auditável para o operador destravar o pedido depois de corrigir o dado na origem.
Recorte deliberado:
- Só
1XX. O filtrocod_retorno LIKE '1%'é o que torna a operação segura.006(já gravado no ERP) re-armado faria o cliente reenviar cadastro que o ERP já tem — duplicaria, exatamente o que a mão única evita.9XXjá retenta sozinho.001(em processamento, marcado pelo cliente) começa com0e não é alcançado. - Só as três tabelas chaveadas por pedido —
contratos,itensped,formulas.propriedadesefonessão chaveadas pelo documento do cliente;estoque, pelo produto. Linha delas presa em1XXcontinua sendo SQL na mão — caso raro, e a alternativa (re-armar por chave de cliente) atingiria pedidos que ninguém pediu para mexer. - Não toca linha soft-deletada. A contagem devolvida é o que o painel mostra ao operador, e ela não pode incluir linha que, para ele, não existe mais.
- Uma transação para as três. O PowerSync replica no commit, então o cliente nunca vê as três tabelas em estado intermediário.
Quem chama é o botão Reimportar do painel do int-pied, sempre depois do push do pedido — a
linha só volta a ser coletável já com o dado corrigido. O curl do
runbook é o mesmo comando na mão, para diagnóstico.
Consequências¶
Zero não é erro. Pedido inexistente e pedido cujas linhas estão todas em 006 respondem igual:
200 com contagens zeradas. 404 seria pior — obrigaria quem chama a distinguir "não existe" de
"nada preso", que para o operador é a mesma frase: não havia o que reabrir.
Uma corrida aceita, não resolvida. O write-back do integrador-client pode carimbar 1XX de
novo logo depois do re-armar. A operação roda sob o XADM_LOCK, mas esse lock não cobre esse
caminho: ele serializa contra o applyPut, que por definição da mão única não escreve
cod_retorno. Quem escreve é o PowersyncController, que não o pega. Estender o lock até lá
significaria serializar o write-back de todos os clientes — caminho quente do sistema — para
fechar uma janela estreita numa operação idempotente, em que o operador vê a contagem e repete.
Trocaríamos uma corrida rara por um gargalo permanente. Fica declarado em vez de escondido.
A 0013 continua em pé. Esta decisão não afrouxa a mão única; nomeia a única porta por onde ela pode ser aberta, e por quem.
Alternativas descartadas¶
- Deixar o upsert zerar
cod_retornoquando o payload muda. Reabre exatamente o buraco que a 0013 fechou: o X-Adm reenvia payload idêntico de rotina, e "mudou" é frágil de definir sobre camposCHARcom padding. - Endpoint que aceita o
cod_retornoalvo. Poder mais amplo do que o problema pede — e o único alvo sensato é000. Parâmetro livre convidaria a re-armar006. - Job que re-arma
1XXsozinho depois de N minutos.1XXé terminal porque alguém precisa corrigir o dado; re-armar sem correção só recria o mesmo erro em laço.
Revisão (2026-09-17) — re-arme automático de cadastro compartilhado com dado novo¶
O caso ADEMIR STEIN (pedido 260081441) mostrou o outro lado da mão única: o PUT de 16/09
atualizou o endereço no espelho (frete novo), mas a linha ficou 006 — e o client só coleta
000/9XX, então o ERP nunca viu a correção. Divergência silenciosa espelho × ERP, sem caminho
operacional (o re-arme sob comando só toca 1XX).
Exceção automática, mais estreita que a alternativa descartada acima — por isso não reabre o buraco da 0013:
- Só cadastros compartilhados (
propriedades,fones,estoque— chave por documento/produto). Linhas de pedido (contratos,itensped,formulas) mantêm a mão única absoluta: re-armá-las duplicaria pedido no ERP, que é o que a 0013 evita. - Só
006com dado realmente mudado.000/9XXjá são coletáveis;1XXsegue com o operador. "Mudou" é comparação insensível a padding/blank (mesmoTexto) e a escala (compareTo) — re-PUT idêntico sossega no006(cobertura emFluxoEntradaIngestIntegrationTest). - O reset limpa
msg_retornoe carimbaupdated_at(mesmo gesto do re-arme por remessa).
Acordo com a equipe X-Adm (Tiago): divergência de endereço em cliente existente se resolve com
uma linha, código 0, endereço do frete — o que o pAbast faz com ela é combinado lá, não aqui.
0024 — Manifesto de remessa¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-09 · Decidido em: 2026-09-09
Contexto¶
O ERP rejeita filho sem pai, em silêncio e de forma terminal (1XX), e o coletor lê só
000/9XX — a linha rejeitada fica invisível para sempre. O ingest é atômico no Postgres, mas essa
atomicidade se perde na fronteira do PowerSync: linhas da mesma transação chegam ao cliente em
checkpoints diferentes.
Três formatos do mesmo defeito, todos medidos em produção em dois dias:
| pai ausente | pedido | evidência |
|---|---|---|
propriedades (cliente) |
260079421, 08-set |
120-Cliente CPF 072.508.149-07 não cadastrado. |
contratos (pedido) |
260079618, 09-set |
120-Pedido (10738.24) não cadastrado. |
estoque (produto) |
260078864, 09-set |
120-Produto para o pedido (…) não cadastrado. |
A cascata é 1:N e silenciosa: no 260079421 uma linha ausente produziu nove terminais; no log
inteiro, 53. E o coletor monta um arquivo com vários pedidos, então a completude tem de ser
avaliada por unidade de negócio, nunca pelo arquivo.
Do lado do cliente, "a linha ainda não chegou" e "a linha não existe" são indistinguíveis — logo nenhuma regra local resolve. O Integrador, no instante do apply, já sabe o conjunto exato.
Decisão¶
Declarar a unidade de entrega na origem: integracao_remessa + integracao_remessa_item, criadas na
mesma transação do apply e replicadas junto com as linhas.
- Uma remessa por grupo com dependência (≥2 linhas ligadas), não por push. Linha solta segue coletável — o manifesto acrescenta garantia, nunca remove, e é o que torna o rollout não-quebrante.
- Uma remessa viva por
(origem, chave_negocio)— invariante de banco (índice único parcialWHERE status <> 'ENTREGUE'). É o que faz o resgate fechar: o re-push reusa a travada e o re-arme a reabre, sem colisão. - Estado derivado por UMA função, chamada do apply, do soft-delete, do write-back e do re-arme.
Uma fonte só é o que impede o drift de
status,total_itens,bloqueadoeresolvido. - O re-arme é por remessa, não por tabela: alcança o pai e a remessa de cliente.
Alternativas descartadas¶
Estado 002 ("aguardando pai") na linha. Foi o primeiro desenho. Exigiria inventar um código
numa coluna compartilhada com o ERP — homologação com o dono do pAbast — e punha a elegibilidade na
linha, não na unidade. O manifesto a torna desnecessária.
Gate de FK no cliente. Cinco regras hoje, quinze no fluxo completo, cada uma escrita por quem talvez não conheça a semântica — e regra errada trava produção em silêncio. Pior: não resolve o caso da linha que ainda não chegou, que é o caso dos três incidentes. O manifesto é O(1) no cliente.
bloqueado deduzido pelo cliente. O princípio descartado era o cliente inferir dependência,
não modelar dependência. O servidor já tem o grafo; ele marca a flag e o cliente só a lê — a
precisão do gate de FK sem o acoplamento.
Consequências¶
O valor depende de três repos. Este declara; o integrador-client (.ia/003 dele) decide o que
entra no arquivo; o maxsul/powersync (.ia/002 dele) transporta; o maxsul-pied (.ia/009 dele)
reconcilia. Ordem de deploy: este repo primeiro (a V27 cria as tabelas que as sync rules
referenciam), depois powersync, cliente e pied. Qualquer ordem é segura — os três degradam para o
comportamento de hoje —, mas só esta entrega valor.
Uma janela fica aberta, e é do consumidor. Linha que chega antes da remessa dela é, para o
cliente, indistinguível de linha solta. Fecha-se com carência de 3 ciclos no integrador-client; o
servidor não tem como resolver, porque a remessa nasce na mesma transação e quem as separa é o
PowerSync.
A derivação roda no caminho quente do write-back — um PATCH por linha, cada um recalculando as remessas que a contêm. Dezenas de linhas por remessa no volume atual; medir antes de otimizar.
As tabelas levam prefixo integracao_, convenção nova neste repo: as três de controle
existentes (request, push_enviada, mensagem_m2m) não têm prefixo. Adotada de propósito — num
banco que tende a ser réplica do ERP, a fronteira precisa estar no nome. É candidato a §6: a casa não
normatiza nome de tabela auxiliar em base-espelho.
Verificação¶
Regressão dos três incidentes por número de pedido, contra Postgres real: a remessa inclui o pai em
cada um dos três casos, e o re-arme alcança o pai — que era o beco do desenho anterior. Mais a
derivação (pai bloqueia filho, folha não bloqueia irmã, soft-delete e órfão resolvem) e o contrato
do endpoint (bloqueado separando causa de colateral).
0025 — Remessa resolvida à mão¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-10 · Decidido em: 2026-09-10
Contexto¶
A 0024 derivou o estado da remessa dos cod_retorno das linhas,
por uma função só. TRAVADA sai de lá por um caminho só, o re-arme, que parte do pressuposto de que
o operador corrige o dado na origem e reenvia.
O incidente dos 15 pedidos-kit (2026-09-10) mostrou um travamento que não se resolve assim. O
contrato está em 006 e os itens e fórmulas em 120, porque o ERP não criou o produto-kit e o
estoque ficou com um 006 falso. Parte desses pedidos só se resolve lançando à mão no X-Adm. O
maxsul-pied ganhou o botão "Importado manualmente" (ERRO_XADM → IMPORTADO_MANUAL), mas a remessa
continuava TRAVADA no Integrador para sempre:
- no bucket do PowerSync (
status <> 'ENTREGUE'); - no aviso de travada do integrador-client;
- no manifesto, acumulando lixo.
Nenhum caminho a fechava. O re-arme a reabriria e reenviaria as linhas, que é o oposto do que se quer.
Decisão¶
Um estado terminal por comando: RESOLVIDA_MANUAL, gravado por
POST /api/v1/integracao/remessas/resolver-manual?origem=INT-PIED&chave=, com observação obrigatória
no corpo.
- Só a partir de
TRAVADA.ABERTAainda está em entrega, e fechá-la enquanto o cliente manda as linhas arrisca duplicar no ERP:409. - A derivação não o escreve nem o sobrescreve. O
recalcularsai cedo diante dele, e a escrita do derivado virouUPDATE … WHERE status <> 'RESOLVIDA_MANUAL'. O write-back não pega oXADM_LOCK, e sem oWHEREuma derivação que leu a remessa antes do comando a reabriria depois dele. - Viva passa a ser a lista fechada
('ABERTA','TRAVADA'), no índice único e nas consultas, e não mais<> 'ENTREGUE'. Com o filtro aberto, o próximo push reusaria a remessa resolvida e a derivação a reabriria. - O re-arme da chave responde
409. Sem remessa viva, ele cairia no fallback legado porpied_x_pede re-armaria as1XXde um pedido já lançado no X-Adm. O cliente o mandaria de novo. - As linhas não são tocadas. As
1XXseguem1XX, o cliente não as coleta, e a mão única da 0013/0023 segue valendo. - A auditoria mora na remessa (
resolvida_em,resolucao_obs,resolucao_operador), e não emrequest: oRequestCleanupJobapaga osSUCESSOcom mais de 90 dias. Ooperadorvem do corpo, porque o Bearer é estático por instalação e não identifica pessoa.
Alternativas descartadas¶
Marcar os itens resolvido = true para a derivação cair em ENTREGUE. Perde a distinção "o ERP
aceitou" × "o operador resolveu por fora". Além disso, o resolvido é derivado do cod_retorno a
cada derivação: a próxima desfaria a marcação, a menos que ele virasse estado persistido.
Gravar ENTREGUE e marcar a resolução manual em colunas à parte. Não exigiria mudança fora deste
repo, e o bucket limparia na hora. Mas ENTREGUE deixaria de significar "todas as linhas 006", que
é o que o contrato publicado afirma. O maxsul-pied e qualquer métrica contariam como entregue um
pedido que o ERP recusou.
Aceitar também ABERTA. Dá mais poder ao operador do que o problema pede, e abre a janela de
duplicidade.
Consequências¶
Três repos acompanham, mas em qualquer ordem. O integrador-client trata status desconhecido como
não travado: a remessa sai do aviso, e as linhas, em 1XX, continuam sem sair. O maxsul-pied já
ignora IMPORTADO_MANUAL. O que falta é limpeza:
- a sync rule do
maxsul/powersynctrocastatus <> 'ENTREGUE'por(status = 'ABERTA' OR status = 'TRAVADA')nas duas queries do manifesto, para a resolvida sair do bucket (OR encadeado, nãoIN: a 1.23.3 rejeitaINcom lista literal); - o integrador-client alinha o filtro local de remessa viva e passa a conhecer a constante;
- o maxsul-pied chama o comando no clique "Importado manualmente".
A exceção à derivação única é nomeada, não difusa. A 0024 continua em pé. Um estado só escapa da função, e escapa por uma porta só, sob comando e com auditoria.
Deferido: reabrir o estoque 006 falso (incluir=estoque no re-arme). Só vale se a Maxsul
preferir reprocessar a lançar à mão, e hoje a escolha é lançar à mão.
Revisão (2026-09-10) — dois caminhos de duplicidade achados na revisão cruzada¶
Antes do primeiro deploy, a revisão dos quatro repos (este, integrador-client, powersync e maxsul-pied) achou dois jeitos de a resolução à mão duplicar o pedido no ERP, o oposto do que ela promete:
- Linha pendente numa
TRAVADA. O filho de um pai1XXfica000, bloqueado. Resolver tirava a remessa do manifesto (sync rule e cliente só leem as vivas), e o cliente passava a mandar esse filho por presença — ex. o contrato de um pedido cujo cliente voltou120, lançado à mão depois de cadastrar o cliente. Decisão: oresolver-manualtambém responde409quando algum item da remessa está pendente (EstadoDoItem.pendente(): nem006, nem1XX, nem deletado, nem órfão). Resolver à mão fica para o que nada mais vai mandar; o resto se resolve corrigindo o dado e re-armando. Os 15 kits passam (contrato006, itens e fórmulas120). - O backfill de startup ressuscitava a resolvida. O
BackfillRemessaJobroda em toda subida e junta as linhas não-006de cada pedido na remessa viva — que ofindVivajá não acha para a resolvida. Cada restart criava umaTRAVADAnova com as1XX, e com uma viva de novo na chave o409do re-arme deixava de valer. Decisão: o backfill pula a chave que temRESOLVIDA_MANUAL, e, como defesa em profundidade, o re-arme por remessa não reabre linha decontratos/itensped/formulasque também seja item de uma resolvida. Os pais (propriedades/fones/estoque) ficam de fora dessa exclusão de propósito: são compartilhados entre pedidos, e excluí-los travaria o re-arme de outro pedido do mesmo cliente.
Verificação¶
RemessaResolverManualTest, contra Postgres real, cobre: TRAVADA vira RESOLVIDA_MANUAL com
auditoria e sem tocar as linhas; a derivação não reabre, nem com a linha indo a 006; ABERTA dá
409; sem remessa viva dá 200 com zero, idempotente; a validação dá 400; o re-arme depois da
resolução dá 409 e deixa as 120 intactas; uma remessa nova da mesma chave não colide no índice; a
consulta traz a resolução. RemessaAuthTest cobre o 401.
0026 — Heartbeat do integrador-client: repasse ao central¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-10 · Decidido em: 2026-09-10
Contexto¶
O integrador-client passou a mandar um heartbeat (versão, fluxos, hostname) para que o central-backend
saiba quais instalações estão vivas e alerte o silêncio. O porquê da feature inteira — rota em dois
saltos por esta instância (o firewall do cliente só libera int.<cliente>), contrato congelado entre os
quatro repos, alerta por horas úteis — é da
decisão local 0028 do central-backend.
Esta decisão registra só o que é deste salto: como o Integrador recebe, repassa e responde.
O salto tem um problema de observabilidade. Ele tem duas classes de falha com pesos opostos:
misconfiguração local (env vazia, CLIENTE_ID errado, token divergente), que precisa virar ERROR no
GlitchTip, e o central fora do ar, que é transitório, se repete a cada hora em cada instância e não pode
virar issue. O caminho padrão da casa (lançar exceção e deixar o UnifiedErrorResponseProcessor
renderizar) loga todo 5xx em ERROR e devolve o nome do status como code.
Decisão¶
- Recebe, carimba e repassa sem gravar.
POST /api/v1/heartbeatfica sob o BearerINTEGRADOR_API_TOKENde/api/**, valida o corpo pela tabela do contrato e acrescenta ocliente_idda instância (CLIENTE_ID, a identidade vem do contexto e nunca do corpo). Depois repassa ao central emPOST {CENTRAL_URL}/api/integrador/heartbeat, BearerCENTRAL_API_TOKEN. O DTO de saída é fechado e sai comnull/[]explícitos. - Erros montados no controller. O
HeartbeatControllerdevolveProblemDetailà mão, como oViewRejectionHandler, e escolhe o nível do log:503 heartbeat_desligadoe502 heartbeat_recusadosaem com ERROR;502 central_indisponivelsai com WARN. É a segunda exceção deliberada ao processor neste repo, depois do/api/v1/xadm(erros). O400de validação continua sendo do processor. - Env vazia responde 503; não recusa o boot.
CENTRAL_API_TOKENé segredo de saída: ausente, o app degrada com comportamento declarado (norma engenharia/seguranca). A recusa de boot vale para o validador de entrada. As instâncias sem integrador-client ficam com as duas envs vazias, sem custo. - Sem flag de desligar e sem retry. O desligamento é no client (
heartbeat.intervaloMinutos=0). Perder um heartbeat é irrelevante, porque o próximo cobre — por isso o@Clientnão leva@Retryable. - O 404 do central é conferido. O cliente declarativo do Micronaut não lança exceção no 404: com
retorno
voidele some; comHttpResponse, volta como resposta. Por isso o cliente devolveHttpResponsee o controller confere o status. Sem isso, umaCENTRAL_URLerrada (o proxy responde 404 em HTML) passaria como sucesso.
Alternativas preteridas¶
- Lançar
ServerException/HttpStatusExceptione deixar o processor responder. Mantém um caminho só, mas o central fora viraria uma issue por hora em cada instância, e ocodesairiaBAD_GATEWAYem vez dos códigos do contrato. - Recusar o boot sem
CENTRAL_API_TOKEN/CLIENTE_ID. Obrigaria as instâncias sem integrador-client a cadastrar segredo que não usam, e inverteria a norma de segredo de saída. - Fallback do
CLIENTE_IDpara oCLIENTE. OCLIENTEé nome de exibição (Maxsul) e nunca passa na regra do central; o fallback esconderia a misconfiguração atrás de um erro de validação. @Retryableno cliente. Custa latência para o client e não compra nada: o heartbeat seguinte já é a nova tentativa.
Consequências¶
- Os ERRORs do heartbeat são sempre misconfiguração, e o runbook os mapeia um a um (incidentes comuns).
- Ordem de release obrigatória: este server com a rota antes do jar do client. Ao contrário, cada heartbeat vira 404 autenticado, que a lib loga em ERROR (implantação).
- Mudar o contrato do salto é mudança nos três repos ao mesmo tempo, com as fixtures
src/test/resources/contrato/de cada um.
0027 — Adota o CI 100% GitHub Actions e o deploy pelo control-plane¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-11 · Decidido em: 2026-08-31
Contexto¶
Este RD não decide nada novo. O deploy orquestrado pelo central-backend, disparado pelo CI por
API M2M, é norma da casa — ADR central
0026. O CI/CD num pipeline.yml
único no GitHub Actions também — ADR central
0027. Os dois chegaram aqui no mesmo
dia. O que faltava era o rastro local.
O número desta decisão local coincide por acaso com o do ADR central 0027.
Decisão¶
Em 2026-08-31, dois passos:
- Deploy pelo control-plane (commit
8ef6e39): o CI passou a pedir o deploy ao central-backend, autenticado porCENTRAL_DEPLOY_TOKEN(secret do repo no GitHub), no lugar da ponte SSH forced-command, cuja allow-list de uuid ficava órfã a cada cutover blue-green. - Pipeline único (commit
4f44bb8, mesmo dia): o.github/workflows/pipeline.yml, na variante jar (app.jsonbuild.targets: [jar]), funde gate, docs, build e deploy, e o Forgejo deixa de rodar CI deste repo.
O deploy ancora na imagem fonte.xadm.biz/xadm/integrador-server. O slug da imagem difere do
app_id (integrador), como o
ADR central 0024 permite, e o control-plane
resolve pela imagem. O endereço do control-plane é o domínio estável central-backend.xadm.biz, sem
flavor de build (constituição §9).
Consequências¶
- Um pedido, seis recursos. As instâncias por cliente compartilham a imagem. O central separa os recursos Coolify pela instância derivada do FQDN e deploya todas as instâncias num pedido só.
- O pedido de deploy é assíncrono: o central só enfileira. Quem confirma a entrega nas seis bases é o smoke de produção (decisão local 0022).
- O
pipeline.ymlé template rastreado da casa: muda por re-derivação (/xadm-docs), não por edição solta. - Este RD é ponteiro: divergência de mérito sobre o control-plane ou o modelo de CI se resolve nos ADRs centrais 0026 e 0027, não aqui.
0028 — Native e jar no ar, com a troca feita instância a instância¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15 · Decidido em: 2026-09-15
Contexto¶
A casa deploya o server Micronaut como binário native por padrão, e o jar só com motivo registrado (decisão central 0033). O integrador compilava para native desde a 0020, mas rodava só jar nas seis instâncias, uma por cliente. A 0033 cita como motivo bloqueios de locale, FCM e Jackson no upload, além da falta de e2e native, e nenhum deles estava registrado aqui.
Medição de 2026-09-15: binário -Ob compilado no Windows, Postgres de dev e as mesmas 47 checagens
contra o jar e contra o native (views, estáticos, ingest XADM, upload do PowerSync, heartbeat, export
XLSX, /api/health, OpenAPI).
- Locale: o push formata moeda em pt-BR, e a GraalVM embarca só
en. O build ganhou-H:IncludeLocales=pt-BR. - Jackson no upload: o upload do PowerSync lia o corpo pelo
ObjectMapperdo Jackson, por reflexão. Passou aoJsonMapperdo Micronaut Serde, gerado em compile-time. O ingest XADM (árvore do Jackson) e o registro do/api/health(umMap) passam no binário sem metadata extra. - Paridade: 45 de 47 nos dois. As duas divergências são as mesmas no jar e no native e não vêm do
AOT:
/favicon.icofora da raiz dos estáticos, e/admin/mensageriasem cabeçalhoAccept. - Custo: boot até o
/healthem 3,6 s (native) contra 8,0 s (jar); RSS de 137 MiB contra 365 MiB. O número absoluto muda no Linux; a proporção é o que se mediu. - Fora da medição: o envio FCM real (o push fica desligado em dev), a formatação pt-BR no binário
(só o push formata), o build Linux do
Dockerfile.nativee uma suíte e2e native do integrador.
Decisão¶
Correção 2026-09-15: o reconcile do central-backend lê
int-jar.<cli>.xadm.bizcomo a mesma instância (Coolify, dois recursos no mesmo domínio), e o jar movido para esse host não perde a instância. O jar de troca fica noint.<cli>, dividindo o tráfego com o native, até a instância provar o native; depois vai para oint-jar.<cli>.xadm.biz, e o native fica dono doint.<cli>. O jar só se move quando o reconcile no ar aceita esse host; antes disso, fica noint.<cli>. Procedimento no runbook de implantação.
O build.targets passa a ["native", "jar"], e a troca é feita instância a instância.
- Cada cliente ganha o recurso
integrador-server-<cli>-native-pull, que puxa a tagnative-amd64, com o mesmo domínio da instância (int.<cli>.xadm.biz) como primeiro FQDN. O control-plane tira a instância só do primeiro FQDN e só na formaint.<cli>.xadm.biz: um jar movido para outro domínio perderia a instância e colidiria com os outros cinco no mapa de deploy. - Enquanto os dois estão no ar, o proxy divide o tráfego da instância entre jar e native. A divisão se
mede pelo
flavordo/health. - A troca começa por uma instância com o push ligado, que serve de piloto do FCM. As demais ganham o
-native-pulluma a uma, cada uma só depois de smoke verde e GlitchTip sem exceção nova na anterior. - O jar só para quando sai do
build.targets, numa release própria, depois que as seis instâncias têm native. Enquantojarestiver nobuild.targets, cada release redeploya o-jar-pull, e um recurso parado voltaria a subir. Depois dessa release, o-jar-pullfica parado como fallback.
Consequências¶
- A release builda as duas imagens, e o deploy dispara os dois alvos em todas as instâncias. Os seis
recursos
-native-pullexistem antes da primeira release com native (implantação). - A release só sai depois que o
xadm-commonspublicar as versões de lib que este app declara: oDockerfile.nativebuilda num container limpo, que resolve dependências só pelo registro. - Durante a troca há duas cópias do app no mesmo banco. Elas não duplicam efeito: o relay da
mensageria elege instância, a limpeza diária é idempotente, o backfill do manifesto é barrado pelo
UNIQUEdos itens (uma corrida no boot sai como ERROR no log, sem dado duplicado), e a deduplicação do push passou da memória de cada cópia para uma reivindicação no banco. - O que ficou fora da medição é coberto pela ordem da troca: o FCM real se prova no piloto, e o build Linux e o binário no ar se provam no primeiro deploy, pelo smoke de cada instância. A suíte e2e native do integrador fica como pendência declarada.
- Procedimento de build e diagnóstico: native-image.
Alternativas consideradas¶
- Jar nesta release e native na seguinte, depois de um piloto: preterida pelo dono. A medição já cobre os bloqueios que a 0033 citava, e a troca por instância limita o risco do que não foi medido.
- Só native, na próxima release: o FCM real e o build Linux iriam direto às seis instâncias, sem piloto e sem fallback no ar.
0029 — cliente_id do heartbeat derivado do fqdn¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-20 · Decidido em: 2026-09-18
Contexto¶
A decisão 0026 fixou que CLIENTE_ID não tem fallback: o
CLIENTE é nome de exibição (Maxsul, Sul Plata Trading do Brasil) e nunca passaria na regra do
central (^[a-z0-9-]{1,50}$), então derivar dele esconderia a misconfiguração atrás de um erro de
validação. A consequência aceita foi: env vazia → 503 heartbeat_desligado + ERROR a cada batida.
O custo apareceu na operação. Em 2026-09-18, 5 das 6 instâncias estavam sem CLIENTE_ID — o
heartbeat de cada uma respondia 503 e logava ERROR de hora em hora, e a correção foi cadastrar a env
na mão nas cinco. Isso fecha o buraco daquele dia, não a classe de erro: a env é cadastrada à mão
por instância, então o próximo cliente novo nasce quebrado do mesmo jeito. O erro só aparece depois
que o integrador-client do cliente já está instalado e batendo.
Existe uma fonte confiável que ninguém precisa cadastrar: toda instância tem COOLIFY_FQDN, e ele é
int.<slug>.xadm.biz, onde <slug> é exatamente o cliente_id do central (conferido nas seis).
Decisão¶
CLIENTE_IDexplícito sempre vence. O derivado é só fallback — nada muda onde a env existe.- Faltando
CLIENTE_ID, ocliente_idé o 1º label doCOOLIFY_FQDN, e só quando o fqdn casaint.<slug>.xadm.biz. Qualquer outro formato deixa ocliente_idvazio e o heartbeat segue em503 heartbeat_desligado: melhor desligado do que carimbar o central com o cliente errado. - O fqdn da perna native não casa, de propósito. Ela é
int-native.<cli>.xadm.biz(decisão 0028); lá oCLIENTE_IDexplícito é copiado da perna jar e vence pela regra acima. Derivar dali carimbaria um cliente que não existe. CLIENTEcontinua fora, pelo motivo original da 0026 — esta decisão não o reabilita.- A resolução mora no código (
HeartbeatController), não no YAML: default aninhado em${...}não funciona no Micronaut, que casa o primeiro}e transforma o resto em lixo (engenharia/java-micronaut, Armadilhas). Mesmo padrão dosascar.slug, resolvido noMensageriaEnfileirador. - O boot diz de onde saiu o
cliente_id— env explícita, derivado do fqdn, ou nenhuma fonte.
Alternativas preteridas¶
- Deixar como estava (sem fallback). Mantém a 0026 intacta, mas aceita que todo cliente novo nasça com heartbeat em 503 + ERROR até alguém lembrar da env.
- Fallback para
CLIENTE. Já preterido na 0026 e continua preterido: é nome de exibição. - Derivar sem conferir o formato. Um fqdn de outro formato viraria um
cliente_idinventado, e o central passaria a receber heartbeat carimbado com cliente errado — pior que o 503. - Recusar o boot sem
cliente_id. Inverteria a 0026 sem necessidade: as instâncias sem integrador-client ficam legitimamente sem as envs do heartbeat, e nenhum heartbeat chega nelas.
Consequências¶
- Instância nova nasce com heartbeat funcionando; cadastrar
CLIENTE_IDdeixa de ser passo obrigatório e vira override. - A perna native exige
CLIENTE_IDexplícito na env — está na lista do que se copia da perna jar ao criar o recurso native. - O 503 continua existindo para o caso sem nenhuma fonte, e o runbook de incidentes comuns segue válido.
- Config e precedência: application-config.
0030 — Credencial FCM fora do jar e exigida no boot¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-20 · Decidido em: 2026-09-18
Contexto¶
A credencial do firebase-admin vivia em src/main/resources/firebase-admin-sdk.json, versionada:
chave privada no git e, pelo empacotamento, também dentro do app.jar publicado no registry. A
property push.credentials-path já existia e o FcmPushService já lia de disco, mas o default era
classpath:firebase-admin-sdk.json — o arquivo embutido.
A migração para native (.ia/017) força a mudança: no native a credencial passa a depender de
metadata de recurso, e o jeito de injetá-la muda de qualquer forma. É a hora de tirá-la do build.
Havia ainda um problema de visibilidade. A credencial era aberta na primeira tentativa de push — credencial ausente ou path errado logava WARN só naquele momento. Numa instância que passa dias sem push, uma credencial trocada de lugar fica invisível justamente até a hora em que ela importa. Com a credencial saindo do jar para um secret file montado, "path errado" deixa de ser hipótese remota e passa a ser o modo de falha esperado de um deploy mal configurado.
Decisão¶
- A credencial sai do repo e do jar. Em produção ela chega por
PUSH_CREDENTIALS_PATHapontando para um secret file montado no container. Nenhuma mudança na assinatura doFcmPushService: a property já existia e o serviço já lia de disco. - Com o push ligado, a credencial é conferida no boot e a instância não sobe sem ela. Com
push.enabled=true, um@EventListenerdeStartupEventabre a credencial e lança se ela não abrir, com mensagem nomeando o path e a saída (PUSH_ENABLED=false). - Push desligado não exige credencial, e empresa não reconhecida (que já desliga o push) não derruba o boot.
Isso é o que a norma da casa manda: "property de segredo não declarada → feature desligada;
declarada e vazia → boot recusado, com a env nomeada" (engenharia/seguranca, Segredos).
PUSH_ENABLED=true é a feature declarada ligada. Não conflita com a
decisão 0026, que preteriu recusar o boot por
CENTRAL_API_TOKEN: lá a feature fica desligada nas instâncias que não a usam — o primeiro caso
da norma, não o segundo.
O gate é @EventListener(StartupEvent) e não @Requires(property=…, pattern=".+") porque a
condição não é "a property está vazia" e sim "a credencial abre": o path pode estar preenchido e
apontar para um arquivo que não existe, que é exatamente a falha que se quer pegar.
Alternativas preteridas¶
- Manter o aviso lazy (WARN no 1º push). Zero mudança, mas a troca para secret file é justo quando um path errado passa despercebido — e passaria dias, até o primeiro push.
- Avisar no boot com WARN, sem derrubar. Visível no deploy, mas um WARN em log de startup é lido por quem está olhando; o deploy seguiria verde com o push morto.
- JSON inteiro numa env (
PUSH_CREDENTIALS_JSON). Evita arquivo no disco, mas põe chave privada multiline na env do Coolify, visível emdocker inspect, e exigiria mudar oFcmPushService. - Manter no classpath com a chave rotacionada. Menor mudança de runtime, mas a chave continuaria viajando na imagem do registry — que é o problema de origem.
Consequências¶
- A ordem de deploy passa a importar: a env
PUSH_CREDENTIALS_PATHe o secret file precisam existir antes do deploy que remove o JSON do jar. Deployar antes derruba as instâncias comPUSH_ENABLED=true— hoje, as de push vivo. - Rotacionar a chave exposta no git é pré-requisito, e a rotação é o que invalida o vazamento; tirar do repo sozinho não invalida nada.
- O
resource-config.jsondo native continua incluindofirebase-admin-sdk.json: é inócuo quando a credencial vem de disco e ainda cobre o caminho de teste. - Config: application-config.
Glossário do projeto¶
Vocabulário do domínio da integração que este app usa. Termos da plataforma X-Adm (Forgejo, Coolify, Garage…) não são redefinidos aqui — linkam para o glossário da plataforma.
Sistemas e sincronização¶
- X-Adm
- ERP de mesa (base Zim) onde todos os dados de negócio nascem; sistema-fonte da integração.
- X-Adm Integração Java
- Agente on-premises que lê os arquivos JSON exportados pelo X-Adm e os encaminha via REST/HTTPS (com retry) ao Integrador.
- Integrador
- Este servidor Micronaut/Java; recebe REST, persiste no Postgres, emite push (FCM) e alimenta o PowerSync. Uma instância por cliente.
- PowerSync
- Motor de sincronização self-hosted (Journey Apps) que acompanha o WAL do Postgres e replica para o SQLite local do app; habilita o offline-first.
- lastchange
- Endpoint público de checagem de "banco parado":
MAX(COALESCE(updated_at, created_at))denotacompl.
Contrato REST¶
- XADM (endpoint
/api/v1/xadm) - Contrato REST
PUT/DELETEque o ERP usa para empurrar mudanças de tabela; sempre HTTP 200, resultado no corpo JSON. - Origem
- Código da rotina que gerou a requisição: do X-Adm (
PPEDAMX,PPEDIDO,PBLDI,PNOTAI,PNOTAD,PCSTPROD,PREQUIS) ou do fluxo de entrada PIED (INT-PIED); a whitelistKNOWN_ORIGENSdecide o que vai ao Sentry. - Bearer estático
- Token fixo (
INTEGRADOR_API_TOKEN) que protege/api/**— não é JWT. O nome antigoAPI_BEARER_TOKENnão é mais lido (sem fallback). - Heartbeat
- Sinal de vida que cada instalação do integrador-client manda (versão, fluxos, hostname) no
startup e a cada 60 min a
POST /api/v1/heartbeat. O Integrador não o grava: carimba ocliente_ide o repassa ao central-backend, que acompanha as instalações e alerta o silêncio (contrato). CLIENTE_ID×CLIENTECLIENTEé o nome de exibição do cliente (Maxsul,Sul Plata Trading do Brasil);CLIENTE_IDé o identificador dele no central-backend (maxsul), usado no repasse do heartbeat. Um não substitui o outro — por isso oCLIENTE_IDnão tem fallback.
Domínio e tipos¶
- BL
- Bill of Lading; identificador de embarque. "Saldo dos BLs" (relatório 30 do X-Adm) é uma tela central no mobile.
- ChaveCP / ChaveProp / ChaveEst / ChaveItem / ChaveNota / ChaveLoteEst
- Chaves de negócio compostas do X-Adm para contrato, propriedade (fornecedor), estoque (produto), item de pedido, nota e lote de estoque.
- VastInt(n)
- Tipo numérico escalado do Zim com
ndecimais implícitos; mapeia paraNUMERIC/BigDecimal(ex. VastInt(3) → NUMERIC(15,3)). - Zim
- Motor de banco sob o X-Adm; seus tipos (VarAlpha, Date(8), Char(n)) são espelhados no esquema da integração.
- UUID v7
- UUID ordenado no tempo (RFC 9562,
uuidv7()do Postgres 18); migrado do v4, valor antigo preservado emold_id(migrations V14–V16). - Soft-delete (
deleted,deleted_at) - Exclusão lógica:
DELETEmarcadeleted=true; umPOSTcom a mesma chave restaura (migration V7). - NomeCurto (NomeProdCurto / NomePropCurto / DescricaoCurta)
- Apelidos definidos pelo usuário para produtos/clientes/veículos; vivem só no app, editados no mobile, nunca escritos no X-Adm.
- pAbast (pabast_empresa / pabast_senhas)
- Tabelas legadas de empresa/credencial mantidas para o PowerSync; app, login e JWT foram removidos do integrador (decisão 0001).
- Fluxo de saída / Fluxo de entrada
- As duas direções do espelho do X-Adm. Saída:
X-Adm → Integrador → PowerSync → apps(original, ex. Sul Plata; produtor = X-Adm,Chave*preenchida). Entrada: fonte externa (PIED /INT-PIED)→ Integrador → PowerSync → X-Adm(o dado volta ao ERP;Chave*vazia até o write-back). Mesmas tabelas, mais colunas (decisão 0013). - Chave dupla
- Estratégia de upsert que resolve a linha pela
Chave*do X-Adm quando o produtor a fornece (saída) e pela chave natural da fonte (xPed/pied_codigo_alt/CgcCpf) quando ela chega vazia (entrada). Noestoque, a saída resolve porChaveEstou, quando ele é null (Thoms), porCodProd. - CodProd (identidade do produto)
- Identidade do produto no X-Adm quando
ChaveEstchega vazio (instância Thoms): versão virtual (substring) doChaveEst. Gravado como recebido no PUT de saída — sem processamento sobre a chave (decisão 0013). - PiedCodigoAlt (transporte)
- Código do produto na PIED (ex-
codigo_alt), armazenado só para rastreabilidade e como chave natural de correlação do fluxo de entrada. Não é a identidade do produto no espelho — o espelho é cópia fiel do X-Adm, cuja identidade de estoque éChaveEst/CodProd. - updated_at (chave-versão do estoque)
- Timestamp em
estoque(V21) auto-populado por Micronaut Data (@DateUpdated) em toda escrita de entidade (save/update), por qualquer escritor que passe pelo repositório (apply, tela admin, API, write-back PowerSync). É a versão que o tradutor WebStorm-ecom (Thoms) usa para idempotência. Sem dirty-check — bumpa em qualquer update de entidade. O@Querycru denome_prod_curtonão passa por entidade e não bumpa. Ver decisão 0013. - Retorno do X-Adm (write-back)
- As colunas
Chave*/CodRetorno/MsgRetornoque o X-Adm gera para cada linha do fluxo de entrada e devolve peloPOST /api/v1/powersync; são read-only no caminho de entrada (a fonte não as escreve; o upsert as preserva). Toda tabela que volta ao X-Adm carregaCodRetorno/MsgRetorno; na saída ficam semprenull. - fones
- Tabela de contato do cliente (só fluxo de entrada; chave natural
cgc_cpf); um contato por cliente. Semold_id(tabela nova).
Baixa de MDF-e (macro Sascar)¶
- MDF-e
- Manifesto Eletrônico de Documentos Fiscais; agrupa as NF-e/CT-e de uma viagem. No
espelho vive na tabela
mdf(cabeçalho) +mdfcompl/mdfitens/nfmdf/nfcomplmdf/fretes/nfeevento(contexto). Ingerida peloMDFE_PUT(Origem:PMDFE). - situacao_baixa
- Coluna própria do Integrador na
mdf(não vem do X-Adm):ATIVO(vigiada),BAIXA(encerramento solicitado — comando pendente),ERRO. Distinta doNFEEVENTO(fiel ao X-Adm, só leitura). Ver baixa-mdfe-sascar. - cod_retorno (MDF-e)
- Sinal do comando de baixa que o fluxo
ENCERRA_MDFEdo integrador-client observa namdf:000pendente (setado pelo callback),006executado (write-back do client),1XX/9XXerro. Mesmo contrato de retorno do fluxo de entrada PIED (V18). - watch (Sascar)
POSTdestilado do Integrador ao int-sascar (/api/integrador/mdfe/watch) dizendo quais MDF-e vigiar (status=aberto) e quando tirar do conjunto ativo (status=encerrado). O int-sascar nunca vê o JSON cru do ZIM — recebe status/destino já interpretados.- macro Sascar
- Sinal que o motorista dispara no rastreador Sascar ao fim da viagem; o int-sascar o
detecta e chama o callback
POST /api/sascar/encerramentodo Integrador, que grava a baixa. - ENCERRA_MDFE
- Fluxo
dXpEnviodo integrador-client (no ZIM) que lê o comando de baixa (situacao_baixa=BAIXA/cod_retorno=000) pelo PowerSync e encerra a MDF-e no ERP, escrevendocod_retorno=006de volta. Vive fora deste repo.
Infraestrutura¶
- FCM
- Firebase Cloud Messaging; push para 4 tópicos (2 por empresa:
bl_liberado_*,nota_venda_emitida_*). - Nuvem Wiechert
- Hospedagem/infra de terceiros (Wiechert Suporte Técnico) onde rodam o Integrador, o Postgres e o PowerSync.