$npx -y skills add butterbase-ai/butterbase-skills --skill realtimeUse when enabling WebSocket subscriptions for live database changes, presence/multiplayer state, or when debugging clients that connect but receive no events
| 1 | # Butterbase Realtime |
| 2 | |
| 3 | Live database change notifications over WebSocket, with per-row RLS enforcement. Once a table is enabled, INSERT/UPDATE/DELETE events stream to subscribed clients **filtered by the same RLS policies** that gate reads. |
| 4 | |
| 5 | One tool: **`manage_realtime`** with two actions: `configure` and `get`. |
| 6 | |
| 7 | --- |
| 8 | |
| 9 | ## 1. The mental model |
| 10 | |
| 11 | ``` |
| 12 | Postgres (data plane) Control API Browser |
| 13 | ───────────────────── ──────────── ──────── |
| 14 | INSERT/UPDATE/DELETE ──trigger──► realtime.changes ──WAL listener─► WebSocket ──► client |
| 15 | │ |
| 16 | └── RLS check per (role, user) ──► filter rows |
| 17 | ``` |
| 18 | |
| 19 | When you `configure` a table: |
| 20 | 1. A Postgres trigger is installed → writes every change to `realtime.changes`. |
| 21 | 2. A LISTEN connection in `RealtimeManager` reads those changes. |
| 22 | 3. For each connected client, the change is RLS-checked **as that user** before broadcasting. |
| 23 | 4. Clients only receive events for rows they could read with a regular SELECT. |
| 24 | |
| 25 | --- |
| 26 | |
| 27 | ## 2. Prerequisites |
| 28 | |
| 29 | Before calling `configure`, the table must: |
| 30 | |
| 31 | 1. **Exist.** Run `manage_schema` (`action: "apply"`) first. Realtime won't auto-create it. |
| 32 | 2. **Have RLS configured (if you care about isolation).** Realtime respects whatever policies exist via `manage_rls`. **No policies = all events flow to all users of that role.** This is the #1 silent leak. |
| 33 | 3. **Have a primary key.** RLS checks query by PK. Tables without one can't be realtime-enabled cleanly. |
| 34 | |
| 35 | --- |
| 36 | |
| 37 | ## 3. Configure tables |
| 38 | |
| 39 | ```js |
| 40 | manage_realtime({ |
| 41 | app_id: "app_abc123", |
| 42 | action: "configure", |
| 43 | tables: ["messages", "presence", "documents"] |
| 44 | }) |
| 45 | // → [{ table: "messages", status: "enabled" }, ...] |
| 46 | ``` |
| 47 | |
| 48 | - Idempotent — already-enabled tables are skipped. |
| 49 | - All three events (INSERT / UPDATE / DELETE) are enabled together; per-event filtering happens client-side via subscription `filter`. |
| 50 | - Validation: every named table must exist or you get `VALIDATION_TABLE_NOT_FOUND`. |
| 51 | |
| 52 | ### Inspect current state |
| 53 | |
| 54 | ```js |
| 55 | manage_realtime({ app_id: "app_abc123", action: "get" }) |
| 56 | // → { |
| 57 | // tables: [{ table_name, enabled, trigger_installed, drift, created_at, updated_at }, ...], |
| 58 | // active_connection: true, |
| 59 | // websocket_url: "wss://api.butterbase.dev/v1/app_abc123/realtime" |
| 60 | // } |
| 61 | ``` |
| 62 | |
| 63 | `drift: true` means the control-plane config says enabled but the data-plane trigger is missing — typically after a schema migration that dropped/recreated the table. Re-run `configure` to repair. |
| 64 | |
| 65 | --- |
| 66 | |
| 67 | ## 4. Connect from a client |
| 68 | |
| 69 | ``` |
| 70 | wss://api.butterbase.dev/v1/{app_id}/realtime?token={JWT_or_API_KEY} |
| 71 | ``` |
| 72 | |
| 73 | Browsers can't set custom headers on WebSocket upgrade, so the JWT goes in the query string. Server clients can use `Authorization: Bearer ...` instead. |
| 74 | |
| 75 | ```js |
| 76 | const ws = new WebSocket( |
| 77 | `wss://api.butterbase.dev/v1/${appId}/realtime?token=${userJwt}` |
| 78 | ); |
| 79 | |
| 80 | ws.onopen = () => { |
| 81 | ws.send(JSON.stringify({ type: "subscribe", table: "messages" })); |
| 82 | }; |
| 83 | |
| 84 | ws.onmessage = (e) => { |
| 85 | const msg = JSON.parse(e.data); |
| 86 | if (msg.type === "change") handleChange(msg); // { type, table, op, record, old_record, timestamp } |
| 87 | }; |
| 88 | ``` |
| 89 | |
| 90 | The Butterbase SDK wraps this: |
| 91 | |
| 92 | ```ts |
| 93 | const realtime = client.realtime(appId, userJwt); |
| 94 | realtime.subscribe("messages", (change) => console.log(change.op, change.record)); |
| 95 | ``` |
| 96 | |
| 97 | ### Welcome and protocol |
| 98 | |
| 99 | On connect, the server sends: |
| 100 | |
| 101 | ```json |
| 102 | { "type": "connected", "app_id": "app_abc123", "role": "butterbase_user" } |
| 103 | ``` |
| 104 | |
| 105 | Then a heartbeat every 30s: |
| 106 | |
| 107 | ```json |
| 108 | { "type": "heartbeat", "timestamp": "..." } |
| 109 | ``` |
| 110 | |
| 111 | ### Client → server messages |
| 112 | |
| 113 | | Type | Body | Purpose | |
| 114 | |------|------|---------| |
| 115 | | `subscribe` | `{ table, filter? }` | Subscribe to changes; optional client-side filter `{ col: value }` | |
| 116 | | `unsubscribe` | `{ table }` | Stop receiving | |
| 117 | | `presence_track` | `{ metadata }` | Announce yourself with arbitrary metadata (cursor, status) | |
| 118 | | `event` | `{ event, payload }` | Trigger a function with `trigger: { type: "websocket", config: { event } }` | |
| 119 | |
| 120 | ### Server → client messages |
| 121 | |
| 122 | | Type | Body | |
| 123 | |------|------| |
| 124 | | `change` | `{ table, op: "INSERT"\|"UPDATE"\|"DELETE", record, old_record, timestamp }` | |
| 125 | | `presence_state` | `{ clients: [{ client_id, user_id, metadata }] }` | |
| 126 | | `heartbeat` | `{ timestamp }` | |
| 127 | |
| 128 | --- |
| 129 | |
| 130 | ## 5. RLS enforcement (the critical pitfall) |
| 131 | |
| 132 | For each broadcast, the server runs (roughly): |
| 133 | |
| 134 | ```sql |
| 135 | SET LOCAL ROLE butterbase_user; |
| 136 | SET LOCAL request.jwt.claim.sub = '{user_id}'; |
| 137 | SELECT 1 FROM "{table}" WHERE "{pk}" = {record_pk} LIMIT 1; |
| 138 | ``` |
| 139 | |
| 140 | If the row is **not visible** under RLS, the change is **silently dropped** for that client. There is no error. |
| 141 | |
| 142 | **Common consequences:** |
| 143 | |
| 144 | - Client connects, sees `connected`, subscribes — but receives no events. → RLS too restrictive (or no policies at all |