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 nonavarro_app) sobe pelouploadDatado connector →POSTna 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
fetchCredentialsrenova pelo refresh do central (Segurança): o SDK pede credencial cerca de 30 s antes doexp, 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
uploadDatadepois 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
uploadDataenquanto houver CRUD pendente. Ele é removível — ao adotar a versão dopowersync-kotlinque 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: odownloadErrorsó aparece nostatusStream, 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 ostatusStreame reporta peloAppLog.error(Flutter) dois casos:- O primeiro
downloadErrorde 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
downloadedOperationsnão avança, ou!connectedcomdownloadError, 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.dartdo 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 osyncedColumns. Derivada, a coluna nova entra retroativamente na migration antiga, e a seguinte, comALTER TABLE ADD COLUMN, quebra todo install novo comduplicate 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 osyncedColumns, e o upgrade a partir da versão anterior chega ao mesmo esquema do install novo. Implementação de referência: oraw_tables_migration_test.dartdo bi-transporte. - O primeiro
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 esperatrue/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. DeclararColumn.realsobre uma colunanumericfunciona — o SDK casta —, e é exatamente por funcionar que passa despercebido: o valor viradoublee a precisão que onumeric(15,3)existe para garantir se perde, em silêncio, em toda linha. Quem escolheunumericno Postgres já decidiu que aquele número tem de fechar, e o transporte não tem autoridade para desfazer essa decisão.realfica para o que já nasce float na origem (float4/float8— grandeza física, medição); valor que precisa fechar (dinheiro, quantidade fiscal, margem) nascenumericno Postgres e chegatextaqui.-
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. SemDecimal, semBigInt, sem ponto flutuante.A conversão mora numa função só. Ela recusa o que não casar
^-?\d+(\.\d+)?$(onumericaceitaNaNeInfinity) 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
numericsem 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;doublesó no último passo, para consumidor que só aceitadouble(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
textordena e compara lexicograficamente no SQLite ('9' > '10'), e oDecimaldo Dart se apoia emBigInt, 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 bigintao lado donumeric), calculada no Postgres, que chegaintegere 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, e0.29 * 100dá28.999…, que o truncamento transforma em28. NemCAST(x AS REAL), que é o mesmo double por outro caminho.Teto na web: o
intdo 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 ointé de 64 bits. -integerdeclarado comotextcusta ordenação. O serviço manda inteiro; declarar texto faz o SQLite comparar lexicograficamente ('10' < '9'), o que quebraORDER BY,BETWEENe í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/Decimalou a string — nunca umdoublecom 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. Nopostgresql18compartilhado (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. Reservemax_replication_slotscom folga (um slot por consumidor).- A publication tem de se chamar
powersync(nome fixo, exigido pelo serviço) e a casa a criaFOR 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 exigirALTER PUBLICATION powersync ADD TABLE <t>no mesmo PR da migração (além doGRANT 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 aceitaALTER PUBLICATION … ADD/DROP TABLEnuma publication criadaFOR ALL TABLES(restrição do Postgres, não do PowerSync) — é dropar e recriar. - A role do serviço precisa de
REPLICATION,BYPASSRLSeSELECTnas tabelas publicadas (maisALTER DEFAULT PRIVILEGES … GRANT SELECTse tabelas novas entram depois). Não é superuser. -
O
idda 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 peloidque 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_datapelo hash e emite a remoção dos buckets já gravados no insert. Por isso PK composta com oidfora dela funciona, desde que ocurrent_datatenha 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 doid— não precisa. É porque PK simples faz identidade eidcoincidirem: 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 FULLresolve 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):
- Sem identidade não há update nem delete. Tabela sem PK e sem índice único cai em
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/diagnosticsePOST /api/admin/v1/validatedevolvem os erros por tabela prontos: tabela fora da publication sai comoTable <t> is not part of publication 'powersync'. Run: ALTER PUBLICATION powersync ADD TABLE <t>.; RLS ligada semBYPASSRLSsai comoPSYNC_S1145com oALTER ROLEescrito. As rotas exigemapi_tokensna config — sem token elas respondemAuthentication 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 owal_level=logicalnã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 pelojwks_urido 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/audiencena 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 é ocentral-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 umjwks_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_urido Google, de chaves rotativas, sem inline. A regra vale para JWK próprio (central-backendou self-sign), não para o do Google.
Config — armadilhas¶
edition: 3é obrigatório nosync_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 emstreams:comconfig: edition: 3(várias migradas debucket_definitions:), e a diferença decide o que se pode escrever: sync rules clássicas não aceitam subquery, JOIN nem CTE; Sync Streams aceitamIN (SELECT …)e subconsulta aninhada,INNER JOIN(as colunas selecionadas têm de vir de uma só tabela) e CTE no blocowith:— e é owith:global (no topo do config, compartilhado entre streams) que exigeedition: 3. Nenhum dos dois formatos aceita agregação ou ordenação:GROUP BY,ORDER BY,LIMITeUNIONestã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_S2305tem 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 emconnecting, 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". Streamsauto_subscribede 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 — oINsó casa parâmetro↔coluna-array. As duas únicas formas que o serviço aceita são<coluna> IN auth.parameter('array')eauth.parameter('x') IN <coluna que é array JSON>(README dopackages/sync-rules); um parâmetro no lado esquerdo contra uma lista de literais não é nenhuma delas e a query é rejeitada comofatal— com ou sem filtro de coluna no mesmoWHERE. Gate por papel escreve-se com igualdade encadeada, parênteses obrigatórios quando háANDde coluna junto:(auth.parameter('role') = 'A' OR auth.parameter('role') = 'B') AND .... Não generalize para "oINé 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/liveness200 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 --waitnã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-releasefaz 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-backuploadData) 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. - 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
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:
- 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 deORde papel sem parênteses quando háANDde coluna, a chave raizsync_rules:(Sync Streams exigemsync_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. - 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 oECONNREFUSED :5432no log é esperado. SemLoaded sync configno log o resultado é inconclusivo (não é verde);Sync config updatedsó 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.