Live Data: Push, Poll & Freshness
A cache only stays correct if it finds out when the underlying data changes. There are two ways it
can: it asks (poll), or the database tells it (push). dio.watch() is the same call either
way — it asks the master Vista whether it can push, subscribes if so, and quietly does nothing if
not.
That last part is the bit worth knowing before you pick a backend. watch() returning Ok(()) does
not mean you have a live feed; it means nothing went wrong. This page says exactly what each shipped
driver delivers today, and what — if anything — you have to do to get it.
- Three freshness models
- What each backend delivers today
- SpacetimeDB — filtered fine push, no setup
- SurrealDB — fine push, no setup
- PostgreSQL — coarse push, and why you must ask for it
- What a consumer must assume
- Adding push to a driver
Three freshness models
Every backend answers two questions, and the pair decides what you get:
- Can it tell you something changed at all? A file on disk cannot. A database server can.
- When it does, does it hand you the row, or only a signal?
| Model | What arrives | What the Dio does |
|---|---|---|
| poll | nothing — you re-read on a timer | re-lists the whole set |
| coarse push | “something changed”, no payload | re-reads the set to find out what |
| fine push | the action and the affected record | applies that one row to the cache |
Fine push is the only model where a change costs you no query. Coarse push still beats polling: you re-read because something happened, not on the off-chance.
There is a fourth position, currently held by one backend. Fine push still leaves a question open — does this row belong in my filtered set? — because a driver that watches the whole table will hand you rows your conditions exclude. Filtered fine push closes it: the conditions go into the subscription itself and the database maintains the result incrementally, so a row that changes out of the set arrives as a delete. Membership stops being something anyone has to reconcile.
What each backend delivers today
| Backend | Mechanism | Model | Setup required |
|---|---|---|---|
| SpacetimeDB | subscription over WebSocket (BSATN) | filtered fine push | none |
| SurrealDB | LIVE SELECT over WebSocket | fine push | none |
| PostgreSQL | LISTEN/NOTIFY on {table}_changed | coarse push | a trigger and an explicit opt-in |
| SQLite | — | poll | — |
| MySQL | — | poll | — |
| MongoDB | — | poll | — |
| CSV files | — | poll | — |
| REST / GraphQL | — | poll | — |
| AWS | — | poll | — |
| Kubernetes | — | poll | — |
| CLI tools | — | poll | — |
| Append logs | — (write-only sink) | n/a | — |
Only three drivers implement watch_vista at all. Everything in the poll rows inherits the trait
default, which reports the capability as unsupported and leaves
can_subscribe false — so dio.watch() on those is a no-op
and a refresh_every timer carries the load.
A polled backend is not broken, and you do not need different application code for it. The Lens keeps
its refresh_every, the scenery is content-aware, and an unchanged re-read bumps no generation and
repaints nothing. The cost is latency and a query you didn’t strictly need — not correctness.
SpacetimeDB — filtered fine push, no setup
SpacetimeDB is a database that is also the application server: a WASM module holding the schema and the reducers runs inside it, and the client’s normal way to read anything is to subscribe. Push is not a feature bolted on beside queries; it is the product.
The driver sends the Vista’s own conditions as the subscription query, so the server evaluates them and only matching rows are ever transmitted. Because it maintains that result incrementally, a row that changes so it no longer matches arrives as a delete — you are told the row left, not merely that something happened. That is why this is the only row in the table where set membership needs no reconciliation from anyone: not a re-read per notification as SurrealDB does, and not a whole-set reload as Postgres does.
Nothing needs installing or enabling. There is no trigger, no extension, no opt-in flag — which is
why can_subscribe is simply true rather than something you have to declare.
The trade is real and worth stating. SpacetimeDB’s SQL has no ORDER BY, OFFSET, GROUP BY, IN
or LIKE, and the driver does not emulate them — a grid over a SpacetimeDB table shows rows in the
server’s natural order and its sort headers are inert. Aggregates for a chart have to be materialised
by the module into a rollup table or computed in a view. You are choosing a backend that pushes
beautifully and queries narrowly.
SurrealDB — fine push, no setup
LIVE SELECT delivers a typed per-row notification: the action (CREATE / UPDATE / DELETE) and
the affected record. The driver re-reads each notified id through the vista’s own conditions, so a
row that no longer belongs to a filtered set arrives as a delete rather than silently lingering. A
real table is watchable the moment you build it; only query-sourced vistas (rhai: / base:) are
not.
LIVE SELECT needs a transport the server can push frames down, which means WebSocket. That is not a
trap you can fall into: SurrealConnection::connect accepts only ws://, wss:// and cbor:// and
rejects anything else outright, so every SurrealDB connection you can actually open is one that can
carry live queries.
PostgreSQL — coarse push, and why you must ask for it
Postgres has no built-in change feed. It has LISTEN/NOTIFY, a general-purpose pub/sub channel, and
a trigger you install yourself decides what gets announced. The driver listens on {table}_changed
and yields a payload-less invalidation for every notification.
Because you install that trigger, only you know whether it exists — and LISTEN on a channel nobody
ever feeds succeeds, then blocks forever. A Vista that assumed it could push merely because the
table is writable would advertise a feed that may never arrive, which a consumer cannot tell apart
from a table where nothing happens. So it is opt-in:
#![allow(unused)]
fn main() {
let master = db
.vista_factory()
.with_notify(true) // yes, I installed the trigger
.from_table(Product::table(db.clone()))?;
}
The trigger itself, and the full walkthrough, are in Real-Time Push with LISTEN/NOTIFY. The declaration applies to every table a factory builds, so if only some of your tables carry triggers, use separate factories. It also carries across reference traversal — a relation target inherits the parent’s opt-in rather than silently reverting to unwatchable.
What a consumer must assume
Push is best-effort, and the contract is deliberately loose so that a driver can tighten its scope later without breaking anyone. Callers always hand over the full Vista; the driver delivers what it can. Four things hold everywhere:
- The stream may be coarser than your Vista. Watching the whole table and letting the consumer discard the rest is a legitimate implementation.
- A pushed row may fall outside your conditions, precisely because of the above. Either the driver reconciles or you must.
- An invalidation means “re-read everything.” It carries no id and implies nothing about how much changed.
- The stream ending is normal. Connections drop; consumers resubscribe with backoff and reconcile
the gap.
Dio::watch()already does this for you.
The consequence worth internalising: can_subscribe advertises
an attempt to push, not a guarantee of delivery. No shipped backend is lossless, so anything that
must not miss a change keeps a reconcile path — a slow timer, a refresh on reconnect — rather than
trusting push alone.
Adding push to a driver
If you are implementing a backend, the driver-side contract — what you must promise when you
override watch_vista, and when you may honestly advertise the capability — is step 8 of
Adding a New Persistence. Start with the capability
false; turning it on the day the feed works is a healthier state than shipping a flag that lies.