
# Real-Time Subscriptions

Data-bound page components can stay current without a full reload by subscribing to record changes. A data source declares a `refreshMode` — `static` (the default), `poll`, or `realtime` — and Sovrium does the rest: poll mode re-fetches on an interval, realtime mode opens a WebSocket (with an SSE fallback) and applies `insert`/`update`/`delete` events as they arrive. External integrations can subscribe programmatically through the same endpoints.

| Endpoint                                   | Transport                               |
| ------------------------------------------ | --------------------------------------- |
| `GET /api/tables/:tableSlug/subscribe`     | WebSocket upgrade (per-table)           |
| `GET /api/tables/:tableSlug/subscribe/sse` | Server-Sent Events fallback (per-table) |
| `GET /api/realtime/subscribe`              | Global WebSocket                        |

## refreshMode on a data source

Set `refreshMode` on any component's `dataSource`. The component's existing `filter` and `sort` scope the live stream too — clients only receive changes that still match the data source's criteria.

```yaml
pages:
  - name: Live Dashboard
    path: /dashboard
    components:
      - type: dataTable
        dataSource:
          table: orders
          filter:
            - field: status
              operator: eq
              value: processing
          sort:
            - field: createdAt
              direction: desc
          refreshMode: realtime
```

## Poll mode

Poll mode periodically re-fetches the data source — no WebSocket needed. Use it when near-real-time is sufficient and connection management overhead is undesirable.

```yaml
dataSource:
  table: inventory
  refreshMode: poll
  pollIntervalMs: 10000 # Re-fetch every 10 seconds
```

| Property         | Default        | Constraint                                      |
| ---------------- | -------------- | ----------------------------------------------- |
| `pollIntervalMs` | `30000` (30 s) | Minimum `1000` (1 s) to prevent server flooding |

## Real-time mode (WebSocket)

`refreshMode: realtime` opens a WebSocket to `/api/tables/:tableSlug/subscribe`. The server pushes change events as records are created, updated, or deleted — from **any** source (REST API, admin UI, direct DB writes, automations).

```
GET /api/tables/orders/subscribe
Upgrade: websocket
```

The server then streams messages such as:

```json
{ "type": "change", "event": "insert", "record": { "id": "42", "fields": {  } } }
{ "type": "change", "event": "update", "record": {  }, "oldRecord": {  } }
{ "type": "change", "event": "delete", "recordId": 42 }
{ "type": "heartbeat", "timestamp": "2026-04-05T12:00:00Z" }
```

Insert/update events carry the full `record` (field-permission filtered server-side); delete events carry only `recordId`. The connection requires authentication (`401` on unauthenticated upgrade) and a valid table slug (`404` otherwise).

## Subscription filtering

Subscriptions respect the data source's current `filter` and `sort`, so clients are not flooded with irrelevant events. A change that no longer matches the filter is delivered as the appropriate transition (e.g. an update that moves a row out of the filtered set is surfaced so the client can drop it).

## Message contract

All realtime messages — WebSocket frames and SSE `data:` events use the **identical** format — are a discriminated union keyed on `type`.

| `type`              | Purpose                                                                                    |
| ------------------- | ------------------------------------------------------------------------------------------ |
| `change`            | `insert`/`update`/`delete` mutation; `record`/`oldRecord` use the `{ id, fields }` payload |
| `conflict`          | A concurrent edit overwrote a pending optimistic change (drives the conflict toast)        |
| `heartbeat`         | Keep-alive emitted every `heartbeatIntervalMs`                                             |
| `subscribed`        | Handshake confirmation; echoes the resolved `filter`/`fields`/`subscriptionId`             |
| `unsubscribed`      | Unsubscription confirmation                                                                |
| `join` / `leave`    | A user opened/left a `presence: true` page (scoped by `pagePath`)                          |
| `presence-sync`     | Full presence snapshot sent once on join                                                   |
| `connection-status` | Transport connectivity (`connected` / `reconnecting`)                                      |

The `change` `record` payload mirrors the Records API `{ id, fields }` envelope, so a client can apply an event to a data-bound cache with no shape translation. The `fields` are already field-permission filtered.

:::callout
**Frozen transport constants.** Client and server share `REALTIME_TRANSPORT_CONFIG`: reconnect backoff `[1000, 2000, 4000, 8000]` ms, max reconnect delay `30000` ms, heartbeat `30000` ms, max `10` connections per user, idle timeout `300000` ms, presence-stale timeout `60000` ms, max `50` presence entries per page.
:::

## Presence awareness

When a page sets `presence: true`, the server broadcasts `join`/`leave`/`presence-sync` messages so teams can see who else is viewing the same page or table — reducing accidental conflicts. Presence is scoped by `pagePath`.

## Conflict resolution & optimistic locking

Realtime drives an optimistic-UI flow: the client applies its edit immediately, then reconciles with the server. If a concurrent edit overwrote a pending optimistic change, the server emits a `conflict` message and the canonical (server-wins) value prevails.

A record `PATCH` may carry a top-level `updatedAt` token. When present, the server rejects a stale write with `409 Conflict`. Tables that participate declare an `updated-at` system field so the column auto-bumps on every write — see [CRUD & Upsert](records-crud).

## Connection management

The client auto-reconnects with the configured backoff, sends/expects heartbeats, and falls back to SSE where WebSocket is unavailable — so realtime features work reliably across network conditions.

## Programmatic subscription

External clients open `/api/tables/:tableSlug/subscribe` (or the global `/api/realtime/subscribe`) and send a handshake naming the `table` (and optional `subscriptionId`, `filter`, `fields`). The server replies with a `subscribed` confirmation, then streams `change` events; an unsubscribe yields an `unsubscribed` confirmation.

## Related pages

- [CRUD & Upsert](records-crud) — the optimistic-locking `updatedAt` contract
- [Filtering, Sorting & Pagination](records-filtering-sorting) — the filter/sort that scope a subscription
- [Records Overview](records-overview) — the `{ id, fields }` envelope the stream mirrors
- [Pages Overview](pages-overview) — data-bound components and `dataSource` configuration
