Documentação

Introdução

Um simples fetch + insertMany muitas vezes é tudo de que você precisa. Quando há muitas linhas, ou quando o servidor deve enviar as mudanças por conta própria, o sorla-db já fala dois protocolos de fábrica. Os dois são alternativas ao fetch, não substitutos.

Você precisa de Use
uma página de dados de vez em quando fetch + insertMany / bulkWrite
muitas linhas (100 000+) com progresso, retomando depois de uma queda de conexão Server-Sent Events: collection.stream(url)
páginas sob demanda (rolagem infinita) e mudanças ao vivo feitas por outros usuários WebSocket: collection.connect(url)

Server-Sent Events

O servidor envia as linhas em blocos por uma única resposta HTTP longa; a coleção armazena cada bloco com um bulkWrite.

const loading = app.db.orders.stream('/api/orders/stream', {
  headers: { Authorization: `Bearer ${token}` },
  onProgress: ({ loaded, total }) => (progress.value = total ? loaded / total : 0),
  onError: error => console.warn(error.message)   // conexões que caíram, linhas rejeitadas
});

const state = await loading.done;   // { loaded, total, blocks, errors, lastEventId, finished }
loading.close();                    // para antes do fim: done resolve com o que já foi carregado

O protocolo

A resposta é Content-Type: text/event-stream. Cada evento carrega JSON:

retry: 2000
event: meta
data: {"total": 100000}

id: 1
event: rows
data: [{"_id": 1, "name": "…"}, … 5000 rows …]

id: 2
event: rows
data: [...]

event: end
data: {"total": 100000}
Evento Dados Efeito
meta { total } o número de linhas esperado, para o progresso
rows [docs] ou { rows } um bulkWrite: linhas cujo _id já existe são substituídas, as demais são inseridas
ops [bulkWrite operations] qualquer mudança
delete [ids] ou { ids } remove documentos
end { total? } fim: done resolve e o cliente não reconecta
abort { message } o servidor desiste: done rejeita com essa mensagem

Outros nomes de evento vão para onMessage(event). Linhas de comentário (: keep-alive) são ignoradas.

Retomada

Dê um id: a cada bloco. Quando a conexão cai, o cliente espera (retry: 1000 ms, ou o que o servidor enviou com retry:), reconecta e envia o último id no cabeçalho Last-Event-ID. O servidor continua a partir do próximo bloco. Linhas enviadas duas vezes são substituídas pelo _id, nunca duplicadas.

Situação O que acontece
erro de rede, reinício do servidor, stream encerrado sem end reconecta, até maxRetries (10) falhas seguidas
HTTP 4xx (exceto 408 e 429) done rejeita com error.status; sem nova tentativa
HTTP 5xx, 408, 429 tenta de novo
uma linha que viola o schema é ignorada e contada em state.errors; onError recebe o BulkWriteError; o resto do bloco é armazenado
JSON inválido em um evento contado em state.errors e informado ao onError

Opções

Opção Padrão
transport 'fetch' 'fetch': cabeçalhos personalizados (Authorization), qualquer método e corpo, funciona no Node 18+. 'eventsource': o EventSource do navegador (só GET, cookies, reconecta sozinho)
headers, method, body, credentials transporte fetch
withCredentials false transporte EventSource, cookies entre origens
mode 'upsert' 'insert' faz com que ids duplicados sejam erros
retry 1000 ms antes de reconectar
maxRetries 10 falhas seguidas antes de done rejeitar
lastEventId retoma um stream de uma sessão anterior
onProgress(state), onError(error), onMessage(event) callbacks
apply(type, data) trata rows / ops / delete você mesmo, em vez de gravar na coleção
fetch, EventSource globais as suas próprias implementações (testes, polyfills)

Sem uma coleção, sorla.stream(url, { apply }) entrega cada bloco ao seu código (uma grade de dados que armazena as linhas por conta própria, por exemplo).

Um servidor em Node

Um endpoint completo está em example/sse_server.js:

http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' });
  let block = Number(req.headers['last-event-id'] ?? 0);   // continua depois do último bloco recebido
  res.write(`event: meta\ndata: ${JSON.stringify({ total: TOTAL })}\n\n`);
  const next = () => {
    const rows = rowsOfBlock(block);
    if (!rows.length) return res.end('event: end\ndata: {}\n\n');
    block++;
    const ok = res.write(`id: ${block}\nevent: rows\ndata: ${JSON.stringify(rows)}\n\n`);
    ok ? setImmediate(next) : res.once('drain', next);   // respeita um cliente lento
  };
  next();
}).listen(5390);

100 000 linhas em 20 blocos carregam em cerca de 400 ms num notebook, de ponta a ponta. Atrás de um nginx, desative o buffering para o endpoint (cabeçalho X-Accel-Buffering: no ou proxy_buffering off).


WebSocket

Uma única conexão transporta requisições e respostas (páginas para uma rolagem infinita, ou qualquer coisa que você definir) e as mudanças que o servidor envia.

const remote = app.db.orders.connect('wss://api.example.com/orders');

const page = await remote.load({ offset: 0, limit: 200, query: { status: 'open' }, sort: { due: 1 } });
// { rows, hasMore, total }; as linhas também são armazenadas em app.db.orders

remote.on('push', ({ message }) => console.log('server changed', message.type));
remote.on('open', () => status.textContent = 'online');
remote.on('close', () => status.textContent = 'reconnecting…');

const stats = await remote.request('stats', { year: 2026 });   // qualquer tipo de requisição
remote.send('typing', { user: 'ana' });                       // envia sem esperar resposta
remote.close();

O protocolo

As mensagens são objetos JSON com um type:

Direção Mensagem
cliente → servidor { type: 'load', id, offset, limit, query, sort, … }, qualquer request(type, payload) como { type, id, …payload }, send(type, payload) como { type, …payload }, { type: 'ping' }
servidor → cliente, respostas { type: 'reply', id, …result } (para load: rows, hasMore, total), { type: 'error', id, message }
servidor → cliente, envios do servidor { type: 'rows', rows }, { type: 'ops', ops }, { type: 'delete', ids }: aplicados à coleção; qualquer outra coisa vira um evento message
servidor → cliente { type: 'pong' } (opcional)

Comportamento

  • Requisições feitas durante a conexão ficam numa fila e são enviadas quando o socket abre.
  • Depois de uma queda, o cliente reconecta com espera crescente exponencial: 500 ms, 1 s, 2 s… até 30 s. Requisições que ainda aguardavam resposta são rejeitadas (The connection closed before the reply), então tente-as de novo; as que estão na fila são enviadas depois da reconexão.
  • Um { type: 'ping' } é enviado a cada 25 s, e uma conexão que fica em silêncio por dois heartbeats (pings periódicos) é substituída, o que pega proxies que derrubam conexões ociosas sem avisar ninguém.
  • As respostas expiram depois de 30 s (No reply to "load" after 30000 ms).
Opção Padrão
apply true para collection.connect, false para sorla.connect armazena na coleção as linhas carregadas e as enviadas pelo servidor
mode 'upsert' 'insert' faz com que ids duplicados sejam erros
reconnect true
retry, maxRetry, maxRetries 500, 30000, Infinity início da espera crescente, atraso máximo, tentativas
heartbeat 25000 ms entre pings; 0 desativa
timeout 30000 ms de espera por uma resposta; 0 desativa
protocols subprotocolos do WebSocket
WebSocket global a sua própria implementação (Node: o pacote ws)

Eventos: open, close ({ code, reason }), error ({ error }), push ({ message }) e message ({ message } para os outros tipos). remote.state é 'connecting', 'open' ou 'closed'.

Sem uma coleção

sorla.connect(url, options) retorna a mesma conexão com apply: false: load() retorna as linhas sem armazená-las, que é o que uma grade de dados no modo de rolagem infinita precisa.

Um servidor em Node

import { WebSocketServer } from 'ws';

const server = new WebSocketServer({ port: 5391 });
server.on('connection', socket => {
  socket.on('message', raw => {
    const message = JSON.parse(raw);
    if (message.type === 'ping') return socket.send(JSON.stringify({ type: 'pong' }));
    if (message.type === 'load') {
      const cursor = orders.find(message.query ?? {}).sort(message.sort ?? { _id: 1 });
      const total = cursor.length;
      const rows = cursor.skip(message.offset).limit(message.limit).toArray();
      socket.send(JSON.stringify({ type: 'reply', id: message.id, rows, total, hasMore: message.offset + rows.length < total }));
    }
  });
});

// outro usuário mudou algo: envia para todo mundo
function broadcast(rows) {
  for (const client of server.clients) client.send(JSON.stringify({ type: 'rows', rows }));
}

O servidor pode usar o próprio sorla-db, como acima: a query e o sort do cliente rodam sem alterações.