Skip to content

Manuseio de Grandes Volumes de Dados (SSOT)

Status: Padrão obrigatório a partir de 2026-06-04 Aplicabilidade: TODA Edge Function que consulta tabelas que podem crescer com volume escolar (alunos, inscrições, resultados, mensagens, logs, etc.).

1. Contexto / por que existe

O PostgREST (camada HTTP do Supabase) aplica um cap global de linhas por resposta — db-max-rows, default 1000. Quando uma query retorna mais que esse cap, a resposta não vem com erro: ela vem truncada silenciosamente para 1000 linhas, sem cabeçalho de aviso visível ao cliente JS.

Para uma escola com >1000 inscrições em uma olimpíada, isso significa que:

  • A lista de alunos da tela "Resultados" perde linhas (alunos do "fim" da ordenação somem — historicamente os níveis do ensino médio).
  • Filtros derivados em memória (niveis, series, turmas) ficam incompletos (níveis "S" e "J" simplesmente não aparecem nos dropdowns).
  • Stats agregados desbalanceiam (cards "Participantes", "Classificados" ficam errados para baixo, sem nenhum alerta).
  • A coordenação não publica resultados porque "não tem dado lá".

Este é um padrão de falha silenciosa categorizado como Silent Data Loss (ver docs/security/SILENT_DATA_LOSS_AUDIT.md). O incidente 2026-06-04 (Nível S / J Canguru) foi causado exatamente por isso.

2. Regra de ouro

Queries que potencialmente retornam >1000 linhas SEMPRE precisam de uma estratégia de paginação. Nunca um select direto.

Duas estratégias canônicas, ambas no _shared/:

EstratégiaQuando usarImplementação
queryInChunks(...)Você tem uma lista de IDs e faz IN(...)_shared/resultados-compute.ts — particiona o array em chunks de 80
fetchAllRows(buildQuery)Você filtra por .eq()/.in() em um escopo amplo (escola, ano) e não tem uma lista de IDs_shared/pagination-helpers.ts — drena via .range(from, to)
ts
import { fetchAllRows } from "../_shared/pagination-helpers.ts";

const inscricoes = await fetchAllRows<{ id: string }>(
  () => supabase.from("inscricoes_olimpiada")
    .select("id, aluno_id, nivel_id")
    .eq("escola_id", escolaId)
    .eq("olimpiada_id", olimpiadaId),
  { label: "inserir-resultados.inscricoes" },
);

Importante: o argumento é uma factory — Supabase QueryBuilders são single-shot quando awaitados; cada iteração precisa de uma nova instância.

3. Checklist obrigatório de revisão (PR)

Quando o PR adiciona ou altera uma query, marcar mentalmente:

  • [ ] A tabela cresce proporcional ao tamanho de uma escola? (alunos, inscrições, resultados, contatos, mensagens, logs)
  • [ ] A query pode retornar >1000 linhas para a maior escola da base?
  • [ ] Se sim, usa fetchAllRows (sem .in(ids)) ou queryInChunks (com .in(ids))?
  • [ ] Frontend: a feature usa React Query com paginação no servidor (page/pageSize) e debouncing de busca? Nunca slice no cliente sobre uma lista capada.
  • [ ] Stats/contadores calculados em backend são feitos sobre a coleção drenada (não sobre a página atual)?
  • [ ] Existe teste Deno cobrindo o cenário ">1000 linhas"? Mockar range e validar que o helper drenou todas as páginas.

4. UI — paginação e debounce

A camada de leitura paginada já existe em useResultadosPorOlimpiadaPaginado (50 linhas por página, sort/filter no servidor, search com debounce). É o padrão de referência:

  • staleTime: dado operacional → 30s–5min
  • keepPreviousData: evita flash de empty state
  • queryKey inclui escola_id, olimpiada_id, fase_id, nivel_id, page, busca
  • Busca → debounce ≥300ms no input antes de invalidar query
  • Backend ordena (order().range()) — nunca confiar em ordem natural

Para listas pequenas (≤100 linhas) é aceitável renderizar tudo, mas ainda sem depender do cap implícito do PostgREST.

5. Import (zero-trust)

Já documentado em docs/development/IMPORT_SYSTEM_STANDARD.md:

  • Batches de 50–100 linhas no upsert
  • Validação server-side mesmo quando o frontend já validou
  • EdgeRuntime.waitUntil para trabalho >25s sem segurar a Response

Nada muda aqui — este documento complementa o pipeline de leitura.

6. Auditoria histórica

IncidenteCausaFix
2026-06-04 — Nível S/J Canguru sumidos da tela Resultados de uma escola com 1.4k inscriçõesCap silencioso de 1000 do PostgREST em 8 queries de inscricoes_olimpiada (handleList, handleStats, handleBatchInit, handleStatsAgregados, internals, previewCorte, fetchOlimpiadasList, fase-anterior)Helper fetchAllRows + refactor em _shared/resultados-queries.ts. Snapshot de publicação usa paginação (snapshot-publicados.ts) — não houve perda de dado em banco.

7. Referências

  • supabase/functions/_shared/pagination-helpers.ts — helper + testes
  • supabase/functions/_shared/resultados-compute.tsqueryInChunks (IN-based)
  • supabase/functions/_shared/snapshot-publicados.ts — exemplo de pagina range já consolidado
  • docs/audits/AUDIT_RESULTADOS_NIVEL_S_2026-06-04.md — incidente
  • docs/security/SILENT_DATA_LOSS_AUDIT.md — taxonomia da falha
  • mem://architecture/large-dataset-handling-standard — memória de regra