# Broadcast, presence and table changes
Source: https://sovaria.in/docs/realtime-broadcast-and-presence

supabase's channel API in every SDK — broadcast, presence and postgres_changes with subscribe, send, track, presenceState and removeChannel: how it maps onto Sovaria channels, guarantees, limits and security.

One channel object for the three things a live screen needs: **broadcast** (short messages to everyone on the channel: cursors, typing, game moves), **presence** (who is here and each person's state) and **table changes** (a row was added, changed or deleted). The calls are supabase's; underneath they are Sovaria channels, so channel rules, plan limits and catch-up after reconnects all apply ([Channels and connections](https://sovaria.in/docs/realtime-channels)).

TypeScript:
```ts
const room = sovaria.channel('room-42', { config: { broadcast: { self: false, ack: false }, presence: { key: user.id } } })

room
  .on('broadcast', { event: 'cursor' }, ({ payload }) => moveCursor(payload))
  .on('presence', { event: 'sync' }, () => setOnline(room.presenceState()))
  .on('presence', { event: 'join' }, ({ key, newPresences }) => toast(`${key} joined`))
  .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages' }, () => reloadMessages())
  .subscribe(async (status) => {
    if (status === 'SUBSCRIBED') await room.track({ name: user.name, online_at: Date.now() })
  })

await room.send({ type: 'broadcast', event: 'cursor', payload: { x: 120, y: 48 } })
// later
await sovaria.removeChannel(room)
```

Python:
```python
room = sovaria.channel("room-42", {"config": {"presence": {"key": user_id}}})
room.on_broadcast("cursor", lambda msg: move_cursor(msg["payload"]))
room.on_presence_sync(lambda: set_online(room.presence_state()))
room.on_postgres_changes("INSERT", lambda change: reload_messages(), table="messages")
await room.subscribe()
await room.track({"name": name})
await room.send_broadcast("cursor", {"x": 120, "y": 48})
await sovaria.remove_channel(room)
```

Swift:
```swift
let room = sovaria.channel("room-42", presenceKey: userId)
room.onBroadcast(event: "cursor") { message in moveCursor(message["payload"]) }
    .onPresenceChange { _ in setOnline(room.presenceState()) }
    .onPostgresChange(table: "messages", event: "INSERT") { _ in reloadMessages() }
room.subscribe()
try await room.track(["name": name])
try await room.broadcast(event: "cursor", message: ["x": 120, "y": 48])
await sovaria.removeChannel(room)
```

Kotlin:
```kotlin
val room = sovaria.channel("room-42", presenceKey = userId)
room.onBroadcast("cursor") { moveCursor(it["payload"]) }
    .onPresenceChange { setOnline(room.presenceState()) }
    .onPostgresChange("messages", "INSERT") { reloadMessages() }
    .subscribe()
room.track(mapOf("name" to name))
room.broadcast("cursor", mapOf("x" to 120, "y" to 48))
sovaria.removeChannel(room)
```

Dart:
```dart
final room = sovaria.channel('room-42', presenceKey: userId)
  ..onBroadcast(event: 'cursor', callback: (msg) => moveCursor(msg['payload']))
  ..onPresenceSync(() => setOnline(room.presenceState()))
  ..onPostgresChanges(table: 'messages', event: 'INSERT', callback: (_) => reloadMessages())
  ..subscribe();
await room.track({'name': name});
await room.sendBroadcastMessage(event: 'cursor', payload: {'x': 120, 'y': 48});
await sovaria.removeChannel(room);
```

## Broadcast

- `send({ type: 'broadcast', event, payload })` reaches everyone subscribed to the channel; `.on('broadcast', { event }, cb)` receives one event name (`'*'` for all).
- You don't get your own messages unless `config.broadcast.self` is true. With `config.broadcast.ack`, `send()` waits until the server accepted the message.
- Messages a plain Sovaria client publishes (`sovaria.realtime.publish`) arrive as broadcasts too, with their `event` (or `'message'`).
- Each message counts toward your plan's realtime messages and is checked against the channel's publish rule and size limit.

## Presence

- `track(state)` shares this client's state; `presenceState()` gives everyone's, by presence key (`config.presence.key`, or the user's id). `join` and `leave` events say who came and went; `sync` fires after any change.
- Each entry carries `user_id`: the signed-in user the **server** stamped on the message. A client can pick its presence key but can't pretend to be someone else — check `user_id` before you trust an entry.
- Only the server sends the full list (`presence_state`) and the changes (`presence_diff`). A client may publish only `track` and `untrack`; any other presence message is refused (`publish_rejected`, or 400 on the publish API), and the SDKs ignore a presence list that another client sent.
- Presence is kept by the server. A client that joins is sent everyone's current state at once. Everyone on the channel is told when someone tracks, untracks or disconnects. If a connection drops without saying goodbye (a closed laptop, a lost network), its entry is removed within 90 seconds and the others are told.
- A state can be up to 4 KB, and a channel can have up to 1,000 people present (limits set by Sovaria). Past either limit, `track` is refused with `presence_refused`.
- Each `track` (and its refresh) is a realtime message. For a plain "who is connected" list without state, `sovaria.realtime.presence(channel)` costs nothing ([Presence](https://sovaria.in/docs/realtime-presence)).

### Presence sent through the publish API

Current SDKs send `track` and `untrack` on the channel's own connection. Older ones sent them through the publish API (`POST /api/v1/rt/stream/publish/{channel}`): the JavaScript SDK up to 0.9.2 when `config.broadcast.ack` was on, and the Python SDK up to 0.11.2 always. Sovaria keeps that presence too, with the same rules:

- **Signed-in users only.** A publish with just the project key, the service role key or an internal caller is still delivered, but no presence is kept for it.
- **Your own entry only.** The entry is stored for the signed-in caller (one per tab, by the message's `ref`) and `user_id` is stamped by the server, so nobody can replace or remove another user's presence.
- **The same channel rules.** Publishing needs the PUBLISH rule; keeping a `track` also needs SUBSCRIBE, as a connection would. `untrack` needs only PUBLISH.
- **The same limits.** 4 KB of state and 1,000 people per channel, plus at most **10 entries per user per channel** (one per open tab). These limits are set by Sovaria, the same for every project.
- **90-second expiry.** There is no connection to watch, so an entry stays for **90 seconds** after its last `track` (those SDKs re-send it every 30 seconds), then leaves and everyone is told, as for a dropped connection. `untrack` removes it at once.
- **Refusals don't fail the publish.** The message is still delivered and the answer is `200`; when the presence couldn't be kept, the answer has a `presence_refused` field saying why (state too large, channel full, too many tabs, or no SUBSCRIBE rule).

Answer:
```json
{ "channel": "room-1", "published": true, "stream_seq": 42,
  "presence_refused": "At most 10 presence entries per user on a channel" }
```

> **Tip:** Update to the current SDKs: presence on the connection leaves the moment a tab closes, instead of up to 90 seconds later.

## Table changes (postgres_changes)

`.on('postgres_changes', { event: 'INSERT' | 'UPDATE' | 'DELETE' | '*', schema: 'public', table }, cb)` listens on the table's change events — turn them on for the table first ([Database change events](https://sovaria.in/docs/realtime-database-changes)). The callback gets supabase's payload shape:

Payload:
```json
{ "schema": "public", "table": "messages", "eventType": "INSERT", "commit_timestamp": "2026-10-31T09:12:44.512Z",
  "new": { "id": 42, "room_id": 7, "body": "hi" }, "old": {}, "errors": null, "row_count": 1 }
```

`new` is the row as this user could read it: each subscriber gets only the rows your access policies and row-level security let it see. A change to a row it can't see isn't sent to it at all. A delete carries the row's primary key in `old` only for tables without row-level security. `filter` (`'room_id=eq.42'`) is applied on the server, so only matching rows are sent. Details: [Database change events](https://sovaria.in/docs/realtime-database-changes).

## Subscribing and leaving

| Status | When |
|---|---|
| `SUBSCRIBED` | The server admitted this user to the channel (then presence and broadcast work). The SDKs (JS 0.10.5+, Python 0.12.3+) wait for that confirmation, so a refused channel goes straight to `CHANNEL_ERROR` and never shows `SUBSCRIBED` first. |
| `CHANNEL_ERROR` | The connection was refused: no rule lets this user subscribe, or the session ended. |
| `TIMED_OUT` | No connection within 10 seconds (pass another timeout to `subscribe`). |
| `CLOSED` | After `unsubscribe()` / `removeChannel()`, or the connection closed for good. |

Under one channel object there is one connection for the channel and one per table listened to; they reconnect with backoff and catch up on missed broadcasts by themselves. `removeAllChannels()` closes everything (on sign-out, for example).

## Rules and limits

- Subscribing needs the channel's SUBSCRIBE rule; sending and tracking need its PUBLISH rule ([Channels and connections](https://sovaria.in/docs/realtime-channels)). Table changes need a SUBSCRIBE rule on `db.table.<table>`, or a read rule that names the table (an explicit ALLOW).
- Plan limits apply per message and connection: messages per month, concurrent connections, message size and events per second ([Realtime settings and limits](https://sovaria.in/docs/realtime-settings)).

## Why a connection was refused

A refused connection is opened and then closed at once with a code and a reason, so your app can tell why. The SDKs stop retrying on these codes and report a typed error (`RealtimeClosedError` in JavaScript and Python, `RealtimeClosedException` in Dart, `SovariaError.realtimeClosed` in Swift, `SovariaException.realtimeClosed` in Kotlin) with the code and the reason. Other closes (a network drop, `1011`, `1013` busy) reconnect with backoff.

| Code | Meaning | Error code |
|---|---|---|
| `4401` | Sign in again: no session, or the ticket expired or was already used. | `UNAUTHENTICATED` |
| `4403` | No rule lets this user subscribe to the channel, or access was revoked (reason `CHANNEL_DENIED: …`). | `PERMISSION_DENIED` |
| `4408` | A malformed request, such as a bad `filter`. | `BAD_REQUEST` (Dart, Swift, Kotlin: `INVALID_ARGUMENT`) |
| `4429` | A rate or connection limit: plan, project or per-user (reason `PLAN_LIMIT_REACHED: …` and so on). | `RATE_LIMITED` |
