Documentation

Introduction

Plain fetch + insertMany is often all you need. When there are many rows, or the server should push changes, sorla-db speaks two protocols out of the box. Both are alternatives to fetch, not replacements.

You need Use
a page of data now and then fetch + insertMany / bulkWrite
many rows (100 000+) with progress, resuming after a dropped connection Server-Sent Events: collection.stream(url)
pages on demand (infinite scroll) and live changes from other users WebSocket: collection.connect(url)

Server-Sent Events

The server sends the rows in blocks over one long HTTP response; the collection stores each block with one 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)   // dropped connections, rejected rows
});

const state = await loading.done;   // { loaded, total, blocks, errors, lastEventId, finished }
loading.close();                    // stop early: done resolves with what was loaded

The protocol

The response is Content-Type: text/event-stream. Every event carries 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}
Event Data Effect
meta { total } the number of rows to expect, for progress
rows [docs] or { rows } one bulkWrite: rows whose _id exists are replaced, the others inserted
ops [bulkWrite operations] any change
delete [ids] or { ids } removes documents
end { total? } done: done resolves and the client does not reconnect
abort { message } the server gives up: done rejects with that message

Other event names go to onMessage(event). Comment lines (: keep-alive) are ignored.

Resuming

Give every block an id:. When the connection drops, the client waits (retry: 1000 ms, or what the server sent with retry:), reconnects, and sends the last id in the Last-Event-ID header. The server continues with the next block. Rows sent twice are replaced by _id, never duplicated.

Situation What happens
network error, server restart, stream ended without end reconnect, up to maxRetries (10) failures in a row
HTTP 4xx (except 408 and 429) done rejects with error.status; no retry
HTTP 5xx, 408, 429 retried
a row that breaks the schema skipped and counted in state.errors; onError receives the BulkWriteError; the rest of the block is stored
invalid JSON in an event counted in state.errors, reported to onError

Options

Option Default
transport 'fetch' 'fetch': custom headers (Authorization), any method and body, works in Node 18+. 'eventsource': the browser's EventSource (GET only, cookies, reconnects by itself)
headers, method, body, credentials fetch transport
withCredentials false EventSource transport, cookies across origins
mode 'upsert' 'insert' makes duplicated ids errors
retry 1000 ms before reconnecting
maxRetries 10 failures in a row before done rejects
lastEventId resume a stream from a previous session
onProgress(state), onError(error), onMessage(event) callbacks
apply(type, data) handle rows / ops / delete yourself instead of writing to the collection
fetch, EventSource globals your own implementations (tests, polyfills)

Without a collection, sorla.stream(url, { apply }) hands every block to your code (a data grid that stores rows itself, for example).

A server in Node

A complete endpoint is in 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);   // continue after the last block received
  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);   // respect a slow client
  };
  next();
}).listen(5390);

100 000 rows in 20 blocks load in about 400 ms on a laptop, end to end. Behind nginx, turn off buffering for the endpoint (X-Accel-Buffering: no header or proxy_buffering off).


WebSocket

One connection carries requests and replies (pages for an infinite scroll, or anything you define) and changes the server pushes.

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 }; the rows are stored in app.db.orders too

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 });   // any request type
remote.send('typing', { user: 'ana' });                       // fire and forget
remote.close();

The protocol

Messages are JSON objects with a type:

Direction Message
client → server { type: 'load', id, offset, limit, query, sort, … }, any request(type, payload) as { type, id, …payload }, send(type, payload) as { type, …payload }, { type: 'ping' }
server → client, replies { type: 'reply', id, …result } (for load: rows, hasMore, total), { type: 'error', id, message }
server → client, pushes { type: 'rows', rows }, { type: 'ops', ops }, { type: 'delete', ids }: applied to the collection; anything else is a message event
server → client { type: 'pong' } (optional)

Behavior

  • Requests made while connecting are queued and sent when the socket opens.
  • After a drop the client reconnects with exponential back off: 500 ms, 1 s, 2 s… up to 30 s. Requests still waiting for a reply are rejected (The connection closed before the reply), so retry them; queued ones are sent after the reconnection.
  • A { type: 'ping' } is sent every 25 s, and a connection that stays silent for two heartbeats is replaced, which catches proxies that drop idle connections without telling anyone.
  • Replies time out after 30 s (No reply to "load" after 30000 ms).
Option Default
apply true for collection.connect, false for sorla.connect store loaded and pushed rows in the collection
mode 'upsert' 'insert' makes duplicated ids errors
reconnect true
retry, maxRetry, maxRetries 500, 30000, Infinity back off start, maximum delay, attempts
heartbeat 25000 ms between pings, 0 disables
timeout 30000 ms to wait for a reply, 0 disables
protocols WebSocket sub-protocols
WebSocket global your own implementation (Node: the ws package)

Events: open, close ({ code, reason }), error ({ error }), push ({ message }) and message ({ message } for other types). remote.state is 'connecting', 'open' or 'closed'.

Without a collection

sorla.connect(url, options) returns the same connection with apply: false: load() returns the rows without storing them, which is what a data grid in infinite scroll mode needs.

A server in 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 }));
    }
  });
});

// another user changed something: push it to everyone
function broadcast(rows) {
  for (const client of server.clients) client.send(JSON.stringify({ type: 'rows', rows }));
}

The server can use sorla-db itself, as above: the client's query and sort run unchanged.