Pular para conteúdo

PowerSync — o transporte praticado

Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-10-01

Aplica-se a: app que consome PowerSync e repo de stack PowerSync de um cliente.

O PowerSync é o transporte bidirecional entre o Postgres por cliente (o barramento) e as pontas. O connector vive na Plataforma de Integração; o PowerSync serve dois consumos: os apps consumidor Web e Mobile (saída X-Adm→app) e o cliente Java do ERP (entrada nuvem→ERP). Apps específicos que enviam para fora (tipo ④) NÃO consomem PowerSync — o transporte com a Plataforma de Integração é REST. Esta página é só o praticado — o padrão provado nos pilotos, não o aspiracional.

Norma × detalhe

A norma de integração está na constituição e o desenho em Plataforma de Integração. Aqui, a tech.

Cliente (a face do app)

  • Sync read-only por padrão. O app lê o SQLite local que o PowerSync mantém; não escreve direto no Postgres.
  • Write-back só via uploadData. Campo editável (ex. nome-curto no navarro_app) sobe pelo uploadData do connector → POST na Plataforma de Integração, que grava nas tabelas que ela possui. O app nunca fala com o banco; a autoridade da escrita é do dono da tabela (owner-writes, Plataforma de Integração).
  • O JWT de sessão grava-se SÍNCRONO no login — o connector do PowerSync é consumidor, não dono. Armadilha observada: a sessão persistia a identidade do usuário mas não o token; quem gravava o JWT era o credential-provider do connector, que roda assíncrono quando o PowerSync resolve conectar. Qualquer chamada autenticada disparada logo após o login (buscar perfil, papéis, primeira tela) corria com token vazio → 401 intermitente, dependente de corrida. Regra: o login persiste o JWT antes de liberar a navegação; o fetchCredentials/credential-provider lê dessa fonte e renova, nunca é o único a escrever.
  • O fetchCredentials renova pelo refresh do central (Segurança): o SDK pede credencial cerca de 30 s antes do exp, então o connector renova quando falta menos de 1 min, sem timer próprio. 401, 403 e 404 do refresh derrubam a sessão; 5xx, 429 e erro de rede lançam, e o SDK tenta de novo. A senha não fica guardada para re-logar, nem em memória.
  • Write-back precisa de watchdog (contorno de bug upstream). O PowerSync Kotlin core (≤1.14.1) não re-agenda o uploadData depois de uma falha do connector. Se o upload lança, o loop de write-back para e só volta ao reconectar: a fila local de CRUD acumula e o campo editado nunca sobe, sem erro visível. Foi reproduzido com e2e e atinge os dois clientes Kotlin da casa, o mobile e a bridge Java do ERP — a mesma classe de bug já corrigida no SDK JS.

    Contorno da casa, normativo enquanto o upstream não corrige: todo cliente Kotlin de write-back roda um watchdog que re-dispara o uploadData enquanto houver CRUD pendente. Ele é removível — ao adotar a versão do powersync-kotlin que corrige o re-agendamento, tire o watchdog e marque a versão-alvo. - Sync travado vira evento no GlitchTip, não breadcrumb. Sync que para não lança exceção: o downloadError só aparece no statusStream, e o download parado nem isso. Medido no bi-comercial: um cliente ficou mais de 5 min com dado velho, mesmo recarregando, e nenhum evento chegou, porque o erro ia para breadcrumb. Todo app com PowerSync ouve o statusStream e reporta pelo AppLog.error (Flutter) dois casos:

    • O primeiro downloadError de cada tipo (runtimeType) na sessão. O erro fica setado até o próximo checkpoint e se repete a cada emissão; reportar todos inundaria o GlitchTip.
    • O sync travado, avaliado por um vigia a cada 30 s: download em curso cujo downloadedOperations não avança, ou !connected com downloadError, por 5 min (o boot a frio leva 60–90 s). Sync ocioso não conta, e o vigia não depende da cadência do ETL. Se o intervalo entre avaliações passar do dobro do esperado (aba em segundo plano, máquina suspensa), a contagem recomeça em vez de acusar. Sai um evento por episódio, que volta a valer quando o sync destrava, e a exceção tem mensagem estável (SyncTravadoException: <motivo>) para o GlitchTip agrupar numa issue só.

    O vigia é puro, com o relógio injetado, e o teste dirige o relógio em três casos: o travado passa do limite e reporta uma vez, o ocioso não reporta, e o timer congelado não reporta. Implementação de referência: o vigia_de_sync.dart do bi-comercial. - Migration de raw table congela a própria DDL. O SDK não cria nem migra raw table: o app versiona a DDL numa lista de migrations (registradas numa _schema_version) e aplica as pendentes no boot. Migration aplicada não se edita, como no Flyway (Java/Micronaut): mudança, até de índice, vira entrada nova. E cada entrada escreve as colunas e os índices literais, nunca derivados da lista viva que alimenta o syncedColumns. Derivada, a coluna nova entra retroativamente na migration antiga, e a seguinte, com ALTER TABLE ADD COLUMN, quebra todo install novo com duplicate column. O cliente que já tinha o banco passa, e por isso o erro não aparece no dev.

    O teste é obrigatório e roda contra o SQLite real. Num banco vazio, as migrations em ordem produzem o mesmo esquema físico da DDL viva: STRICT, colunas com tipo e índices com as suas colunas. As colunas físicas batem com o syncedColumns, e o upgrade a partir da versão anterior chega ao mesmo esquema do install novo. Implementação de referência: o raw_tables_migration_test.dart do bi-transporte.

Armadilhas do SDK no cliente

Vistas no SDK Dart powersync 2.x (2.3.0, salvo indicação na linha).

Sintoma Causa Cura
sync parado depois do disconnectAndClear o SDK não reconecta sozinho, e o clear apaga as subscriptions subscribe de novo e connect() explícito
conecta e não baixa nada a partir da 2.3, o cliente não se inscreve sozinho nas streams syncStream(nome).subscribe() antes de cada connect()
raw table com linhas velhas depois do disconnectAndClear sem clear:, o SDK não esvazia a raw table declare clear: 'DELETE FROM <tabela>' na raw table
falso "modo legacy" com streams ativas o SDK injeta uma entry de priority INT32_MAX como marcador de fim legacy é só INT32_MAX sem nenhuma priority de stream
progresso por priority inflado downloadProgress.untilPriority(N) é cumulativo subtraia o da priority anterior
modo do servidor errado logo depois do reconnect a primeira emissão traz as priorityStatusEntries do SQLite local confie nelas só com connected
assert isSortedByCompare em statusForPriority (2.0.1) as entries saem fora de ordem por causa do INT32_MAX leia as entries sem depender da ordem
"tentando reconectar" com o app online o downloadError fica setado até o próximo checkpoint mostre o erro só com !connected
cache em memória um checkpoint atrás, com o app aberto o onChange com throttle chega depois do statusStream do mesmo checkpoint, e o gate "checkpoint e tabela mudou" descarta o gatilho guarde o gatilho sem escrita vista como pendente e recarregue quando o onChange chegar
tela vazia com download em 100%, no web o SDK ainda aplica o checkpoint no IndexedDB condicione a tela a downloadProgress != null e aos caches, não a downloading

Tipos no schema do cliente

O SQLite não tem os tipos do Postgres, e o SDK do cliente tem três: text, integer e real (Column.text/Column.integer/Column.real no Dart; a mesma tríade nos outros SDKs). Quem converte é o serviço, na saída — o schema do cliente só declara o que vai chegar. Declarar diferente não dá erro: a doc é explícita ("if a value doesn't match, it is cast automatically"), o SDK casta em silêncio, e é por isso que a coerção errada sobrevive a code review.

Postgres O serviço emite Declare Por quê
bool integer integer 1 = true, 0 = false — não há tipo booleano no SQLite
int2/int4/int8 integer integer
numeric/decimal text text sempre precisão arbitrária no Postgres; só o texto a preserva — real a destrói em silêncio
float4/float8 real real ponto flutuante de verdade dos dois lados
date/time/timestamp text text ISO 8601 (YYYY-MM-DD hh:mm:ss.sssZ no timestamptz)
json/jsonb text text vai na representação serializada
a coluna id — não declare o SDK cria um id TEXT implícito
  • O booleano é o que morde primeiro. A flag chega como 1/0; código que espera true/false (ou a string "true") lê tudo como falso — sem exceção, sem log, com a tela inteira coerente e errada. É o caso que obriga o teste abaixo.
  • numeric → text. Sempre. A convenção não perde precisão. Declarar Column.real sobre uma coluna numeric funciona — o SDK casta —, e é exatamente por funcionar que passa despercebido: o valor vira double e a precisão que o numeric(15,3) existe para garantir se perde, em silêncio, em toda linha. Quem escolheu numeric no Postgres já decidiu que aquele número tem de fechar, e o transporte não tem autoridade para desfazer essa decisão. real fica para o que já nasce float na origem (float4/float8 — grandeza física, medição); valor que precisa fechar (dinheiro, quantidade fiscal, margem) nasce numeric no Postgres e chega text aqui.
  • Na leitura, o texto vira inteiro escalado. O app converte o texto num inteiro na escala da coluna — numeric(15,3) vira milésimos, numeric(10,2) vira centésimos — mexendo só na string: separa no ponto, completa as casas até a escala, junta os dígitos. Sem Decimal, sem BigInt, sem ponto flutuante.

    A conversão mora numa função só. Ela recusa o que não casar ^-?\d+(\.\d+)?$ (o numeric aceita NaN e Infinity) e o que tiver mais casas que a escala, em vez de arredondar calada. É travada por teste com negativo, zero à esquerda e fração curta (-0.29, 0.001, 12.3).

    Coluna numeric sem escala declarada não tem casa fixa para onde escalar: essa vai para o decimal da linguagem (BigDecimal, Decimal). - Conta, soma, razão e comparação em inteiro escalado, com arredondamento explícito; double só no último passo, para consumidor que só aceita double (coordenada de gráfico, célula de XLSX), convertido do valor final e nunca devolvido ao cálculo; texto exibido formata do inteiro escalado.

    Por que inteiro, e não o decimal da linguagem: coluna decimal declarada text ordena e compara lexicograficamente no SQLite ('9' > '10'), e o Decimal do Dart se apoia em BigInt, lento no JavaScript compilado; o inteiro escalado soma e compara como inteiro, em qualquer plataforma. Quando o cálculo precisa acontecer em SQL local, o dono da tabela publica uma coluna auxiliar inteira escalada (valor_milesimos bigint ao lado do numeric), calculada no Postgres, que chega integer e soma, ordena e indexa sem tocar em ponto flutuante.

    O que não é saída: escalar com ponto flutuante — CAST(x * 100 AS INTEGER) no stream ou no SQLite, (double.parse(s) * 100).toInt() no Dart. O texto vira double antes da multiplicação, e 0.29 * 100 dá 28.999…, que o truncamento transforma em 28. Nem CAST(x AS REAL), que é o mesmo double por outro caminho.

    Teto na web: o int do Dart compilado para JavaScript é exato só até 2^53 — cerca de 9 × 10^15. O teto vale para o total, não só para cada linha: confira a escala contra o maior total esperado (em milésimos, dá 9 trilhões de unidades). No mobile e no desktop o int é de 64 bits. - integer declarado como text custa ordenação. O serviço manda inteiro; declarar texto faz o SQLite comparar lexicograficamente ('10' < '9'), o que quebra ORDER BY, BETWEEN e índice sobre a coluna. - Trave por teste contra o SQLite real. Coerção não aparece em revisão nem em gate de build. O teste escreve uma linha por tipo no banco local que o SDK materializa e afirma o valor lido: flag falsa e verdadeira, data que ordena, e o decimal conferido contra o valor exato.

    No decimal, compare o inteiro escalado, o BigDecimal/Decimal ou a string — nunca um double com tolerância. Asserção com margem passa justamente no caso que a regra existe para pegar. Sem esse teste o erro só aparece na tela de quem usa, e no decimal aparece como um total que não fecha, sem ninguém saber onde perdeu.

Connector (server-side, na Plataforma de Integração)

  • Dono do write-back. O connector aplica os write-backs dos apps nas tabelas da Plataforma de Integração (a réplica).
  • Campo de app nunca é sobrescrito pelo ERP. O push do X-Adm reidrata a réplica; os campos que o app edita (nome-curto, status) são preservados — o apply do push não pisa neles. É invariante: dois donos lógicos na mesma linha (o ERP e o app) exigem que o merge respeite a coluna de cada um.

Fonte (o Postgres que replica)

O PowerSync não faz polling na fonte: ele consome replicação lógica do Postgres. Isso impõe pré-requisitos ao servidor (não ao banco do app) e uma superfície de operação que, quando quebra, não acusa erro em lugar nenhum — o sync simplesmente para de chegar e o sintoma aparece como "bug do app".

  • wal_level = logical é do SERVIDOR e exige restart. No postgresql18 compartilhado (Coolify §Banco) a flag vale para todos os bancos da instância — ligar por causa de um app é decisão de plataforma, com janela de restart, não ajuste de app. Reserve max_replication_slots com folga (um slot por consumidor).
  • A publication tem de se chamar powersync (nome fixo, exigido pelo serviço) e a casa a cria FOR ALL TABLES — é o praticado nas seis stacks. O trade-off é declarado: publication ampla custa WAL e memória a mais (a doc do PowerSync recomenda subconjunto quando o volume é grande) e, em troca, tabela nova entra sozinha. Com lista explícita a conta inverte: cada tabela nova sincronizada passa a exigir ALTER PUBLICATION powersync ADD TABLE <t> no mesmo PR da migração (além do GRANT SELECT) — esquecer não quebra deploy nenhum, a tabela só não replica. E a troca de regime não é de graça: o Postgres não aceita ALTER PUBLICATION … ADD/DROP TABLE numa publication criada FOR ALL TABLES (restrição do Postgres, não do PowerSync) — é dropar e recriar.
  • A role do serviço precisa de REPLICATION, BYPASSRLS e SELECT nas tabelas publicadas (mais ALTER DEFAULT PRIVILEGES … GRANT SELECT se tabelas novas entram depois). Não é superuser.
  • O id da sync rule e a chave que o serviço usa para casar a linha são coisas DIFERENTES — e é a segunda que decide o DELETE. Cada linha é identificada por um hash das colunas da replica identity (getUuidReplicaIdentityBson(record, table.replicaIdColumns), WalStream.ts), não pelo id que a sync rule projeta. O que decorre disso, verificado no fonte do serviço:

    • Sem identidade não há update nem delete. Tabela sem PK e sem índice único cai em replicationIdentity: 'nothing' e o diagnóstico sai fatal: No replication id found for <tabela>. Replica identity: default. Configure a primary key on the table.
    • No DELETE o Postgres manda só as colunas da identity — e o serviço não tenta avaliar a sync rule com esse pedaço de linha: busca o registro anterior no current_data pelo hash e emite a remoção dos buckets já gravados no insert. Por isso PK composta com o id fora dela funciona, desde que o current_data tenha a linha.
    • O modo de falha real é o registro anterior faltando — e esse é silencioso: o serviço loga Cannot find previous record for delete on <tabela>: <replicaId> / <id>, não emite remoção nenhuma, e a linha fica no cliente para sempre. Suspeite dele quando "o delete não chega": a linha entrou antes de a tabela ser replicada, ou a identity mudou no meio do caminho.
    • Higiene da casa: id é a PK simples da tabela. Não porque o evento de delete precise do id — não precisa. É porque PK simples faz identidade e id coincidirem: some a divergência entre "a chave que o serviço casa" e "o id que o cliente vê", e o diagnóstico acima passa a bastar. REPLICA IDENTITY FULL resolve por força bruta e cobra caro: todo update vira delete+insert, dobrando as operações de sync. Trocar a identity re-replica a tabela inteira.
    • O slot é a peça frágil: ausente = replicação parada; parado = WAL crescendo. Sem slot o serviço não recebe mudança nenhuma; com slot inativo o Postgres retém WAL indefinidamente e o disco do servidor compartilhado enche — o slot esquecido de um consumidor morto derruba todos os apps da instância. Verificação (não deduza pelo app):
SHOW wal_level;                                  -- logical
SELECT * FROM pg_publication;                    -- existe uma chamada "powersync"
SELECT slot_name, active, restart_lsn FROM pg_replication_slots;  -- ativo e com LSN avançando
  • O serviço tem diagnóstico próprio — e ele já vem com o comando da correção. POST /api/admin/v1/diagnostics e POST /api/admin/v1/validate devolvem os erros por tabela prontos: tabela fora da publication sai como Table <t> is not part of publication 'powersync'. Run: ALTER PUBLICATION powersync ADD TABLE <t>.; RLS ligada sem BYPASSRLS sai como PSYNC_S1145 com o ALTER ROLE escrito. As rotas exigem api_tokens na config — sem token elas respondem Authentication disabled. É a explicação de por que esses erros "não aparecem": o serviço acusa, ninguém pergunta.
  • Resetar slot é operação DESTRUTIVA e mora em runbook (nível 5): derruba o consumidor (pg_terminate_backend), dropa e recria o slot — a réplica volta do zero. Automatizar é legítimo (um app da casa tem essa administração em código), mas o caminho é o mesmo de qualquer destrutiva: passo documentado, e teste de integração com container próprio, porque o wal_level=logical não é o default do Postgres — mecânica em java-micronaut §Testes.

Auth (o consumidor decide o modo)

Duas classes de consumidor, dois modos — quem tem usuário vs quem é máquina:

  • App com usuário real — o PowerSync valida o JWT do usuário pelo JWKS do central-backend (central-backend.xadm.biz) (ou pelo jwks_uri do Google, nos apps que autenticam por Firebase).
  • Cliente máquina-a-máquina (o cliente Java do ERP na entrada — sem humano) — nasce self-signed + JWK inline: o cliente assina o próprio JWT com uma key privada em env e o PowerSync valida contra o JWK público inline na config. É o mais simples e não acrescenta peça (sem broker, sem IdP) — reserve Firebase/pAbast para quem tem usuário de verdade. A key privada é segredo de env (nunca em repo/log) e o algoritmo é público — é Kerckhoffs, não fórmula-em-claro. Cuide do kid/audience na config para o PowerSync casar o JWK certo. (Não confunda com o M2M do broker de setup, que emite token de serviço — lá quem assina é o central-backend, aqui é o próprio cliente.)
  • JWKS inline > jwks_uri — vale para os dois modos. Prefira a chave inline na config do PowerSync em vez de apontar um jwks_uri. A busca remota tem timeout de ~3s e sofria com NAT hairpin, o container batendo no próprio domínio público via Traefik; o resultado era falha intermitente de auth, recorrente em produção e em mais de um cliente. O hairpin hoje se resolve no host (0039), mas inline continua eliminando a ida à rede e o timeout, e o self-sign do M2M já nasce inline por construção.

    Exceção: o Firebase usa o jwks_uri do Google, de chaves rotativas, sem inline. A regra vale para JWK próprio (central-backend ou self-sign), não para o do Google.

Config — armadilhas

  • edition: 3 é obrigatório no sync_config. Sem ele, o PowerSync cai num modo de compatibilidade silencioso — funciona no happy-path e diverge sutilmente depois, sem erro visível. Fixe a edição.
  • O praticado é Sync Streams (streams:), não sync rules clássicas — e é o formato, não só a edição, que libera subconsulta. As seis stacks da casa estão em streams: com config: edition: 3 (várias migradas de bucket_definitions:), e a diferença decide o que se pode escrever: sync rules clássicas não aceitam subquery, JOIN nem CTE; Sync Streams aceitam IN (SELECT …) e subconsulta aninhada, INNER JOIN (as colunas selecionadas têm de vir de uma só tabela) e CTE no bloco with: — e é o with: global (no topo do config, compartilhado entre streams) que exige edition: 3. Nenhum dos dois formatos aceita agregação ou ordenação: GROUP BY, ORDER BY, LIMIT e UNION estão fora dos dois. Ganho prático: recortar a filha pelo estado da mãe sem denormalizar — SELECT * FROM remessa_item WHERE remessa_id IN (SELECT id FROM remessa WHERE status <> 'ENTREGUE') é padrão em produção hoje.
  • Subconsulta é parameter query, e parameter query tem teto de 1000 — que derruba a CONEXÃO, não a query. O PSYNC_S2305 tem dois contadores, ambos com default 1000: buckets por conexão e parameter query results (as linhas que os lookups devolvem, contadas antes da deduplicação). Estourar qualquer um faz o endpoint de sync responder HTTP 500 e o cliente inteiro parar — fica preso em connecting, levando junto os demais streams; o dano não fica contido no stream da subconsulta. Duas consequências da mecânica:

    • O contador soma os streams da conexão, e a parameter query roda uma vez POR stream. Trinta streams com o mesmo recorte de escopo pagam trinta vezes pelo mesmo resultado. A resposta do mantenedor a um relato de produção (32 streams auto_subscribe, nenhuma tabela passando de ~140 linhas) foi fundir os streams, não encolher o dado: "even if all streams use the same join, the parameter query is likely invoked once per stream". Streams auto_subscribe de mesmo escopo são os candidatos naturais à fusão.
    • CTE (with:) baixa a conta porque o contado passa a ser o resultado da CTE, não as linhas que as queries de dados varrem. Dimensione pelo que a subconsulta devolve (quantas remessas vivas existem?), nunca pelo tamanho da tabela que ela lê.

    Não é bug: chegou a ser reportado como regressão (powersync-service#611) e foi fechado como comportamento — versão recente do serviço quebra o número por stream no log, que é o que diz qual encolher ou fundir. - auth.parameter('x') IN (…lista literal…) NÃO existe — o IN só casa parâmetro↔coluna-array. As duas únicas formas que o serviço aceita são <coluna> IN auth.parameter('array') e auth.parameter('x') IN <coluna que é array JSON> (README do packages/sync-rules); um parâmetro no lado esquerdo contra uma lista de literais não é nenhuma delas e a query é rejeitada como fatal — com ou sem filtro de coluna no mesmo WHERE. Gate por papel escreve-se com igualdade encadeada, parênteses obrigatórios quando há AND de coluna junto: (auth.parameter('role') = 'A' OR auth.parameter('role') = 'B') AND .... Não generalize para "o IN é proibido": id IN auth.parameter('ids') é a forma canônica e vale. Uma sync rule inválida não degrada o gate, derruba o sync inteiro (o fail-closed "sem papel não sincroniza" vira fail-total "ninguém sincroniza"). - Sync rule inválida NÃO derruba o serviço — healthcheck verde não atesta config válida. O PowerSync sobe, responde /probes/liveness 200 e apenas loga as queries rejeitadas em JSON ({"errors":[{… "type":"fatal", "source":{"sql":"…"}}]}). As probes documentadas cobrem "serviço vivo", não a validade das sync rules; o sintoma chega horas depois como "não sincronizou nada" no cliente e parece bug de app. Consequência: docker compose up --wait não cobre isso — depois de subir a stack, inspecione o log do serviço e reprove se houver "type":"fatal"/"errors" (o pré-flight da /xadm-release faz isso; ver Deploy). Atalho para editar sync rules com linha e coluna do erro: o CLI oficial (npx powersync validate) roda em self-hosted sobre o YAML local — complemento, não substituto: quem decide é o binário da tag que produção roda. - Toda query de um bucket gateado por papel CITA o parâmetro de papel — e o lint cobra. O gate por papel só é fail-closed se nenhuma query do stream escapar. Uma query nova acrescentada sem o filtro não quebra nada: sobe, sincroniza, fica verde — e entrega o dado a quem não tem o papel. É fail-open por esquecimento, o modo de falha mais silencioso desta página.

    Por isso a regra é estática e barata: no stream gateado, auth.parameter('role') (ou o parâmetro de papel que aquele cliente usa) tem de aparecer em cada query. Ausência reprova no lint, sem subir nada. Cobre parte do que ninguém prova em runtime hoje — que token sem papel não sincroniza nada (0029). - Callback sempre-200. O endpoint de upload/callback (write-back uploadData) responde HTTP 200 com o resultado no corpo (sucesso/erro), nunca um não-2xx para erro de negócio — senão a fila do cliente trava e reenvia em loop. É o mesmo contrato do push do X-Adm (erro vai no corpo, não no status). Este é o caso que exige o 200-sempre no status-por-caso do contrato app↔app: onde há PowerSync ou fila no meio, 200 é obrigatório e não muda; par app↔app novo sem fila usa status HTTP real.

Deploy (verificar antes do release)

O PowerSync self-hosted é uma stack Docker Compose (imagem journeyapps/powersync-service pull, config + sync rules em volume). docker build não o exercita — sem Dockerfile próprio, não há o que buildar; bootstrap (load de sync rules, migração) e healthcheck só rodam no up, e o primeiro a subir a stack de verdade é o Coolify, em produção. Antes de taggar, suba a stack efêmera com volume novo e espere os healthchecks — a /xadm-release faz isso no pré-flight (bloco Docker Compose em Particularidades): docker compose -p <slug>-preflight up -d --wait + down -v. --wait reprova se um serviço fica unhealthy ou um container de bootstrap sai ≠0.

O --wait não é o gate inteiro. Sync rule inválida deixa o serviço healthy (ver Config) — então, com a stack de pé, leia o log do serviço (docker compose -p <slug>-preflight logs — comando bare; a inspeção é pela ferramenta Read/Grep, nunca por pipe de shell) e reprove se houver "type":"fatal" ou "errors". Vermelho de config é bloqueador, igual a healthcheck reprovado.

O repo da stack de um cliente tem gate próprio, em todo push (definição de pronto) — não só no release. Dois passos, minutos de trabalho:

  1. Lint estático (~1s, não sobe nada): rejeita as formas que a tag pinada não aceita — auth.parameter(...) IN ( (use igualdade encadeada), bloco de OR de papel sem parênteses quando há AND de coluna, a chave raiz sync_rules: (Sync Streams exigem sync_config:; com a antiga o serviço cai em modo legacy sem aviso) e a regra de fail-closed acima — toda query de stream ativo cita o parâmetro de papel, quando o arquivo faz gate por papel; stream sem papel por desenho (M2M, global) se marca com # sem-gate-ok: <motivo> no próprio bloco. Cobra também a mesma data de cutoff em todos os streams ativos: o refresh do cutoff desliza todas de uma vez, e data divergente é bump pela metade. A lista é datada pela tag: se o pin subir e a forma passar a ser aceita, a regra sai junto — lint que sobrevive à versão que o motivou vira superstição.
  2. Smoke de carga (~30s): sobe a mesma imagem pinada com o YAML montado, só o bastante para ela ler a config, e reprova se o log trouxer "type":"fatal". Precisa de um Mongo efêmero em replica set: sem storage o serviço fica em retry de conexão, nunca carrega as regras, e o smoke daria falso verde. Postgres não precisa — as regras compilam no carregamento, e o ECONNREFUSED :5432 no log é esperado. Sem Loaded sync config no log o resultado é inconclusivo (não é verde); Sync config updated só sai com todas as regras aceitas. Reprovar pelo log, nunca pelo healthcheck (ver Config).

Ao subir o pin, prove o vermelho. Os dois passos codificam o que aquela tag rejeita — então o verde deles só quer dizer alguma coisa enquanto o vermelho for alcançável naquela imagem. Se a tag nova passar a aceitar uma das formas da lista, ou mudar a string de erro que o smoke procura, o gate fica verde para tudo e ninguém nota: a construção passa a ser aceita por omissão.

Norma: a cada bump do pin, rode lint e smoke contra uma config sabidamente inválida e confirme que reprovam. Se não reprovarem, a regra saiu de validade — reescreva-a ou remova-a, porque regra que sobrevive à versão que a motivou vira superstição. É a mesma disciplina de mutante das guardas do pipeline.yml — ci-testes.

Isso não substitui o e2e do cliente, que prova o fio com dado e claim de verdade, nem o pré-flight da /xadm-release. É a rede barata que falta entre os dois: hoje um erro de sync rule só aparece no release, ou no e2e, que roda tarde.

Mudou sync rule ou subiu o pin? Rode o e2e do cliente correspondente antes de taggar. O tier local/ do e2e-local roda a mesma imagem pinada com as sync rules daquele cliente — o smoke acima prova que as regras compilam; o e2e é o único lugar, fora de produção, que prova que elas filtram o que deviam, com dado e claim de verdade. Revisar o YAML no olho não substitui nenhum dos dois: a rejeição só aparece quando o serviço compila a query, e o vazamento, quando alguém sincroniza. A regra de tipos também depende da imagem: o e2e do cliente que declara numeric como text afirma, na tabela crua ps_data__*, que o valor chega como string JSON com a grafia do ::text do Postgres ("5000.000"), e cada bump do pin a prova de novo.

Servidor-consumidor (ponteiro)

Consumir PowerSync em Java (hoje: o cliente do ERP na entrada) exige uma bridge Kotlin (PsBridge.kt, ~190 linhas). A base de consumo (idempotência + tabela de status) e o desenho de cada tipo de fluxo vivem em Plataforma de Integração e, no concreto, na arquitetura-alvo do integrador. De olho no repo do PowerSync: uma API Java pura deve eliminar a bridge.