Etapa 02 — Idempotência: UPSERT diff-aware¶
Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-06-15
Estende a base da etapa 01: aqui o schema dos fatos vira contrato de UPSERT (chave natural + métricas). A preservação das colunas veiculares é a etapa 04.
1. Contexto e escopo¶
Os fatos (bi_faturamento, bi_movimento) sincronizam para clientes móveis via
PowerSync. Reprocessar um período (reupload do mesmo mês) 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: chaves naturais por fato; pipeline diff-aware
(COPY → TEMP TABLE → UPSERT → DELETE seletivo); métricas finas por upload;
preservação de id/xmin nas linhas inalteradas.
Fora do escopo: preservação das colunas de cadastro veicular (regra especial) → etapa 04.
2. Modelo de dados¶
A identidade de cada fato é uma UNIQUE composta NULLS NOT DISTINCT sobre as
colunas do ERP que o definem — a chave natural (criadas em
V6__chaves_naturais.sql). As PKs são BIGSERIAL (decisão 0005); xmin (system
column do Postgres) é o que o PowerSync observa para propagar mudanças.
erDiagram
bi_faturamento {
bigserial id PK
date dt_frete "idx"
varchar placa "idx"
decimal valor
}
bi_movimento {
bigserial id PK
date data_lcto "idx"
decimal valor
smallint seq_dentro_grupo "desambiguador (V6)"
}
Chave natural — bi_faturamento (uq_bi_faturamento_natural, 10 colunas):
nro_frete, dt_frete, nro_docto, dt_em, placa, segmento, seq_proj, cod_proj,
espec_veic, cod_mot. A V6 fez dedup destrutivo prévio mantendo MIN(id) por
grupo (duplicatas no faturamento eram ruído).
Chave natural — bi_movimento (uq_bi_movimento_natural, 17 colunas): as 16
contábeis (classif_ctb, ident_ger, ident_custo, atrib_custo, ident_rat, db_cr,
placa, segmento, seq_proj, cod_proj, filial, data_lcto, espec_veic, cod_conta,
classif_conta, valor) + seq_dentro_grupo (SMALLINT, NOT NULL, backfill
ROW_NUMBER em V6). O desambiguador existe porque colisões nas 16 colunas são
lançamentos legítimos (mesmo custo repetido no período), não duplicatas — por
isso aqui não houve dedup destrutivo.
Métricas por upload (8 colunas em xls_processamento, V7): para cada fato,
*_inseridas (INSERT puro, xmax=0), *_atualizadas (UPDATE in-place, xmax≠0),
*_inalteradas (enviadas − inseridas − atualizadas), *_removidas (DELETE
seletivo). NULL = upload anterior à spec de métricas.
3. Fluxos principais¶
Pipeline diff-aware na ordem UPSERT → DELETE, por período, dentro de uma
transação (decisão 0007 — COPY + TEMP TABLE no BulkUpserter):
flowchart TD
A[Lote do upload] --> B[COPY → TEMP TABLE]
B --> 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 e xmin preservados]
C --> G["DELETE seletivo: linhas do período<br/>ausentes no upload (usa idx data)"]
- O
WHERE ... IS DISTINCT FROMgarante que só linhas realmente alteradas trocam dexmin— as inalteradas não re-propagam (zero churn no reupload). - O DELETE seletivo remove só o que sumiu dentro do período do upload (apoiado no índice por data), nunca o período inteiro nem dados de outros meses.
- O
ProcessamentoTransactionalHelper.mesclarDadosDoPeriodoorquestra UPSERT + DELETE e devolve oMesclaResumocom as 8 métricas, gravadas emxls_processamentono sucesso.
4. Decisões¶
| Nº | Decisão |
|---|---|
| 0005 | BIGSERIAL como PK nas bi_* |
| 0006 | Chaves naturais + UPSERT diff-aware |
| 0007 | COPY + TEMP TABLE no BulkUpserter |
| 0016 | Período do DELETE vem da aba 10 |
5. Qualidade e observabilidade¶
- Testes
BulkUpserterFaturamentoTest/BulkUpserterMovimentoTest: reupload sem mudança não bumpaxmin(regressão de churn). - As 8 métricas por upload (visíveis no detalhe e na API) tornam o efeito de cada processamento auditável: quantas linhas de fato mudaram.
6. Riscos¶
- Chave natural mal definida → colisão de linhas legítimas; mitigado pelo
seq_dentro_grupoembi_movimento. - UPSERT cego (sem
IS DISTINCT FROM) reintroduziria churn → coberto pelos testes de regressão acima. - GRANT faltando ao
powersync_rolenumabi_*nova → replicação para (42501); ver decisão 0013 e o gotcha no CLAUDE.md.