owservable

Wire protocol

Clients subscribe to database queries over a WebSocket and receive the results, then fresh results whenever the data changes. Every message is a JSON object in a text frame.

Overview

The server keeps one client object per socket. A socket can hold any number of subscriptions, each named by a target chosen by the browser (for example openTasks). The target is the key for everything that follows: re-subscribing with the same target replaces its query, results arrive under that key, and unsubscribe takes it as its argument.

Each subscription names an observe channel, which is a collection or table the server has registered, and a scope: a single document, a list, or a count. The server runs the query, sends the result, and runs it again when a relevant change arrives from the database.

Client → server

typefieldseffect
subscribetarget, scope, observe, configStarts a subscription, or replaces the config of an existing one with the same target.
unsubscribetargetStops the subscription.
reloadtargetRuns the query again and sends the full result.
authenticatejwtHands the token to the server's connection manager and starts session refreshes.
locationpathTells the server which page the user is on (only changes are passed on).
pongidAnswers a ping, echoing its id.

Subscribing

{
	"type": "subscribe",
	"target": "openTasks",
	"scope": "many",
	"observe": "tasks",
	"config": {
		"subscriptionId": "5f1c…",
		"query": {"status": "open"},
		"sort": {"dueAt": 1},
		"fields": ["title", "dueAt"],
		"page": 1,
		"pageSize": 20
	}
}
configscopesmeaning
queryallMongo-style filter; see Queries. Default {}.
sortmany, one{"field": 1} ascending, -1 descending. With scope one, the first document in this order is sent.
fieldsmany, oneProjection: {"title": 1} or ["title"]. When set, updates that touch none of these fields (and not the query) don't trigger a reload.
page, pageSizemany1-based page. Without pageSize the whole result is sent.
incrementalmanySend single changed documents instead of whole lists; see Incremental mode.
delayallMinimum milliseconds between reloads caused by changes. Default 100.
subscriptionIdallEchoed on every result. Generated (UUID) if missing. A new id per config change lets the browser ignore results for an older query.
populates, virtualsallBackend-specific extras (mongoose population and virtuals in the TypeScript MongoDB adapter).

Sending the same config again (ignoring subscriptionId) changes nothing. Any other change re-runs the query immediately.

Scopes and payloads

Results arrive as update messages whose payload holds the data under the target's name:

scopepayload[target]notes
onea document, or {} when nothing matchesQuerying by _id loads that document directly; deleting it sends a delete message.
manyan array of documentsAlso _<target>Count (last known total, once known) and _<target>Recounting: true while a new total is being counted. Totals also arrive as separate total messages.
counta numberRe-counted on inserts, deletes, and updates to queried fields.

A dotted target such as dashboard.tasks nests the data: payload.dashboard.tasks.

Server → client

Messages produced by a subscription also carry subscriptionId, target, execution_time (such as "3.12ms"), and the effective query, sort, fields, paging, populates, virtuals as JSON strings, which are useful when debugging.

typefieldsmeaning
updatepayloadCurrent result; see Scopes.
incrementpayloadLike update, for incremental subscriptions.
totaltotalNumber of documents matching a many query (all pages).
deletepayload (the id)A document left the result (incremental lists, or the document of a one subscription by _id).
errorerrorLoading failed, for example on a query the database rejects.
debugid, payload.event, payload.target, payload.availableTargetsSent after each subscribe (updateSubscription) and unsubscribe (removeSubscription); lists the socket's active targets.
refreshpayload.jwt, payload.user, refresh_inRenewed session from the server's connection manager.
pingidLatency probe; answer with pong.

A list subscription, step by step

These messages are recorded from the reference implementation, with timings left out.

1. Subscribe

→ {"type": "subscribe", "target": "list", "scope": "many", "observe": "items",
   "config": {"subscriptionId": "s1", "query": {"status": "open"}, "sort": {"n": 1}}}

← {"type": "debug", "payload": {"event": "updateSubscription", "target": "list", "availableTargets": "list"}, …}
← {"type": "update", "subscriptionId": "s1", "target": "list",
   "payload": {"list": [{"_id": "a", …}, {"_id": "b", …}, {"_id": "d", …}], "_listRecounting": true}, …}
← {"type": "total", "subscriptionId": "s1", "target": "list", "total": 3, …}

The first result is sent before the total is known, so the browser can render immediately.

2. Someone inserts a matching document

← {"type": "update", "payload": {"list": [ …4 documents… ], "_listCount": 3}, …}
← {"type": "total", "total": 4, …}

3. Someone edits a document outside the query

Nothing is sent: the changed document doesn't match {"status": "open"} and the change doesn't touch a queried field.

4. Unsubscribe

→ {"type": "unsubscribe", "target": "list"}
← {"type": "debug", "payload": {"event": "removeSubscription", "target": "list", "availableTargets": ""}, …}

Incremental mode

With "incremental": true a many subscription sends increment messages:

The browser is responsible for sort order and page size when merging single documents.

Timing

Queries

Queries use MongoDB syntax. The server checks changed documents against your query to decide whether to reload, using this operator set:

$eq $ne $gt $gte $lt $lte $in $nin $exists $not $regex $options $and $or $nor

Plain values mean equality; an array field matches if it contains the value; a missing field equals null. With other operators the server can't decide, so it reloads on every change.

backendnotes
MongoDBEverything MongoDB supports. Rust adapter: _id strings and configured fields are cast to ObjectId/Date; Extended JSON ({"$oid": …}, {"$date": …}) works anywhere.
PostgreSQLThe set above plus $like, $ilike. _id means the primary key. No nested paths.
SQLiteAs PostgreSQL, but without $regex.

On the SQL backends $ne, $nin, $not and $nor follow MongoDB, so they also match NULL columns.

Session messages

Authentication is up to the server. It supplies a connection manager that receives authenticate, location, ping round-trip times and disconnects, and can return a refresh message. Data middlewares registered per observe channel can rewrite each result for the connected user before it is sent, for example to strip fields.

Change events (for backend authors)

Backends report changes in the shape of MongoDB change events. The SQL backends build the same shape from triggers:

{
	"operationType": "update",
	"documentKey": {"_id": "42"},
	"updateDescription": {"updatedFields": {"status": "done"}, "removedFields": []},
	"fullDocument": {"id": 42, "title": "Ship it", "status": "done"}
}

An empty object {} means "something may have changed, reload everything". Backends send it after reconnecting to the database.

Compatibility

The TypeScript packages (v3) and the Rust crates speak the same protocol. The Rust implementation is tested against message sequences recorded from @owservable/core 3.2.0. Known differences: