Peer to peer channels
Open a channel to other users over the rows of a query with useChannel and their writes reach you over WebRTC in tens of milliseconds, optimistically, with ephemeral fields that never touch the database.
Channels are experimental. The API may change between canary releases.
A normal write reaches another device through the database: outbox, server, sync, about a second end to end. That is fine for a list. For a game, a shared cursor or “follow me through this game” in a call, it is too slow.
A channel connects you to a few other users directly, over WebRTC, for the rows a query matches. Any write one of them makes to those rows reaches the others in tens of milliseconds and lands in their cache like their own optimistic write would. The normal sync still runs underneath, so the database stays the source of truth.
1. Opt a table in
Put -- @channel above the table. Fields that should travel only between peers, never to the
database, get -- @ephemeral:
The rules, checked by spky generate:
useChannelonly takes queries on@channeltables, both in the types and at runtime. Other tables never fan out.- An
@ephemeralfield must be optional (TYPE option<...>): the database never gets a value for it. @ephemeralcannot be combined with@crdt,@opaque,@nosync,@parentor@cursor, and you cannot filter or order on it (the query builder throws).- A
@channeltable cannot be@nosync.
Apply the schema (spky migrate / spky deploy). It now also holds _00_channel_signal, the
server-only table the connections are set up through.
2. Open a channel
useChannel(query, peers) opens a channel for as long as the component lives. The query says
which rows the channel carries: only its where counts. It is checked against each row on both
sides and never registered or synced. peers is a list of users, or an accessor of one, re-read
when it changes. Your own id is skipped, so listing everyone is fine:
The query may be an accessor (re-read when it changes; returning nothing closes the channel until it
returns a query again), a built query, or a list of queries. Everything is typed from the query:
it must be on a @channel table, and the channel’s writes take that table’s fields.
ch.status() | Meaning |
|---|---|
idle | The peers list is empty (or only you). |
connecting | Setting up a connection. |
connected | At least one listed user has a tab connected. ch.peers() lists them. |
failed | No connection could be made (see networks). It keeps retrying. |
unsupported | No WebRTC in this runtime (SSR, tests), or a Dart client without ChannelsConfig(rtc: ...). |
Outside components, db.channel(query, peers) returns the same handle; call close() when done.
A connection only forms when each side’s channel lists the other. Nobody can push themselves into your channel, and leaving a user out disconnects them.
What the filter can be
The where must be decidable from the row alone: the table’s own fields (nested object paths
included), literal values, and =, !=, <, <=, >, >=, IN, NOT IN, CONTAINS,
CONTAINSNOT and _or groups. Opening a channel throws for .related(), a filter that follows a
record link ('conversation.owner') or a $parameter.
orderBy, limit, offset, select and .one() do nothing in a channel. They are not an error,
but each draws a warning (once per query shape). When one query object serves both a list and a
channel, say so with .ignoreUnusedArgs():
3. Write as usual
There is no new write API. db.update, db.create and db.delete reach the peers of every open
channel whose filter the row matches:
What happens to each field of an update:
| Field | You | Peers | Database |
|---|---|---|---|
| normal | optimistic, through the outbox (as always) | shows it at once, until their synced row catches up | written as always |
@ephemeral | in memory, shows at once | shows it at once, last writer wins | never sent |
- A peer’s normal field is a claim. A peer shows it until its own synced row holds the same
value, or for 5 seconds at most (
channels.confirmTimeoutMs). A value the server rejected, or one a peer made up, falls back to the server’s on its own. - Ephemeral values live as long as the connection. A peer that connects later gets everyone’s current values; a peer that leaves takes its values with it.
- Writes never wait for the connection. Before it is up, normal fields still sync; ephemeral values go out the moment it opens.
Routing by the filter
Each write goes only to the channels whose query the row matches, so with Bob’s and Carol’s chats
open at once, a message in Bob’s conversation reaches Bob and not Carol. A create is checked
against the new row; an update or delete against the row as stored (one local read), before and
after the patch, so a row moving to another conversation reaches both. The channel’s own
ch.update / ch.create / ch.delete limit a write to that channel’s peers (and still only when
the row matches its query).
Creates and deletes
A created row reaches the peers the same way, so a new chat message shows up on the other side as fast as a cursor moves:
- The peer puts it in its cache like its own optimistic create, so every query that matches it
shows it, sorted where it belongs. The server’s row still downloads and replaces it, computed fields
(
VALUE time::now()) included. - Unconfirmed, it is undone. If the peer’s sync has not delivered the row within
confirmTimeoutMs, the peer removes it again. An id the peer already holds a synced row for is refused, so a peer cannot overwrite real data. - A delete hides the row on the peer until the server confirms it. If the server never does, the row comes back. Deleting a create that was never confirmed simply undoes it.
@ephemeralfields of a created row go to the peers’ overlay, never their store.
Who may send what
Sp00ky proves who sent an update. What to accept is your decision.
What is checked for you, on every message:
- The sender is listed by one of your open channels, and the row matches that channel’s query: the new row for a create, your copy for an update (before or after the patch) or a delete. An update or delete of a row you do not hold is ignored, since there is nothing to check it against.
- The table is
@channel, every field exists on it and fits its column type.
What you add: an accept rule per table, in the client config. It runs after those checks:
The rule gets { kind, from, table, id, row, patch }: kind is update, create or delete, row
is the row as you show it (undefined for a create) and patch the fields (the whole new row for a
create, {} for a delete).
Ephemeral fields have no other check than your rule: the database never sees them. For normal fields the server still enforces your table permissions on the real write, so the worst a peer can do is show a wrong value for the confirm timeout.
The database stamps each signaling row with its sender, and the offer carries the fingerprint of the encrypted connection it opens. That is what lets the receiving tab know which user is on the other end. Impersonation sessions may not open channels.
How a connection is set up
Signaling goes through the database, once per pair of tabs, not per message:
- A tab that wants user B writes a
hellorow to B. B’s tabs hear it through one rawLIVE SELECT. - Of the two tabs, the one with the lower id offers, the other answers. ICE candidates trickle in small batches, so nothing waits for gathering to finish.
- The WebRTC data channel opens. From here on, messages go peer to peer.
Setup costs about two database round trips plus the WebRTC handshake. Open the channel when the screen mounts and it is usually ready long before anyone writes. One connection per pair of tabs is shared by every channel between them. Old signaling rows are swept as new ones arrive.
Networks and TURN
The default transport uses a public STUN server. Most connections work with that. Some networks (strict NATs, corporate firewalls) need a TURN relay; add one:
Without a connection, everything still works at normal sync speed, except ephemeral fields, which do
not arrive. ch.status() is failed, so the UI can say so.
A mesh connects every pair of participants, which suits 2 to 8 people (calls, games). An app that
already has a room (a LiveKit call, its own relay) can replace the transport with channels.transport
(see ChannelTransport in @spooky-sync/core).
Example: follow someone through a game in a call
The call row holds what each side is looking at. The game stays persistent (it survives a reload), the move is ephemeral (scrubbing writes nothing to the database). The existing query keeps showing the row; the channel only makes the peer’s writes arrive sooner:
Flutter and Dart
spooky_core has the same channels, on the same wire protocol: a Flutter app and a browser tab in
one channel connect to each other. The core is pure Dart and ships no WebRTC of its own, so give it
the flutter_webrtc connections from spooky_flutter:
Then open a channel with client.channel(query, peers) (a QueryBuilder or TypedQuery, or a
list of them) and write as usual:
ch.state/ch.states(aStream<ChannelState>, current value first) /ch.subscribe(cb),ch.setPeers,ch.setQueries,ch.close(), and the scopedch.update/ch.create/ch.delete. Peers aretable:idstrings orRecordIds.- Peers’ values fold into every query
Streamshowing the row, as on the web. Dart rows hold record links and dates as strings, so a peer’s values arrive in that shape too; a Dart client still sends them to a browser peer as record ids and dates. - Regenerate the Dart schema (
dart run spooky_core:spooky_gen) after adding-- @channel/-- @ephemeral: it marks the table ('channel': true) and the field (ephemeral: true), and rejects the same mistakesspky generatedoes. - With the default background client, the connections live on the app’s isolate and the database work (signaling rows, peers’ creates and deletes) in the sync worker.
Limits
- Mesh topology, so small groups.