Tempo Real & WebSocket
O Rebase inclui um mecanismo de tempo real integrado que envia alterações de dados para clientes conectados via WebSocket. Quando qualquer registro é criado, atualizado ou excluído, cada assinante que estiver observando essa coleção ou entidade recebe a atualização instantaneamente — sem a necessidade de polling.
Como Funciona
Seção intitulada “Como Funciona”O pipeline de tempo real possui três etapas:
- Gatilho no banco de dados — Uma mutação atinge o banco de dados PostgreSQL (via REST API, SDK ou Studio).
- Fan-out no servidor — O servidor Rebase detecta a alteração e a distribui (fan-out) para cada assinatura WebSocket ativa que corresponda à coleção ou entidade afetada.
- Callback no cliente — O SDK do cliente dispara seu callback
onUpdatecom os dados atualizados.
┌──────────────┐ ┌────────────────────┐ ┌──────────────┐│ PostgreSQL │─────▶│ Rebase Server │─────▶│ Client SDK ││ LISTEN/NOTIFY│ │ RealtimeService │ │ WebSocket │└──────────────┘ └────────────────────┘ └──────────────┘Para implantações multi-instância, o Rebase usa o LISTEN/NOTIFY do PostgreSQL para transmitir alterações entre instâncias de servidor. Isso é gerenciado automaticamente — uma conexão dedicada do PostgreSQL escuta no canal rebase_entity_changes e repassa as atualizações para os assinantes locais.
Zero Configuração
Seção intitulada “Zero Configuração”O tempo real é habilitado por padrão. Não há nenhuma flag para ativar nem serviço para iniciar — se o seu servidor Rebase estiver em execução, o endpoint WebSocket estará disponível.
Por padrão, o Rebase também emite eventos em tempo real para gravações feitas fora da API (via
psql, outro serviço ou pelo editor SQL do Studio) sempre que a conexão com o banco de dados suportar — veja captura de alterações a nível de banco de dados (CDC).
Assinaturas do SDK do Cliente
Seção intitulada “Assinaturas do SDK do Cliente”O SDK do cliente Rebase expõe dois métodos de assinatura em cada acessor de coleção:
listen()— Assina uma coleção inteira (com filtros opcionais).listenById()— Assina uma única entidade pelo seu ID.
Ambos os métodos retornam uma função de cancelamento de assinatura (unsubscribe) que você chama para parar de receber atualizações.
Assinando uma Coleção
Seção intitulada “Assinando uma Coleção”Use listen() para receber atualizações sempre que os registros de uma coleção forem alterados:
const unsubscribe = client.data.products.listen( undefined, // FindParams — pass undefined for all records (response) => { console.log("Products updated:", response.data); console.log("Total:", response.meta.total); }, (error) => { console.error("Subscription error:", error); });O callback recebe um FindResponse<M> contendo:
data— Array de objetosEntity<M>.meta— Informações de paginação (total,limit,offset,hasMore).
Assinando uma Coleção com Filtros
Seção intitulada “Assinando uma Coleção com Filtros”Passe FindParams como o primeiro argumento para filtrar a assinatura:
const unsubscribe = client.data.products.listen( { where: { status: ["==", "published"] }, orderBy: ["createdAt", "desc"], limit: 50, }, (response) => { console.log("Published products:", response.data); });O servidor respeita esses filtros — apenas os registros correspondentes são incluídos nas atualizações.
Assinando uma Única Entidade
Seção intitulada “Assinando uma Única Entidade”Use listenById() para observar um registro específico:
const unsubscribe = client.data.products.listenById( "product-123", (entity) => { if (entity) { console.log("Product updated:", entity.values); } else { console.log("Product was deleted"); } }, (error) => { console.error("Subscription error:", error); });O callback recebe Entity<M> | undefined. O valor undefined significa que a entidade foi excluída.
Cancelando a Assinatura
Seção intitulada “Cancelando a Assinatura”Tanto listen() quanto listenById() retornam uma função de cancelamento de assinatura. Chame-a para parar de receber atualizações e liberar recursos do lado do servidor:
const unsubscribe = client.data.products.listen(undefined, (response) => { // handle updates});
// Later, when you no longer need updates:unsubscribe();Query Builder .listen()
Seção intitulada “Query Builder .listen()”O query builder fluente também oferece suporte a assinaturas em tempo real. Encadeie seus filtros e, em seguida, chame .listen() em vez de .find():
const unsubscribe = client.data.orders .where("status", "==", "pending") .orderBy("createdAt", "desc") .limit(20) .listen( (response) => { console.log("Pending orders:", response.data); }, (error) => { console.error("Error:", error); } );Entrega de Atualizações: Patch Instantâneo + Refetch de Correção
Seção intitulada “Entrega de Atualizações: Patch Instantâneo + Refetch de Correção”Uma alteração nunca viaja para um assinante como dados. Ela viaja como o fato de que algo mudou, e cada assinante é então informado sobre o que ele pode ver por meio de uma consulta executada em seu nome:
-
Invalidação. Quando uma entidade é alterada (criada, atualizada, excluída), o servidor marca os caminhos afetados. A linha que foi gravada não é encaminhada — ela foi lida sob a autorização de quem gravou, o que não diz nada sobre o que qualquer assinante tem permissão para ver.
-
Refetch debounced com RLS. Após 300ms (
REFETCH_DEBOUNCE_MS), o servidor busca novamente a coleção com seus filtros e ordenação originais. A consulta é executada dentro de uma transação que define os valores locais de transaçãoapp.user_ideapp.user_rolesa partir doSubscriptionAuthContextdo assinante, para que o Postgres avalie a Segurança em Nível de Linha (RLS) sob a identidade daquele cliente e apenas as linhas que ele tem autorização para ver sejam enviadas nocollection_update. O debounce também agrupa uma sequência rápida de gravações em uma única consulta.
Versões anteriores enviavam um collection_patch imediato contendo a linha gravada antes desse refetch, para feedback cross-tab em submilissegundos. Essa linha havia sido lida sob o escopo de quem realizou a gravação, portanto podia — e conseguia — alcançar assinantes cujas próprias políticas teriam negado o acesso, e o próprio filtro where da assinatura também não havia sido aplicado a ela. O patch foi removido: a latência percebida para uma atualização agora é a janela do debounce.
O refetch é a leitura REST
Seção intitulada “O refetch é a leitura REST”O refetch executa o mesmo pipeline que o GET /api/data/<collection> executa, com o mesmo tratamento de include. É isso que faz com que find({ q }) e listen({ q }) retornem linhas idênticas, campo por campo.
Costumava ser um método diferente — que aninhava cada relação sob um invólucro { "__type": "relation" } e, como uma assinatura não podia carregar nenhum include, carregava de forma antecipada (eager loading) todas as relações declaradas pela coleção. Assim, a mesma consulta respondia em um formato via HTTP e em outro via socket, e um cliente renderizando ambos via suas linhas mudarem de formato no momento em que uma gravação ocorria.
Portanto, um frame de assinatura aceita o que uma requisição de listagem aceita: filter, logical, orderBy, limit, offset/page, searchString, include e fields. vectorSearch é a exceção e é recusado com VECTOR_SEARCH_NOT_LIVE — uma assinatura é reexecutada a cada gravação correspondente e nada ali calcula distâncias.
collection_update carrega seus próprios metadados
Seção intitulada “collection_update carrega seus próprios metadados”O frame é { rows, pks, meta }:
{ "type": "collection_update", "subscriptionId": "…", "rows": [ { "id": 1, "title": "Widget" } ], "pks": [ { "fieldName": "id", "type": "number" } ], "meta": { "total": 150, "limit": 20, "offset": 0, "hasMore": true, "nextCursor": "eyJ…" }}meta é contabilizado dentro da mesma transação com restrição de RLS que leu as linhas, portanto descreve exatamente as linhas ao lado dele. Sem isso, um cliente que precisasse de um total teria que emitir um GET /count por push — uma viagem de ida e volta (round trip) extra por gravação, por assinante, e uma janela na qual a contagem e as linhas descreviam estados diferentes da coleção.
Quando a contagem em si falha, o frame traz partial: true e nenhum total; isso não é um erro de assinatura, e o cliente deve manter o último total real em vez de substituí-lo pelo tamanho da página.
Canais de Broadcast
Seção intitulada “Canais de Broadcast”Os canais de broadcast permitem que os clientes enviem mensagens arbitrárias uns aos outros em tempo real — útil para recursos como indicadores de digitação, posições de cursor ou notificações personalizadas.
O broadcast é gerenciado no nível do protocolo WebSocket. O servidor suporta os seguintes tipos de mensagem:
| Tipo de Mensagem | Direção | Descrição |
|---|---|---|
join_channel |
Cliente → Servidor | Entrar em um canal nomeado |
leave_channel |
Cliente → Servidor | Sair de um canal |
broadcast |
Cliente → Servidor | Enviar uma mensagem para todos os membros do canal |
broadcast |
Servidor → Cliente | Receber uma mensagem de outro membro |
channel_history |
Cliente → Servidor | Solicitar mensagens retidas após uma sequência |
channel_history |
Servidor → Cliente | As mensagens retidas que um cliente perdeu |
Quando um cliente envia uma mensagem broadcast, o servidor a repassa para todos os outros membros daquele canal (o remetente não recebe sua própria mensagem).
// Broadcast message structure (sent by client){ type: "broadcast", payload: { channel: "room-42", event: "typing", payload: { userId: "user-1", isTyping: true } }}
// Received by other clients in the channel{ type: "broadcast", channel: "room-42", event: "typing", payload: { userId: "user-1", isTyping: true }}Retenção de Canais
Seção intitulada “Retenção de Canais”Por padrão, um broadcast alcança os membros conectados no momento e depois desaparece. Esse é o compromisso ideal para notificações e cursores, e não tem custo adicional.
Para um fluxo de operações — edição colaborativa, qualquer coisa em que uma lacuna silenciosa cause divergência — um canal pode ser configurado para reter suas mensagens. Os broadcasts retidos recebem um número de sequência por canal e são armazenados, de modo que um cliente que se reconectar pode solicitar tudo o que veio após a última mensagem visualizada.
A retenção é opcional (opt-in) e configurada aqui, no servidor:
import { initializeRebaseBackend } from "@rebasepro/server";import { createPostgresAdapter } from "@rebasepro/server-postgres";
await initializeRebaseBackend({ app, server, database: createPostgresAdapter({ connection: db, schema: { tables, enums, relations }, realtime: { channels: [ // Most specific first — the first match wins. { match: "doc:draft:*", limit: 100 }, { match: "doc:*", limit: 500, ttl: "24h" } ] } })});| Campo | Descrição |
|---|---|
match |
Nome exato do canal ("doc:42") ou um prefixo terminado em * ("doc:*") |
limit |
Manter no máximo esta quantidade das mensagens mais recentes por canal |
ttl |
Manter mensagens por no máximo este tempo — "30s", "15m", "24h", "7d", ou milissegundos |
Uma regra precisa de pelo menos um entre limit ou ttl. Uma regra sem nenhum dos dois é ignorada e registrada em log, porque uma retenção ilimitada quase nunca é intencional e não pode ser revertida facilmente depois que a tabela crescer.
Armazenamento
Seção intitulada “Armazenamento”Os canais retidos usam duas tabelas no schema rebase, criadas automaticamente na inicialização quando pelo menos uma regra é configurada:
| Tabela | Conteúdo |
|---|---|
rebase.channel_messages |
As mensagens retidas, indexadas por (channel, seq) |
rebase.channel_cursors |
A maior sequência emitida por canal |
A limpeza (pruning) ocorre à medida que as mensagens chegam, com limitação de taxa (throttled) por canal, para que o custo acompanhe o tempo decorrido em vez do volume de gravação. Ela apenas remove linhas de channel_messages — os cursores são mantidos indefinidamente (são uma linha pequena por canal), porque reiniciar a sequência de um canal mudaria o significado do ponto de retomada salvo de um cliente.
Garantias de entrega
Seção intitulada “Garantias de entrega”- Ordenada. Os números de sequência são alocados por canal, e a ordem de entrega corresponde à ordem da sequência.
- Durável antes de entregue. Uma mensagem que não pode ser armazenada não é entregue a ninguém, e o remetente é informado. Entregá-la a colocaria diante dos assinantes conectados, enquanto a deixaria de fora de qualquer repetição (replay) futura, e nenhuma mensagem posterior poderia reparar essa lacuna.
- Pelo menos uma vez (At-least-once) na recuperação. Um intervalo de repetição pode se sobrepor a mensagens que o cliente já recebeu; o SDK descarta aquelas que já foram entregues.
Rastreamento de Presença
Seção intitulada “Rastreamento de Presença”A presença rastreia quais usuários estão atualmente online em um canal e permite que cada usuário compartilhe um estado personalizado (por exemplo, posição do cursor, status).
| Tipo de Mensagem | Direção | Descrição |
|---|---|---|
presence_track |
Cliente → Servidor | Iniciar o rastreamento de presença com estado personalizado |
presence_untrack |
Cliente → Servidor | Parar o rastreamento de presença |
presence_state |
Cliente → Servidor | Solicitar o estado completo de presença de um canal |
presence_state |
Servidor → Cliente | Entidade completa de todas as presenças em um canal |
presence_diff |
Servidor → Cliente | Atualização incremental (entradas e saídas) |
Quando um cliente envia presence_track, o servidor o adiciona automaticamente ao canal (sem necessidade de um join_channel separado) e transmite um presence_diff para todos os membros do canal.
// Track presence{ type: "presence_track", payload: { channel: "document-edit-42", state: { name: "Alice", cursor: { line: 10, col: 5 } } }}
// Presence diff received by other clients{ type: "presence_diff", channel: "document-edit-42", joins: { "client-abc": { name: "Alice", cursor: { line: 10, col: 5 } } }, leaves: {}}
// Full presence state response{ type: "presence_state", channel: "document-edit-42", presences: { "client-abc": { name: "Alice", cursor: { line: 10, col: 5 } }, "client-def": { name: "Bob", cursor: { line: 22, col: 0 } } }}Presenças inativas são limpas automaticamente após 30 segundos de inatividade.
Reconexão Automática
Seção intitulada “Reconexão Automática”O SDK do cliente se reconecta automaticamente quando a conexão WebSocket cai:
- Backoff exponencial — Os atrasos de reconexão começam em 1 segundo e dobram a cada tentativa, com limite de 30 segundos.
- Máximo de 5 tentativas — Após 5 tentativas de reconexão com falha, o cliente para de tentar.
- Re-assinatura automática — Após uma reconexão bem-sucedida, todas as assinaturas ativas são registradas novamente no servidor. Nenhuma intervenção manual é necessária.
- Fila de mensagens — As mensagens enviadas enquanto desconectado são colocadas em fila e entregues após a reconexão.
Você pode ouvir os eventos do ciclo de vida da conexão:
// `ws` is undefined on a client built without realtime, so narrow it once.const ws = client.ws;if (ws) { ws.on("connect", () => console.log("Connected")); ws.on("disconnect", () => console.log("Disconnected")); ws.on("reconnect", () => console.log("Reconnected")); ws.on("error", (error) => console.error("Error:", error));}Autenticação & RLS
Seção intitulada “Autenticação & RLS”As assinaturas WebSocket respeitam automaticamente as políticas de Segurança em Nível de Linha (RLS). Quando o cliente é autenticado:
- A conexão WebSocket se autentica usando o mesmo token JWT que a API REST.
- Cada refetch de assinatura é executado dentro de uma transação PostgreSQL com
set_config('app.user_id', ...)eset_config('app.user_roles', ...)— garantindo que as políticas de RLS sejam aplicadas. - O token é verificado uma única vez, quando o socket se autentica, e o servidor não o verifica novamente durante a vida útil da conexão. Um token de acesso que expira, uma sessão que é revogada ou um papel (role) que é removido não altera o que um socket aberto pode ler até que ele se reautentique ou se reconecte. O SDK reautentica seu socket a cada vez que atualiza seu token e o desconecta no logout; um cliente que se comunica diretamente com o protocolo mantém a identidade com a qual foi aberto até que se reconecte.
Isso significa que cada socket recebe apenas atualizações de registros que sua identidade autenticada tem permissão para ver.
Executar mais de uma instância — o barramento LISTEN/NOTIFY, o comportamento da presença entre processos e como escrever seu próprio transporte — tem uma página dedicada: Tempo real entre instâncias.
Captura de Alterações a Nível de Banco de Dados (CDC)
Seção intitulada “Captura de Alterações a Nível de Banco de Dados (CDC)”O Change Data Capture vem ativado por padrão. O Rebase captura alterações no banco de dados e emite eventos em tempo real para toda gravação confirmada (committed), independentemente de como ela foi feita — REST, SDK, Studio, psql, um cron job em outro serviço, Drizzle/SQL puro ou pelo editor SQL do Studio. Este é o mesmo modelo do Supabase Realtime monitorando o write-ahead log.
Nenhuma configuração é necessária. Em uma conexão de banco de dados que suporte o recurso, o CDC se autoprovisiona na inicialização; em uma que não suporte (por exemplo, uma role restrita que não pode criar triggers), o Rebase silenciosamente usa o tempo real em nível de aplicação — nada para ativar, nada que quebre.
Configuração
Seção intitulada “Configuração”O CDC é controlado pela variável de ambiente REALTIME_CDC:
| Valor | Comportamento |
|---|---|
auto (padrão) |
Habilita a captura a nível de banco de dados onde a conexão permitir; faz fallback silencioso para o tempo real a nível de aplicação caso contrário. Zero configuração. |
trigger |
Força a captura baseada em triggers. Funciona em qualquer PostgreSQL, incluindo instâncias gerenciadas sem replicação lógica. Emite aviso (em vez de fazer fallback silencioso) se não conseguir provisionar. |
wal |
Dá preferência à replicação lógica WAL. Ainda não empacotado nativamente — degrada para trigger e registra o modo ativo nos logs. |
off |
Apenas tempo real a nível de aplicação. Use isso para evitar o overhead de triggers por gravação em cargas de trabalho com muitas gravações. |
Na inicialização, você verá uma linha de log informando o modo ativo, por exemplo:
📡 [CDC] Realtime source = database-level change capture (mode: trigger). All writes now emit realtime events regardless of origin.Se a conexão não puder dar suporte, auto registra uma linha informativa em vez disso e continua com o tempo real a nível de aplicação:
ℹ️ [CDC] Database-level change capture unavailable (likely insufficient privileges to create triggers…) — using app-level realtime.Como Funciona
Seção intitulada “Como Funciona”- Autoprovisionamento — Na inicialização (contexto de servidor/proprietário), o Rebase instala um trigger idempotente
AFTER INSERT/UPDATE/DELETEem cada tabela gerenciada. O trigger emite uma notificação compacta de alteração no canalrebase_cdc. Um payload que excederia o limite de 8 KB doNOTIFYdo PostgreSQL faz fallback para uma mensagem apenas com identificadores, garantindo que o CDC nunca aborte a gravação que disparou o trigger. - Captura — Um cliente
LISTENdedicado e fora do pool por instância consome orebase_cdc, mapeia a tabela alterada de volta para sua coleção e encaminha a alteração para o mesmo pipeline deRealtimeServiceusado pelas mutações da API. Assim como o listener entre instâncias, ele priorizaDATABASE_DIRECT_URLe se reconecta automaticamente. - Entrega segura com RLS — A linha bruta do fluxo de alterações nunca é encaminhada aos assinantes. A alteração é marcada como invalidada, e cada assinatura relê a linha sob seu próprio contexto de autenticação. A filtragem é, portanto, por assinante, nunca por publicador: um cliente só recebe linhas que suas políticas de RLS permitem.
- Entre instâncias — Como cada instância observa cada commit através do fluxo de alterações, o CDC também é o canal entre instâncias; a transmissão legada
rebase_entity_changespor mutação não é utilizada enquanto o CDC estiver ativo. - Desduplicação — Uma mutação feita por meio da API do Rebase é entregue localmente no instante em que é confirmada e também é refletida de volta pelo fluxo de alterações. A instância de origem suprime esse eco (um registro de curta duração de suas próprias emissões), de modo que os assinantes nunca vejam uma gravação da API duas vezes.
Requisitos & Observações
Seção intitulada “Requisitos & Observações”- O CDC requer uma connection string direta (
DATABASE_DIRECT_URLou a conexão primária) para o clienteLISTEN— poolers de conexão em modo de transação não oferecem suporte a sessões deLISTENde longa duração. - Os triggers são instalados apenas em tabelas associadas a uma coleção registrada. Gravações em tabelas não mapeadas são ignoradas.
- Uma coleção cuja tabela ainda não foi migrada é ignorada com um aviso, em vez de bloquear o CDC para as demais.
- O streaming de replicação lógica nativa do WAL (
wal2json/pgoutput) está planejado; atualmenteREALTIME_CDC=waldegrada para o caminho baseado em triggers, que oferece cobertura equivalente a nível de banco de dados.
Timeout de Requisições Pendentes
Seção intitulada “Timeout de Requisições Pendentes”Para evitar que as requisições dos clientes fiquem travadas indefinidamente, todas as operações WebSocket pendentes que esperam uma resposta do servidor (como buscas únicas de coleção FETCH_COLLECTION, buscas de entidade única FETCH_ONE, criação/atualização SAVE, exclusões DELETE, contagens COUNT e verificações de unicidade CHECK_UNIQUE_FIELD) têm um timeout padrão de 30 segundos.
Se o servidor não responder dentro dessa janela de 30 segundos, o cliente exclui automaticamente a requisição pendente e rejeita a promise com um ApiError contendo a mensagem "Request timed out".
Mensagens unidirecionais que não esperam resposta (como subscribe_collection, subscribe_one, unsubscribe, join_channel, leave_channel, broadcast, presence_track, presence_untrack e presence_state) resolvem imediatamente após a transmissão e não acionam timeouts.
Quando um frame de canal é recusado
Seção intitulada “Quando um frame de canal é recusado”Um frame de canal é fire-and-forget: await channel.broadcast(...) resolve quando o frame é gravado no socket, não quando o servidor o aceitou. Isso é deliberado — um aplicativo colaborativo transmite uma posição de cursor sessenta vezes por segundo, e aguardar uma confirmação para cada uma transformaria cada transmissão em uma viagem de ida e volta (round trip).
Portanto, uma recusa não pode ser uma promise rejeitada. Ela chega no onError:
const channel = client.realtime.channel("doc:42");
channel.onError((error) => { if (error.code === "CHANNEL_FORBIDDEN") showReadOnlyBanner(); if (error.code === "RATE_LIMITED") throttleCursorUpdates();});| Código | Significado |
|---|---|
CHANNEL_FORBIDDEN |
Você não é membro do canal — entre nele antes de transmitir ou ler seu histórico |
RATE_LIMITED |
Ultrapassou o limite de frames do canal mencionado acima |
CHANNEL_HISTORY_WRITE_FAILED |
Um broadcast retido não pôde ser persistido, então foi descartado |
CHANNEL_HISTORY_READ_FAILED |
Uma solicitação de recuperação (catch-up) não pôde ser atendida |
CHANNEL_BUS_PAYLOAD_TOO_LARGE |
O broadcast alcançou apenas esta instância — veja O limite de 8 KB no barramento do Postgres |
Sem nenhum handler anexado, esses eventos são registrados no log como aviso. Eles costumavam ser completamente descartados: não havia promise para rejeitar e nenhum canal para entregar, então um broadcast proibido era indistinguível de um entregue.
Próximos Passos
Seção intitulada “Próximos Passos”- SDK do Cliente — Referência completa do SDK, incluindo acessores tipados de coleção.
- Autenticação — Configure a autenticação JWT e as políticas de RLS.
- Arquitetura do Backend — Visão geral da arquitetura do servidor Rebase.