# Realtime and Collaborative Apps | Volcano

URL: https://volcano.dev/solutions/realtime-apps

> Build collaborative apps on Volcano: Postgres change streams, broadcast and presence over one WebSocket, with row-level security deciding what each subscriber receives.

## Live updates without a socket server

Row changes, broadcast messages and presence arrive on one connection, filtered by the same policies as your queries.

```console
$ volcano cloud config deploy
realtime enabled · broadcast · presence · postgres_changes
✓ wss://…/realtime/v1/websocket ready
# then, in the browser
$ channel.onPostgresChanges('INSERT', 'public', 'cards', render)
INSERT public.cards { id: 'c_18f…', title: 'Ship it' }
# subscribers only receive rows their policies already allow
```

## Collaboration is a state problem, not a socket problem

Standing up a WebSocket server is the first hour. The rest is the hard part: keeping the socket's idea of the world in step with the database, deciding who may hear which event, and cleaning up presence when a laptop closes without saying goodbye.

Volcano streams the changes from Postgres itself and applies your read policies per subscriber. Add broadcast for the things you do not want to persist, like a cursor position, and presence for who is currently here — all on the same connection.

## The database is the event source

*Changes*

Subscribe to a table and each insert or update arrives because it was committed, not because someone remembered to publish it. A background job's write reaches the UI on the same path a user's does.

- Inserts and updates, or every event type
- Filter by schema and table
- Writes from anywhere reach subscribers

```console
# a scheduled function updates a row
$ update cards set status = 'done' where id = 'c_18f';
browser A  UPDATE public.cards → status: done
browser B  UPDATE public.cards → status: done
nothing had to publish an event
```

## The stream is filtered per subscriber

*Authorization*

A subscription is bounded by the same row-level policies as a query. Two people on the same channel see different rows if that is what your rules say, so a live feed cannot become a leak.

- Read policies apply to the stream
- No second permission model for events
- Per-subscriber, not per-channel

Image: Realtime settings with change streams, broadcast and presence enabled.

## Cursors and who is here

*Live-only data*

Broadcast carries messages you never want in a table, and presence tracks who is on the channel with automatic cleanup when a connection drops. Neither needs a row.

- Broadcast for cursors, typing, nudges
- Presence syncs the full roster on every change
- Cleanup happens when the socket closes

```console
# onPresenceSync fires with the whole roster
sync  ada@example.com, cleo@example.com
…cleo closes her laptop
sync  ada@example.com
1 present · no cleanup job to write
```

## How it works

1. **Turn realtime on**: Enable it in volcano-config.yaml and deploy. There is no server to provision.
2. **Write the read policy**: The policy that filters a SELECT is what filters the stream, so get it right once.
3. **Subscribe to changes**: Listen for row events on the tables that drive your UI and apply them to local state.
4. **Cursors and presence**: Broadcast the things that should not persist, and track who is on the channel.

## Enable it, secure it, subscribe, collaborate

### Enable

```yaml
version: 1

realtime:
  enabled: true

functions:
  - name: board-api
    visibility: public

# Deploying this creates the WebSocket endpoint for the project:
#   wss://<api-host>/realtime/v1/websocket
#
# Preview what will change before applying:
#   volcano cloud config deploy --dry-run
```

### Secure

```sql
create table if not exists boards (
  id      uuid primary key default gen_random_uuid(),
  name    text not null,
  team_id uuid not null
);

create table if not exists cards (
  id         uuid primary key default gen_random_uuid(),
  board_id   uuid not null references boards(id) on delete cascade,
  title      text not null,
  status     text not null default 'todo',
  updated_at timestamptz not null default now()
);

alter table boards enable row level security;
alter table cards  enable row level security;

-- Subscribers receive a change only if this same rule would have let them
-- select the row. One policy covers the query path and the stream.
drop policy if exists team_reads_cards on cards;
create policy team_reads_cards on cards
  for select to authenticated
  using (exists (
    select 1
    from boards b
    join memberships m on m.team_id = b.team_id
    where b.id = cards.board_id and m.user_id = auth.uid()
  ));

drop policy if exists team_writes_cards on cards;
create policy team_writes_cards on cards
  for all to authenticated
  using (exists (
    select 1
    from boards b
    join memberships m on m.team_id = b.team_id
    where b.id = cards.board_id and m.user_id = auth.uid()
  ));
```

### Subscribe

```typescript
import { VolcanoRealtime } from '@volcano.dev/sdk/realtime';

// The access token is what makes the stream yours: policies filter it against
// the signed-in user. The client and database name let the SDK fetch each
// changed row, under the same policies.
export const realtime = new VolcanoRealtime({
  apiUrl: process.env.NEXT_PUBLIC_VOLCANO_API_URL!,
  anonKey: process.env.NEXT_PUBLIC_VOLCANO_ANON_KEY!,
  accessToken: session.access_token,
  volcanoClient: volcano,
  databaseName: 'app',
});

await realtime.connect();

export async function watchBoard(boardId: string, apply: (card: Card) => void) {
  // A Postgres channel streams one table and is named schema:table. The
  // policy decides which cards reach this user; the board check narrows them
  // to the view that is open.
  const channel = realtime.channel('public:cards', { type: 'postgres' });

  // Inserts and updates arrive here. A signed-in user's subscription does not
  // receive deletes, so remove cards when the server says they are gone.
  channel.onPostgresChanges('*', 'public', 'cards', ({ record }) => {
    if (record?.board_id === boardId) apply(record);
  });

  await channel.subscribe();

  // Always tear the channel down when the view unmounts.
  return () => channel.unsubscribe();
}
```

### Collaborate

```typescript
export async function collaborate(boardId: string, me: { id: string; email: string }) {
  const cursors = realtime.channel(`board:${boardId}:cursors`, { type: 'broadcast' });
  const people  = realtime.channel(`board:${boardId}:who`,     { type: 'presence' });

  // Cursors move constantly and are worthless a second later — broadcast
  // them instead of writing rows.
  cursors.on('cursor', (data) => {
    const { userId, x, y } = data as { userId: string; x: number; y: number };
    if (userId !== me.id) drawCursor(userId, x, y);
  });

  // Presence hands you the whole state on every change rather than
  // individual join and leave events. Each entry carries the user's id and
  // the metadata they signed up with.
  people.onPresenceSync((state) => {
    renderAvatars(Object.values(state).map(({ connInfo }) => connInfo));
  });

  // Subscribing to the presence channel is what puts you on the roster.
  await cursors.subscribe();
  await people.subscribe();

  const onMove = throttle((e: PointerEvent) => {
    cursors.send({ type: 'cursor', userId: me.id, x: e.clientX, y: e.clientY });
  }, 50);

  window.addEventListener('pointermove', onMove);

  return () => {
    window.removeEventListener('pointermove', onMove);
    cursors.unsubscribe();
    people.unsubscribe();
  };
}
```

## What you skip

- **0 servers to run or scale**: The WebSocket endpoint appears when you enable realtime in the manifest.
- **1 policy for queries and streams**: The rule that filters a SELECT filters what each subscriber receives.
- **3 channels on one connection**: Row changes, broadcast and presence share a single WebSocket.

## Outcomes

- **Always in step**: The UI updates because the row was committed, not because code remembered to publish.
- **Streams that cannot leak**: Subscribers receive only rows their policies already allow.
- **Presence that cleans itself**: A dropped connection leaves the channel without a reaper job.

## Built on Volcano

- [The connection](https://volcano.dev/features/realtime): Postgres change streams, broadcast and presence over one WebSocket, enabled from the manifest.
- [The source of truth](https://volcano.dev/features/postgres): PostgreSQL 16 through 18 whose committed writes are the events, with policies filtering each stream.
- [Who is on the channel](https://volcano.dev/features/authentication): Verified sessions, so presence shows real users and policies know who is subscribing.
- [Progress from long work](https://volcano.dev/features/durable-functions): Durable functions update a row or broadcast at each step, so the page follows a workflow as it runs.

## Frequently asked questions

### How do I turn realtime on?

Set realtime.enabled to true in volcano-config.yaml and run volcano cloud config deploy. That creates the WebSocket endpoint for the project; there is no server to provision.

### Can a subscriber receive rows they cannot read?

No. Row-level security applies per subscriber, so two people on the same channel can receive different subsets of the same table.

### When should I use broadcast instead of row changes?

When the message should not persist. Cursor positions, typing indicators and nudges are broadcast; anything you would want after a refresh should be a row.

### What happens when a connection drops?

Presence drops that client and syncs the new roster to everyone else, so participants see them disappear without a cleanup job on your side.

### Do writes from a function reach subscribers?

Yes. The stream comes from Postgres, so a scheduled job's update arrives at every subscriber exactly like a write from the browser.

### How many channels can a client open?

Several, over the one WebSocket connection. A channel is typed as postgres, broadcast or presence, so a collaborative board usually opens one of each and unsubscribes from all of them when the view unmounts.

## Get started

Ready to make it live? Create a free account at https://volcano.dev/signup, or have your coding agent follow the setup playbook at https://volcano.dev/setup.md.
