Etapa 02 — Idempotência: UPSERT diff-aware¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-16
Estende a base da etapa 01: aqui o schema dos fatos e dimensões
bi_*vira contrato de UPSERT (chave natural + métricas). A validação de que Java escreve o mesmo que o Python é a etapa 03.
1. Contexto e escopo¶
Os bi_* (fatos + dimensões) sincronizam para clientes móveis via PowerSync.
Reprocessar um período (reupload do mesmo relatório) não pode gerar churn de
checkpoint: cada linha tocada no Postgres re-propaga para os celulares,
gastando banda e bateria mesmo sem mudança real.
Dentro do escopo: PK UUID v7 + chave natural UNIQUE por tabela; pipeline
diff-aware (BulkUpsert com ON CONFLICT DO UPDATE WHERE IS DISTINCT FROM);
DELETE seletivo por período; métricas agregadas por upload; preservação de id
UUID e xmin nas linhas inalteradas.
Fora do escopo: a conformidade linha-a-linha com o legado Python → etapa 03.
2. Modelo de dados¶
Toda bi_* tem PK id UUID NOT NULL DEFAULT uuidv7() (migration V7, decisão
0008 — PowerSync exige PK single-column
TEXT/UUID) e a chave natural como UNIQUE (código do ERP, CNPJ, ou composta
NF+produto+cliente+filial). As FKs de bi_movimento/bi_meta apontam para as
UNIQUE das dimensões. xmin (system column do Postgres) é o que o PowerSync
observa para propagar mudanças.
erDiagram
bi_cliente ||--o{ bi_movimento : "cod_cliente"
bi_produto ||--o{ bi_movimento : "cod_produto"
bi_filial ||--o{ bi_movimento : "cod_filial"
bi_vendedor ||--o{ bi_movimento : "cod_vendedor"
bi_vendedor ||--o{ bi_meta : "cod_vendedor"
bi_movimento {
uuid id PK "uuidv7()"
integer nf "uq natural"
text cod_produto "uq natural"
text cod_cliente "uq natural"
text cod_filial "uq natural"
decimal margem
}
bi_meta {
uuid id PK "uuidv7()"
text cod_vendedor "uq natural"
date data "uq natural"
decimal litros
}
Chaves naturais (UNIQUE, V7):
| Tabela | Constraint | Colunas |
|---|---|---|
bi_cliente/bi_produto/bi_filial/bi_vendedor |
uq_bi_*_cod |
código do ERP |
bi_posto/bi_trr |
uq_bi_*_cnpj |
cnpj |
bi_movimento |
uq_bi_movimento_natural |
nf, cod_produto, cod_cliente, cod_filial |
bi_meta |
uq_bi_meta_natural |
cod_vendedor, data |
bi_custo_inventario |
uq_bi_custo_inventario_data |
data |
Procedência de
bi_filial: os relatórios XLSX só carregam ocod_filial; município/UF da filial não existem nessas planilhas (as colunas UF/Município referem-se ao destino da venda, que vai parabi_movimento). Por isso oFilialRepositorysó fazensureExistsBatch(INSERT … ON CONFLICT DO NOTHING) — garante a FK sem tocar emmunicipio/uf(cadastro manual viadocs/privado/seed-filiais.sql).
Métricas por upload (3 colunas agregadas em xls_processamento, V9 + V15):
linhas_enviadas(V15): soma das linhas que o app pediu pra persistir, em todas as tabelas tocadas pelo arquivo.linhas_efetivas: soma dos INSERT/UPDATE que de fato escreveram em disco (oint[]doexecuteBatch(), ignorandoSUCCESS_NO_INFO). Conflito filtrado peloWHERE IS DISTINCT FROMretorna 0 e preserva oidUUID +xmin. Mesmo escopo delinhas_enviadas.linhas_removidas: soma dos DELETE seletivos. Separada delinhas_efetivasporque um relatório com 99 linhas idênticas + 1 removida na origem teriaefetivas=0mas 1 DELETE real disparando WAL.
O placar do diff-aware é linhas_enviadas − linhas_efetivas: as linhas que já
estavam idênticas no banco e não geraram WAL nem checkpoint PowerSync. É o número que
justifica todo o desenho desta etapa, e a tela de detalhe o exibe como Inalteradas.
Correção 2026-07-16: até a V15 não havia
linhas_enviadas, e a tela derivava as "Inalteradas" deregistros_processados − linhas_efetivas. As duas colunas estão em escopos diferentes —registros_processadosconta só a entidade principal do arquivo,linhas_efetivassoma todas as tabelas —, então a conta dava negativo (um RELATORIO18 real exibia-98). A V15 acrescentalinhas_enviadasno mesmo escopo delinhas_efetivase a tela passa a usá-la. Sem backfill: uploads anteriores à V15 ficam comlinhas_enviadasNULL e a tela exibe "—" — a informação nunca foi persistida e não dá pra reconstruir.
Granularidade agregada (3 colunas) é divergência legítima do bi-transporte (que usa 8 colunas por-tabela) — ambas válidas.
3. Fluxos principais¶
Pipeline diff-aware na ordem UPSERT → DELETE, por período. O BulkUpsert
usa PreparedStatement.executeBatch() (decisão
0009) — necessário aqui para
preservar o rowCount per-row que alimenta linhas_efetivas.
flowchart TD
A[Lote do upload] --> C["INSERT ... ON CONFLICT (chave natural)<br/>DO UPDATE WHERE ... IS DISTINCT FROM"]
C --> D{Linha mudou?}
D -- sim --> E[UPDATE: novo xmin → PowerSync propaga]
D -- não --> F[no-op: id UUID e xmin preservados]
C --> G["DELETE seletivo: linhas do período<br/>ausentes no upload"]
G --> H[UPSERT primeiro, DELETE depois]
- O
WHERE ... IS DISTINCT FROMgarante que só linhas realmente alteradas trocam dexmin— as inalteradas não re-propagam (zero churn no reupload). - Ordem: UPSERT primeiro (preserva
id), DELETE depois (remove órfãs). - DELETE seletivo:
DELETE … WHERE periodo BETWEEN X AND Y AND NOT EXISTS (…upload…)— só apaga o que sumiu neste período, nunca o período inteiro.
3.1 Cobertura do DELETE seletivo¶
| Processador | UPSERT diff | DELETE seletivo |
|---|---|---|
| RELATORIO18, META, META_FERNANDO, POSTOS, TRR | ✅ | ✅ (por período/catálogo) |
| MARGEM_CONSOLIDADA, MARGEM_DIA | ✅ | ❌ (intencional — reupload só faz no-op) |
reWriteBatchedInserts=falseé obrigatório (env Coolify): com a flag ON, pgjdbc reescreve N inserts em multi-VALUES eexecuteBatch()retornaSUCCESS_NO_INFO(-2), matando a métricalinhas_efetivas.
4. Configuração¶
| Env / Property | Valor | Papel |
|---|---|---|
DATASOURCES_DEFAULT_URL (query) |
reWriteBatchedInserts=false |
preserva rowCount per-row |
| — | PostgreSQL 18+ | função uuidv7() nativa |
A criação de qualquer bi_* nova exige GRANT ao powersync_role (decisão
0013) — sem ele o PowerSync para com
42501.
5. Decisões¶
| Nº | Decisão |
|---|---|
| 0007 | Micronaut Data JDBC, sem Hibernate |
| 0008 | PK UUID v7 nas bi_* |
| 0009 | reWriteBatchedInserts=false |
| 0013 | GRANT ao powersync_role |
6. Riscos¶
- Chave natural mal definida → colisão de linhas legítimas; mitigado pela escolha de chave por domínio (código/CNPJ/composta).
- UPSERT cego (sem
IS DISTINCT FROM) reintroduziria churn → coberto pelos testes dos processadores. reWriteBatchedInsertsligado por engano → métricalinhas_efetivaszera (SUCCESS_NO_INFO); fixado no env.- GRANT faltando ao
powersync_rolenumabi_*nova → replicação para (42501); ver decisão 0013 e as regras para tabela nova no modelo de dados.
Correção 2026-09-14: o ponteiro para o
CLAUDE.md/HOWTO-DEPLOY.mdpassou para a página dona do fato; os dois arquivos deixaram de carregar esse conteúdo.