@rebasepro/cli 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -1
- package/dist/commands/api-keys.d.ts +1 -0
- package/dist/commands/generate_sdk.d.ts +1 -1
- package/dist/index.es.js +399 -57
- package/dist/index.es.js.map +1 -1
- package/package.json +20 -15
- package/templates/template/backend/package.json +0 -1
- package/templates/template/backend/src/index.ts +1 -1
- package/templates/template/config/collections/posts.ts +1 -3
- package/templates/template/config/collections/tags.ts +1 -3
- package/templates/template/config/collections/users.ts +1 -4
- package/templates/template/package.json +0 -1
- package/templates/template/scripts/example.ts +4 -1
- package/skills/rebase-admin/SKILL.md +0 -710
- package/skills/rebase-api/SKILL.md +0 -662
- package/skills/rebase-api/references/.gitkeep +0 -3
- package/skills/rebase-auth/SKILL.md +0 -1143
- package/skills/rebase-auth/references/.gitkeep +0 -3
- package/skills/rebase-backend-postgres/SKILL.md +0 -633
- package/skills/rebase-backend-postgres/references/.gitkeep +0 -3
- package/skills/rebase-basics/SKILL.md +0 -749
- package/skills/rebase-basics/references/.gitkeep +0 -3
- package/skills/rebase-collections/SKILL.md +0 -1328
- package/skills/rebase-collections/references/.gitkeep +0 -3
- package/skills/rebase-cron-jobs/SKILL.md +0 -699
- package/skills/rebase-cron-jobs/references/.gitkeep +0 -1
- package/skills/rebase-custom-functions/SKILL.md +0 -233
- package/skills/rebase-deployment/SKILL.md +0 -885
- package/skills/rebase-deployment/references/.gitkeep +0 -3
- package/skills/rebase-design-language/SKILL.md +0 -692
- package/skills/rebase-email/SKILL.md +0 -701
- package/skills/rebase-email/references/.gitkeep +0 -1
- package/skills/rebase-entity-history/SKILL.md +0 -485
- package/skills/rebase-entity-history/references/.gitkeep +0 -1
- package/skills/rebase-local-env-setup/SKILL.md +0 -189
- package/skills/rebase-local-env-setup/references/.gitkeep +0 -3
- package/skills/rebase-realtime/SKILL.md +0 -755
- package/skills/rebase-realtime/references/.gitkeep +0 -3
- package/skills/rebase-sdk/SKILL.md +0 -594
- package/skills/rebase-sdk/references/.gitkeep +0 -0
- package/skills/rebase-storage/SKILL.md +0 -765
- package/skills/rebase-storage/references/.gitkeep +0 -3
- package/skills/rebase-studio/SKILL.md +0 -746
- package/skills/rebase-studio/references/.gitkeep +0 -3
- package/skills/rebase-ui-components/SKILL.md +0 -1488
- package/skills/rebase-ui-components/references/.gitkeep +0 -3
- package/skills/rebase-webhooks/SKILL.md +0 -623
- package/skills/rebase-webhooks/references/.gitkeep +0 -1
- package/templates/template/backend/drizzle.config.ts +0 -51
|
@@ -1,755 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: rebase-realtime
|
|
3
|
-
description: Guide for the Rebase WebSocket realtime engine. Use this skill when the user needs real-time data synchronization, broadcast channels, presence tracking, table change broadcasts, or live updates in their application.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Rebase Realtime Engine
|
|
7
|
-
|
|
8
|
-
Rebase includes a built-in WebSocket-based realtime engine that broadcasts database changes, supports broadcast channels for arbitrary client-to-client messaging, and provides presence tracking for online status.
|
|
9
|
-
|
|
10
|
-
> **IMPORTANT FOR AGENTS:** The WebSocket protocol uses specific message types that differ from typical pub/sub patterns. Read the **Message Types** section carefully before generating any WebSocket code. Do NOT invent message type names — use only the types documented here.
|
|
11
|
-
|
|
12
|
-
## Architecture
|
|
13
|
-
|
|
14
|
-
```
|
|
15
|
-
┌──────────────┐ ┌────────────────────┐ ┌──────────────┐
|
|
16
|
-
│ PostgreSQL │─────▶│ Rebase Server │─────▶│ Client SDK │
|
|
17
|
-
│ LISTEN/NOTIFY│ │ RealtimeService │ │ WebSocket │
|
|
18
|
-
└──────────────┘ └────────────────────┘ └──────────────┘
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
The realtime pipeline has three stages:
|
|
22
|
-
|
|
23
|
-
1. **Database mutation** — A record is created/updated/deleted (via REST API, SDK, or Studio).
|
|
24
|
-
2. **Server fan-out** — The `RealtimeService` detects the change and fans out to every active WebSocket subscription matching the affected collection or entity.
|
|
25
|
-
3. **Client callback** — The client SDK fires the registered `onUpdate` callback with fresh data.
|
|
26
|
-
|
|
27
|
-
For multi-instance deployments, PostgreSQL `LISTEN/NOTIFY` broadcasts changes across server instances via the `rebase_entity_changes` channel.
|
|
28
|
-
|
|
29
|
-
### Zero Configuration
|
|
30
|
-
|
|
31
|
-
Realtime is enabled out of the box. There is no flag to flip or service to start. If the Rebase server is running, the WebSocket endpoint is available on the same HTTP port.
|
|
32
|
-
|
|
33
|
-
## Client SDK Subscriptions (Recommended)
|
|
34
|
-
|
|
35
|
-
The `@rebasepro/client` SDK provides high-level methods with automatic authentication, reconnection, subscription deduplication, and typed responses.
|
|
36
|
-
|
|
37
|
-
### Subscribing to a Collection — `listen()`
|
|
38
|
-
|
|
39
|
-
```typescript
|
|
40
|
-
import { createRebaseClient } from "@rebasepro/client";
|
|
41
|
-
|
|
42
|
-
const client = createRebaseClient({
|
|
43
|
-
baseUrl: "http://localhost:3001",
|
|
44
|
-
websocketUrl: "ws://localhost:3001",
|
|
45
|
-
getAuthToken: async () => myAuthToken,
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
// Subscribe to all products
|
|
49
|
-
const unsubscribe = client.data.products.listen(
|
|
50
|
-
undefined, // FindParams — pass undefined for all records
|
|
51
|
-
(response) => {
|
|
52
|
-
console.log("Products updated:", response.data); // Entity<M>[]
|
|
53
|
-
console.log("Total:", response.meta.total);
|
|
54
|
-
},
|
|
55
|
-
(error) => {
|
|
56
|
-
console.error("Subscription error:", error);
|
|
57
|
-
}
|
|
58
|
-
);
|
|
59
|
-
|
|
60
|
-
// Stop listening when done
|
|
61
|
-
unsubscribe();
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
### Subscribing with Filters
|
|
65
|
-
|
|
66
|
-
Pass `FindParams` as the first argument to filter the subscription:
|
|
67
|
-
|
|
68
|
-
```typescript
|
|
69
|
-
const unsubscribe = client.data.products.listen(
|
|
70
|
-
{
|
|
71
|
-
where: { status: "published" },
|
|
72
|
-
orderBy: "created_at:desc",
|
|
73
|
-
limit: 50,
|
|
74
|
-
},
|
|
75
|
-
(response) => {
|
|
76
|
-
console.log("Published products:", response.data);
|
|
77
|
-
}
|
|
78
|
-
);
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
The server respects these filters — only matching records are included in updates. Filter, sort, limit, offset, and searchString are all supported.
|
|
82
|
-
|
|
83
|
-
### `listen()` Signature
|
|
84
|
-
|
|
85
|
-
```typescript
|
|
86
|
-
listen(
|
|
87
|
-
params: FindParams | undefined,
|
|
88
|
-
onUpdate: (response: FindResponse<M>) => void,
|
|
89
|
-
onError?: (error: Error) => void
|
|
90
|
-
): () => void // returns unsubscribe function
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
The `FindResponse<M>` contains:
|
|
94
|
-
|
|
95
|
-
| Field | Type | Description |
|
|
96
|
-
|-------|------|-------------|
|
|
97
|
-
| `data` | `Entity<M>[]` | Array of matching entities |
|
|
98
|
-
| `meta.total` | `number` | Number of entities returned |
|
|
99
|
-
| `meta.limit` | `number` | Requested limit |
|
|
100
|
-
| `meta.offset` | `number` | Requested offset |
|
|
101
|
-
| `meta.hasMore` | `boolean` | Whether more records exist beyond limit |
|
|
102
|
-
|
|
103
|
-
### Subscribing to a Single Entity — `listenById()`
|
|
104
|
-
|
|
105
|
-
```typescript
|
|
106
|
-
const unsubscribe = client.data.products.listenById(
|
|
107
|
-
"product-123",
|
|
108
|
-
(entity) => {
|
|
109
|
-
if (entity) {
|
|
110
|
-
console.log("Product changed:", entity.values);
|
|
111
|
-
} else {
|
|
112
|
-
console.log("Product was deleted");
|
|
113
|
-
}
|
|
114
|
-
},
|
|
115
|
-
(error) => {
|
|
116
|
-
console.error("Subscription error:", error);
|
|
117
|
-
}
|
|
118
|
-
);
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
### `listenById()` Signature
|
|
122
|
-
|
|
123
|
-
```typescript
|
|
124
|
-
listenById(
|
|
125
|
-
id: string | number,
|
|
126
|
-
onUpdate: (entity: Entity<M> | undefined) => void,
|
|
127
|
-
onError?: (error: Error) => void
|
|
128
|
-
): () => void // returns unsubscribe function
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
The callback receives `undefined` when the entity is deleted.
|
|
132
|
-
|
|
133
|
-
### Unsubscribing
|
|
134
|
-
|
|
135
|
-
Both `listen()` and `listenById()` return an unsubscribe function. Call it to stop receiving updates and clean up server-side resources:
|
|
136
|
-
|
|
137
|
-
```typescript
|
|
138
|
-
const unsubscribe = client.data.products.listen(undefined, (response) => {
|
|
139
|
-
// handle updates
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
// Later:
|
|
143
|
-
unsubscribe();
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
In React, use `useEffect` cleanup:
|
|
147
|
-
|
|
148
|
-
```tsx
|
|
149
|
-
useEffect(() => {
|
|
150
|
-
const unsubscribe = client.data.products.listen(
|
|
151
|
-
{ where: { active: true } },
|
|
152
|
-
(response) => setProducts(response.data)
|
|
153
|
-
);
|
|
154
|
-
return () => unsubscribe();
|
|
155
|
-
}, []);
|
|
156
|
-
```
|
|
157
|
-
|
|
158
|
-
> **WARNING FOR AGENTS:** Always call the unsubscribe function when a component unmounts or a page navigates away. Failing to do so causes memory leaks and unnecessary server-side work.
|
|
159
|
-
|
|
160
|
-
## Query Builder `.listen()`
|
|
161
|
-
|
|
162
|
-
The fluent query builder also supports realtime subscriptions. Chain your filters, then call `.listen()` instead of `.find()`:
|
|
163
|
-
|
|
164
|
-
```typescript
|
|
165
|
-
const unsubscribe = client.data.orders
|
|
166
|
-
.where("status", "==", "pending")
|
|
167
|
-
.orderBy("created_at", "desc")
|
|
168
|
-
.limit(20)
|
|
169
|
-
.listen(
|
|
170
|
-
(response) => {
|
|
171
|
-
console.log("Pending orders:", response.data);
|
|
172
|
-
},
|
|
173
|
-
(error) => {
|
|
174
|
-
console.error("Error:", error);
|
|
175
|
-
}
|
|
176
|
-
);
|
|
177
|
-
```
|
|
178
|
-
|
|
179
|
-
### `.listen()` Signature on QueryBuilder
|
|
180
|
-
|
|
181
|
-
```typescript
|
|
182
|
-
listen(
|
|
183
|
-
onUpdate: (data: FindResponse<M>) => void,
|
|
184
|
-
onError?: (error: Error) => void
|
|
185
|
-
): () => void
|
|
186
|
-
```
|
|
187
|
-
|
|
188
|
-
> **IMPORTANT FOR AGENTS:** The `.listen()` method on the query builder is only available when the `RebaseClient` is configured with a `websocketUrl`. If the WebSocket is not configured, calling `.listen()` throws: `"Listen is only available when RebaseClient is configured with a websocketUrl."`
|
|
189
|
-
|
|
190
|
-
## Update Delivery: Instant Patch + Correctness Refetch
|
|
191
|
-
|
|
192
|
-
Rebase uses a two-phase update strategy for collection subscriptions:
|
|
193
|
-
|
|
194
|
-
1. **Phase 1 — Instant entity patch (`collection_entity_patch`):** When a single entity changes, the server immediately pushes a lightweight patch message. The client merges this into its cached collection data for near-instant cross-tab feedback — no database query needed.
|
|
195
|
-
|
|
196
|
-
2. **Phase 2 — Debounced full refetch (`collection_update`):** After a 300ms debounce window (`REFETCH_DEBOUNCE_MS`), the server runs a full collection query with the original filters and sort order, then sends the authoritative update. This ensures correctness when filters, sort order, or pagination are affected by the change.
|
|
197
|
-
|
|
198
|
-
This gives sub-millisecond perceived latency for simple updates and guaranteed correctness for complex queries. Multiple rapid mutations within the 300ms window are coalesced into a single database query.
|
|
199
|
-
|
|
200
|
-
## Subscription Deduplication and Caching
|
|
201
|
-
|
|
202
|
-
The client SDK automatically deduplicates subscriptions:
|
|
203
|
-
|
|
204
|
-
- If multiple components subscribe to the same collection with identical parameters (path, filter, orderBy, limit, etc.), only **one** WebSocket subscription is created on the server.
|
|
205
|
-
- Additional callbacks are registered locally and receive the same data.
|
|
206
|
-
- New subscribers immediately receive the cached latest data if available (`isInitialDataReceived`).
|
|
207
|
-
- The server subscription is only unsubscribed when the **last** callback for that key is removed.
|
|
208
|
-
|
|
209
|
-
Deduplication keys are computed deterministically:
|
|
210
|
-
- **Collection subscriptions:** JSON of `{ path, filter, limit, startAfter, orderBy, order, searchString, collection.name }` with sorted keys.
|
|
211
|
-
- **Entity subscriptions:** `"${path}|${entityId}"`.
|
|
212
|
-
|
|
213
|
-
## WebSocket Protocol (Raw / Low-Level)
|
|
214
|
-
|
|
215
|
-
For environments where the SDK is not available, you can use the raw WebSocket protocol. The connection URL is the same HTTP endpoint (no special path required).
|
|
216
|
-
|
|
217
|
-
### Connection Lifecycle
|
|
218
|
-
|
|
219
|
-
1. Open a WebSocket connection to the server URL.
|
|
220
|
-
2. Send an `AUTHENTICATE` message with your JWT token.
|
|
221
|
-
3. Wait for `AUTH_SUCCESS` before sending subscription or other messages.
|
|
222
|
-
4. Send `subscribe_collection` or `subscribe_entity` to start receiving data.
|
|
223
|
-
5. Receive `collection_update`, `entity_update`, and `collection_entity_patch` messages.
|
|
224
|
-
6. Send `unsubscribe` to stop a subscription.
|
|
225
|
-
|
|
226
|
-
### Authentication Step (Required First)
|
|
227
|
-
|
|
228
|
-
```typescript
|
|
229
|
-
const ws = new WebSocket("ws://localhost:3001");
|
|
230
|
-
|
|
231
|
-
ws.onopen = () => {
|
|
232
|
-
// Step 1: Authenticate FIRST
|
|
233
|
-
ws.send(JSON.stringify({
|
|
234
|
-
type: "AUTHENTICATE",
|
|
235
|
-
requestId: "auth_1",
|
|
236
|
-
payload: { token: "<jwt-token>" }
|
|
237
|
-
}));
|
|
238
|
-
};
|
|
239
|
-
```
|
|
240
|
-
|
|
241
|
-
> **IMPORTANT FOR AGENTS:** Authentication is a SEPARATE step. Do NOT put the token inside subscribe messages. The `AUTHENTICATE` message must be sent and acknowledged before any other operations.
|
|
242
|
-
|
|
243
|
-
### Authentication Responses
|
|
244
|
-
|
|
245
|
-
| Response Type | Payload | Description |
|
|
246
|
-
|--------------|---------|-------------|
|
|
247
|
-
| `AUTH_SUCCESS` | `{ userId, roles }` | Token accepted, session authenticated |
|
|
248
|
-
| `AUTH_ERROR` | `{ error: { message, code } }` | Token rejected |
|
|
249
|
-
|
|
250
|
-
Auth error codes:
|
|
251
|
-
|
|
252
|
-
| Code | Meaning |
|
|
253
|
-
|------|---------|
|
|
254
|
-
| `INVALID_INPUT` | No token provided |
|
|
255
|
-
| `INVALID_TOKEN` | Token is invalid or expired |
|
|
256
|
-
|
|
257
|
-
### Subscribe to a Collection
|
|
258
|
-
|
|
259
|
-
```typescript
|
|
260
|
-
ws.send(JSON.stringify({
|
|
261
|
-
type: "subscribe_collection",
|
|
262
|
-
payload: {
|
|
263
|
-
subscriptionId: "sub_products_1",
|
|
264
|
-
path: "products",
|
|
265
|
-
filter: { status: ["==", "active"] },
|
|
266
|
-
orderBy: "created_at",
|
|
267
|
-
order: "desc",
|
|
268
|
-
limit: 50
|
|
269
|
-
}
|
|
270
|
-
}));
|
|
271
|
-
```
|
|
272
|
-
|
|
273
|
-
### Subscribe to a Single Entity
|
|
274
|
-
|
|
275
|
-
```typescript
|
|
276
|
-
ws.send(JSON.stringify({
|
|
277
|
-
type: "subscribe_entity",
|
|
278
|
-
payload: {
|
|
279
|
-
subscriptionId: "sub_product_42",
|
|
280
|
-
path: "products",
|
|
281
|
-
entityId: "42"
|
|
282
|
-
}
|
|
283
|
-
}));
|
|
284
|
-
```
|
|
285
|
-
|
|
286
|
-
### Unsubscribe
|
|
287
|
-
|
|
288
|
-
```typescript
|
|
289
|
-
ws.send(JSON.stringify({
|
|
290
|
-
type: "unsubscribe",
|
|
291
|
-
payload: { subscriptionId: "sub_products_1" }
|
|
292
|
-
}));
|
|
293
|
-
```
|
|
294
|
-
|
|
295
|
-
### Handling Server Messages
|
|
296
|
-
|
|
297
|
-
```typescript
|
|
298
|
-
ws.onmessage = (event) => {
|
|
299
|
-
const message = JSON.parse(event.data);
|
|
300
|
-
|
|
301
|
-
switch (message.type) {
|
|
302
|
-
case "collection_update":
|
|
303
|
-
// Full collection data after refetch
|
|
304
|
-
console.log("Collection:", message.subscriptionId);
|
|
305
|
-
console.log("Entities:", message.entities); // Entity[]
|
|
306
|
-
break;
|
|
307
|
-
|
|
308
|
-
case "entity_update":
|
|
309
|
-
// Single entity update
|
|
310
|
-
console.log("Entity:", message.subscriptionId);
|
|
311
|
-
console.log("Data:", message.entity); // Entity | null
|
|
312
|
-
break;
|
|
313
|
-
|
|
314
|
-
case "collection_entity_patch":
|
|
315
|
-
// Lightweight instant patch for a collection subscription
|
|
316
|
-
console.log("Patch:", message.subscriptionId);
|
|
317
|
-
console.log("Entity ID:", message.entityId);
|
|
318
|
-
console.log("Entity:", message.entity); // Entity | null (null = deleted)
|
|
319
|
-
break;
|
|
320
|
-
|
|
321
|
-
case "ERROR":
|
|
322
|
-
console.error("Error:", message.payload?.error?.message);
|
|
323
|
-
console.error("Code:", message.payload?.error?.code);
|
|
324
|
-
break;
|
|
325
|
-
}
|
|
326
|
-
};
|
|
327
|
-
```
|
|
328
|
-
|
|
329
|
-
## Message Types Reference
|
|
330
|
-
|
|
331
|
-
### Client → Server
|
|
332
|
-
|
|
333
|
-
| Type | Payload | Description |
|
|
334
|
-
|------|---------|-------------|
|
|
335
|
-
| `AUTHENTICATE` | `{ token }` | Authenticate the WebSocket session with a JWT |
|
|
336
|
-
| `subscribe_collection` | `{ subscriptionId, path, filter?, orderBy?, order?, limit?, offset?, startAfter?, searchString? }` | Subscribe to collection changes |
|
|
337
|
-
| `subscribe_entity` | `{ subscriptionId, path, entityId }` | Subscribe to a single entity |
|
|
338
|
-
| `unsubscribe` | `{ subscriptionId }` | Unsubscribe from a subscription |
|
|
339
|
-
| `join_channel` | `{ channel }` | Join a broadcast channel |
|
|
340
|
-
| `leave_channel` | `{ channel }` | Leave a broadcast channel |
|
|
341
|
-
| `broadcast` | `{ channel, event, payload }` | Send a message to channel members |
|
|
342
|
-
| `presence_track` | `{ channel, state }` | Track presence with custom state (auto-joins channel) |
|
|
343
|
-
| `presence_untrack` | `{ channel }` | Stop tracking presence |
|
|
344
|
-
| `presence_state` | `{ channel }` | Request full presence snapshot |
|
|
345
|
-
| `FETCH_COLLECTION` | `FetchCollectionProps` | One-shot collection fetch (request/response) |
|
|
346
|
-
| `FETCH_ENTITY` | `FetchEntityProps` | One-shot entity fetch |
|
|
347
|
-
| `SAVE_ENTITY` | `SaveEntityProps` | Create or update an entity |
|
|
348
|
-
| `DELETE_ENTITY` | `DeleteEntityProps` | Delete an entity |
|
|
349
|
-
| `COUNT_ENTITIES` | `FetchCollectionProps` | Count entities matching criteria |
|
|
350
|
-
| `CHECK_UNIQUE_FIELD` | `{ path, name, value, entityId?, collection? }` | Check field uniqueness |
|
|
351
|
-
|
|
352
|
-
### Server → Client
|
|
353
|
-
|
|
354
|
-
| Type | Key Fields | Description |
|
|
355
|
-
|------|-----------|-------------|
|
|
356
|
-
| `AUTH_SUCCESS` | `{ requestId, payload: { userId, roles } }` | Authentication successful |
|
|
357
|
-
| `AUTH_ERROR` | `{ requestId, payload: { error: { message, code } } }` | Authentication failed |
|
|
358
|
-
| `collection_update` | `{ subscriptionId, entities }` | Full collection data (authoritative refetch) |
|
|
359
|
-
| `entity_update` | `{ subscriptionId, entity }` | Single entity data (entity or `null` if deleted) |
|
|
360
|
-
| `collection_entity_patch` | `{ subscriptionId, entityId, entity }` | Instant single-entity patch for a collection subscription |
|
|
361
|
-
| `broadcast` | `{ channel, event, payload }` | Broadcast from another channel member |
|
|
362
|
-
| `presence_state` | `{ channel, presences }` | Full presence snapshot |
|
|
363
|
-
| `presence_diff` | `{ channel, joins, leaves }` | Incremental presence update |
|
|
364
|
-
| `ERROR` | `{ requestId?, payload: { error: { message, code } } }` | General error |
|
|
365
|
-
| `FETCH_COLLECTION_SUCCESS` | `{ requestId, payload: { entities } }` | Response to FETCH_COLLECTION |
|
|
366
|
-
| `FETCH_ENTITY_SUCCESS` | `{ requestId, payload: { entity } }` | Response to FETCH_ENTITY |
|
|
367
|
-
| `SAVE_ENTITY_SUCCESS` | `{ requestId, payload: { entity } }` | Response to SAVE_ENTITY |
|
|
368
|
-
| `DELETE_ENTITY_SUCCESS` | `{ requestId, payload: { success: true } }` | Response to DELETE_ENTITY |
|
|
369
|
-
| `COUNT_ENTITIES_SUCCESS` | `{ requestId, payload: { count } }` | Response to COUNT_ENTITIES |
|
|
370
|
-
| `CHECK_UNIQUE_FIELD_SUCCESS` | `{ requestId, payload: { isUnique } }` | Response to CHECK_UNIQUE_FIELD |
|
|
371
|
-
|
|
372
|
-
### Error Codes
|
|
373
|
-
|
|
374
|
-
| Code | Meaning |
|
|
375
|
-
|------|---------|
|
|
376
|
-
| `INVALID_INPUT` | Missing required field |
|
|
377
|
-
| `INVALID_TOKEN` | JWT invalid or expired |
|
|
378
|
-
| `UNAUTHORIZED` | Authentication required but session not authenticated |
|
|
379
|
-
| `FORBIDDEN` | Admin access required for this operation |
|
|
380
|
-
| `RATE_LIMITED` | Too many requests (see Rate Limiting) |
|
|
381
|
-
| `INTERNAL_ERROR` | Unexpected server error |
|
|
382
|
-
| `NOT_SUPPORTED` | Operation not available for this driver |
|
|
383
|
-
| `SQL_ERROR` | SQL execution error (admin only) |
|
|
384
|
-
|
|
385
|
-
### Admin-Only Message Types
|
|
386
|
-
|
|
387
|
-
These message types require the authenticated user to have the `admin` role:
|
|
388
|
-
|
|
389
|
-
| Type | Description |
|
|
390
|
-
|------|-------------|
|
|
391
|
-
| `EXECUTE_SQL` | Execute raw SQL |
|
|
392
|
-
| `FETCH_DATABASES` | List available databases |
|
|
393
|
-
| `FETCH_ROLES` | List available roles |
|
|
394
|
-
| `FETCH_UNMAPPED_TABLES` | List tables not mapped to collections |
|
|
395
|
-
| `FETCH_TABLE_METADATA` | Get column/FK metadata for a table |
|
|
396
|
-
| `FETCH_CURRENT_DATABASE` | Get the current database name |
|
|
397
|
-
| `CREATE_BRANCH` | Create a database branch |
|
|
398
|
-
| `DELETE_BRANCH` | Delete a database branch |
|
|
399
|
-
| `LIST_BRANCHES` | List all database branches |
|
|
400
|
-
|
|
401
|
-
## Broadcast Channels
|
|
402
|
-
|
|
403
|
-
Broadcast channels let clients send arbitrary messages to each other in real time — useful for typing indicators, cursor positions, or custom notifications.
|
|
404
|
-
|
|
405
|
-
### Joining a Channel
|
|
406
|
-
|
|
407
|
-
```typescript
|
|
408
|
-
ws.send(JSON.stringify({
|
|
409
|
-
type: "join_channel",
|
|
410
|
-
payload: { channel: "room-42" }
|
|
411
|
-
}));
|
|
412
|
-
```
|
|
413
|
-
|
|
414
|
-
### Sending a Broadcast
|
|
415
|
-
|
|
416
|
-
```typescript
|
|
417
|
-
ws.send(JSON.stringify({
|
|
418
|
-
type: "broadcast",
|
|
419
|
-
payload: {
|
|
420
|
-
channel: "room-42",
|
|
421
|
-
event: "typing",
|
|
422
|
-
payload: { userId: "user-1", isTyping: true }
|
|
423
|
-
}
|
|
424
|
-
}));
|
|
425
|
-
```
|
|
426
|
-
|
|
427
|
-
### Receiving Broadcasts
|
|
428
|
-
|
|
429
|
-
The server relays broadcasts to **all other members** of the channel (the sender does NOT receive its own message):
|
|
430
|
-
|
|
431
|
-
```typescript
|
|
432
|
-
// Received by other clients in the channel
|
|
433
|
-
{
|
|
434
|
-
type: "broadcast",
|
|
435
|
-
channel: "room-42",
|
|
436
|
-
event: "typing",
|
|
437
|
-
payload: { userId: "user-1", isTyping: true }
|
|
438
|
-
}
|
|
439
|
-
```
|
|
440
|
-
|
|
441
|
-
### Leaving a Channel
|
|
442
|
-
|
|
443
|
-
```typescript
|
|
444
|
-
ws.send(JSON.stringify({
|
|
445
|
-
type: "leave_channel",
|
|
446
|
-
payload: { channel: "room-42" }
|
|
447
|
-
}));
|
|
448
|
-
```
|
|
449
|
-
|
|
450
|
-
When a client disconnects, they are automatically removed from all channels.
|
|
451
|
-
|
|
452
|
-
## Presence Tracking
|
|
453
|
-
|
|
454
|
-
Presence tracks which users are currently online in a channel, with custom state per user.
|
|
455
|
-
|
|
456
|
-
### Tracking Presence
|
|
457
|
-
|
|
458
|
-
Sending `presence_track` **automatically joins the channel** — no separate `join_channel` needed:
|
|
459
|
-
|
|
460
|
-
```typescript
|
|
461
|
-
ws.send(JSON.stringify({
|
|
462
|
-
type: "presence_track",
|
|
463
|
-
payload: {
|
|
464
|
-
channel: "document-42",
|
|
465
|
-
state: { name: "Alice", cursor: { line: 10, col: 5 } }
|
|
466
|
-
}
|
|
467
|
-
}));
|
|
468
|
-
```
|
|
469
|
-
|
|
470
|
-
### Receiving Presence Diffs
|
|
471
|
-
|
|
472
|
-
All channel members receive incremental `presence_diff` messages when users join or leave:
|
|
473
|
-
|
|
474
|
-
```typescript
|
|
475
|
-
{
|
|
476
|
-
type: "presence_diff",
|
|
477
|
-
channel: "document-42",
|
|
478
|
-
joins: { "client_abc": { name: "Alice", cursor: { line: 10, col: 5 } } },
|
|
479
|
-
leaves: {}
|
|
480
|
-
}
|
|
481
|
-
```
|
|
482
|
-
|
|
483
|
-
### Requesting Full Presence State
|
|
484
|
-
|
|
485
|
-
```typescript
|
|
486
|
-
ws.send(JSON.stringify({
|
|
487
|
-
type: "presence_state",
|
|
488
|
-
payload: { channel: "document-42" }
|
|
489
|
-
}));
|
|
490
|
-
```
|
|
491
|
-
|
|
492
|
-
Response:
|
|
493
|
-
|
|
494
|
-
```typescript
|
|
495
|
-
{
|
|
496
|
-
type: "presence_state",
|
|
497
|
-
channel: "document-42",
|
|
498
|
-
presences: {
|
|
499
|
-
"client_abc": { name: "Alice", cursor: { line: 10, col: 5 } },
|
|
500
|
-
"client_def": { name: "Bob", cursor: { line: 22, col: 0 } }
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
```
|
|
504
|
-
|
|
505
|
-
### Untracking Presence
|
|
506
|
-
|
|
507
|
-
```typescript
|
|
508
|
-
ws.send(JSON.stringify({
|
|
509
|
-
type: "presence_untrack",
|
|
510
|
-
payload: { channel: "document-42" }
|
|
511
|
-
}));
|
|
512
|
-
```
|
|
513
|
-
|
|
514
|
-
### Presence Timeout
|
|
515
|
-
|
|
516
|
-
Stale presences are automatically cleaned up after **30 seconds** of inactivity (`PRESENCE_TIMEOUT_MS = 30000`). The server checks for stale entries every 10 seconds. When a stale presence is removed, a `presence_diff` with a `leaves` entry is broadcast to the channel.
|
|
517
|
-
|
|
518
|
-
## Rate Limiting
|
|
519
|
-
|
|
520
|
-
The WebSocket server enforces per-client rate limiting:
|
|
521
|
-
|
|
522
|
-
| Setting | Value |
|
|
523
|
-
|---------|-------|
|
|
524
|
-
| Max messages per window | **2000** (`WS_RATE_LIMIT`) |
|
|
525
|
-
| Window duration | **60 seconds** (`WS_RATE_WINDOW_MS`) |
|
|
526
|
-
|
|
527
|
-
When the limit is exceeded, the server responds with:
|
|
528
|
-
|
|
529
|
-
```json
|
|
530
|
-
{
|
|
531
|
-
"type": "ERROR",
|
|
532
|
-
"payload": { "error": { "message": "Too many requests. Please slow down.", "code": "RATE_LIMITED" } }
|
|
533
|
-
}
|
|
534
|
-
```
|
|
535
|
-
|
|
536
|
-
The window is a sliding window — the counter resets after 60 seconds of the window start.
|
|
537
|
-
|
|
538
|
-
## Auto-Reconnect Behavior
|
|
539
|
-
|
|
540
|
-
The client SDK (`RebaseWebSocketClient`) automatically reconnects when the connection drops:
|
|
541
|
-
|
|
542
|
-
| Setting | Value |
|
|
543
|
-
|---------|-------|
|
|
544
|
-
| Initial delay | **1 second** |
|
|
545
|
-
| Backoff formula | `min(1000 * 2^attempt, 30000)` ms |
|
|
546
|
-
| Maximum delay | **30 seconds** |
|
|
547
|
-
| Maximum attempts | **5** (`maxReconnectAttempts`) |
|
|
548
|
-
|
|
549
|
-
### Reconnection sequence:
|
|
550
|
-
|
|
551
|
-
1. Connection drops → `onclose` fires.
|
|
552
|
-
2. Client sets `isAuthenticated = false` and queues pending requests.
|
|
553
|
-
3. Reconnect after exponential backoff delay.
|
|
554
|
-
4. On successful reconnect:
|
|
555
|
-
- Auto-authenticates if `getAuthToken` is configured.
|
|
556
|
-
- Calls `resubscribeAll()` — all active collection and entity subscriptions are re-registered with fresh subscription IDs.
|
|
557
|
-
- Processes the queued message queue.
|
|
558
|
-
- Emits `"reconnect"` event.
|
|
559
|
-
|
|
560
|
-
### Connection Lifecycle Events
|
|
561
|
-
|
|
562
|
-
```typescript
|
|
563
|
-
const ws = client.ws; // Access the RebaseWebSocketClient
|
|
564
|
-
|
|
565
|
-
ws.on("connect", () => console.log("Connected"));
|
|
566
|
-
ws.on("disconnect", () => console.log("Disconnected"));
|
|
567
|
-
ws.on("reconnect", () => console.log("Reconnected"));
|
|
568
|
-
ws.on("error", (error) => console.error("Error:", error));
|
|
569
|
-
```
|
|
570
|
-
|
|
571
|
-
Each `on()` call returns an unsubscribe function:
|
|
572
|
-
|
|
573
|
-
```typescript
|
|
574
|
-
const unsub = ws.on("connect", () => { /* ... */ });
|
|
575
|
-
unsub(); // Stop listening
|
|
576
|
-
```
|
|
577
|
-
|
|
578
|
-
## Authentication & RLS
|
|
579
|
-
|
|
580
|
-
WebSocket subscriptions automatically respect Row-Level Security (RLS) policies:
|
|
581
|
-
|
|
582
|
-
1. The client sends an `AUTHENTICATE` message with a JWT token.
|
|
583
|
-
2. The server verifies the token (via `extractUserFromToken` or a custom `AuthAdapter`).
|
|
584
|
-
3. Every subscription refetch runs inside a PostgreSQL transaction with:
|
|
585
|
-
- `set_config('app.user_id', ...)`
|
|
586
|
-
- `set_config('app.user_roles', ...)`
|
|
587
|
-
- `set_config('app.jwt', ...)`
|
|
588
|
-
4. RLS policies are enforced — each user only sees records they have permission to access.
|
|
589
|
-
5. If no auth context is present, the server defaults to `{ userId: "anon", roles: ["anon"] }`.
|
|
590
|
-
|
|
591
|
-
### Auto-Authentication
|
|
592
|
-
|
|
593
|
-
The client SDK handles authentication automatically:
|
|
594
|
-
|
|
595
|
-
- On connect, if `getAuthToken` is configured, the client authenticates immediately.
|
|
596
|
-
- On-demand authentication: if a non-auth message is sent before authentication, the client calls `ensureAuthenticated()` with up to 3 retries and backoff.
|
|
597
|
-
- `setAuthTokenGetter()` can be called after construction to provide the token getter dynamically.
|
|
598
|
-
- `reauthenticate()` forces re-authentication (call after token refresh).
|
|
599
|
-
|
|
600
|
-
### Auth Failure Recovery
|
|
601
|
-
|
|
602
|
-
When the server returns an `AUTH_ERROR` or `UNAUTHORIZED` error for any request or subscription:
|
|
603
|
-
|
|
604
|
-
1. The client calls `onUnauthorized()` (if configured) to attempt a token refresh.
|
|
605
|
-
2. If the refresh succeeds, the failed request is retried automatically.
|
|
606
|
-
3. If the refresh fails, the error is propagated to the callback.
|
|
607
|
-
|
|
608
|
-
## Client Configuration
|
|
609
|
-
|
|
610
|
-
### `RebaseWebSocketConfig`
|
|
611
|
-
|
|
612
|
-
| Property | Type | Required | Description |
|
|
613
|
-
|----------|------|----------|-------------|
|
|
614
|
-
| `websocketUrl` | `string` | Yes | WebSocket server URL (e.g., `"ws://localhost:3001"`) |
|
|
615
|
-
| `getAuthToken` | `() => Promise<string>` | No | Async function that returns the current JWT token |
|
|
616
|
-
| `WebSocket` | `typeof WebSocket` | No | Custom WebSocket constructor for Node.js environments |
|
|
617
|
-
| `onUnauthorized` | `() => Promise<boolean>` | No | Called on auth failure; return `true` if token was refreshed |
|
|
618
|
-
|
|
619
|
-
### Using in Node.js
|
|
620
|
-
|
|
621
|
-
The browser `WebSocket` is not available in Node.js. Pass a WebSocket implementation:
|
|
622
|
-
|
|
623
|
-
```typescript
|
|
624
|
-
import WebSocket from "ws";
|
|
625
|
-
import { createRebaseClient } from "@rebasepro/client";
|
|
626
|
-
|
|
627
|
-
const client = createRebaseClient({
|
|
628
|
-
baseUrl: "http://localhost:3001",
|
|
629
|
-
websocketUrl: "ws://localhost:3001",
|
|
630
|
-
WebSocket: WebSocket as unknown as typeof globalThis.WebSocket,
|
|
631
|
-
});
|
|
632
|
-
```
|
|
633
|
-
|
|
634
|
-
> **IMPORTANT FOR AGENTS:** If WebSocket is not available and no `WebSocket` constructor is provided, the client logs a warning and realtime subscriptions will NOT work. Always provide a WebSocket constructor in Node.js environments.
|
|
635
|
-
|
|
636
|
-
## Server Configuration
|
|
637
|
-
|
|
638
|
-
### Cross-Instance Broadcasting
|
|
639
|
-
|
|
640
|
-
For multi-instance deployments (behind a load balancer), Rebase uses PostgreSQL `LISTEN/NOTIFY` to synchronize changes:
|
|
641
|
-
|
|
642
|
-
- A mutation on Instance A triggers `pg_notify('rebase_entity_changes', ...)`.
|
|
643
|
-
- Instance B receives the notification via its dedicated `LISTEN` connection.
|
|
644
|
-
- Instance B refetches the affected entity and fans out the update to its local subscribers.
|
|
645
|
-
- Each instance has a unique ID (`inst_<uuid>`) to skip its own notifications.
|
|
646
|
-
|
|
647
|
-
This is **automatic** when `connectionString` is provided to the Postgres bootstrapper. The dedicated `LISTEN` connection auto-reconnects with a **3-second fixed delay** if it drops.
|
|
648
|
-
|
|
649
|
-
### `DATABASE_DIRECT_URL` Environment Variable
|
|
650
|
-
|
|
651
|
-
When using PgBouncer or a similar connection pooler, `LISTEN/NOTIFY` does not work through the pooler. Set `DATABASE_DIRECT_URL` to a direct PostgreSQL connection string that bypasses the pooler:
|
|
652
|
-
|
|
653
|
-
```bash
|
|
654
|
-
# Standard pooled connection
|
|
655
|
-
DATABASE_URL=postgres://user:pass@pgbouncer:6432/mydb
|
|
656
|
-
|
|
657
|
-
# Direct connection for LISTEN/NOTIFY (bypasses PgBouncer)
|
|
658
|
-
DATABASE_DIRECT_URL=postgres://user:pass@postgres:5432/mydb
|
|
659
|
-
```
|
|
660
|
-
|
|
661
|
-
The bootstrapper prefers `DATABASE_DIRECT_URL` over `connectionString` for the LISTEN client:
|
|
662
|
-
|
|
663
|
-
```
|
|
664
|
-
const directUrl = process.env.DATABASE_DIRECT_URL || pgConfig.connectionString;
|
|
665
|
-
```
|
|
666
|
-
|
|
667
|
-
### Debounce Window
|
|
668
|
-
|
|
669
|
-
The server coalesces rapid entity mutations into a single database refetch using a configurable debounce:
|
|
670
|
-
|
|
671
|
-
| Constant | Value | Description |
|
|
672
|
-
|----------|-------|-------------|
|
|
673
|
-
| `REFETCH_DEBOUNCE_MS` | `300` ms | Debounce window for collection and entity refetches |
|
|
674
|
-
|
|
675
|
-
If 10 entities change within 300ms, only **one** database query is executed for each affected subscription.
|
|
676
|
-
|
|
677
|
-
### Presence Cleanup
|
|
678
|
-
|
|
679
|
-
| Constant | Value | Description |
|
|
680
|
-
|----------|-------|-------------|
|
|
681
|
-
| `PRESENCE_TIMEOUT_MS` | `30000` ms (30s) | Stale presence entries are removed after this timeout |
|
|
682
|
-
| Cleanup interval | `10000` ms (10s) | How often the server checks for stale presences |
|
|
683
|
-
|
|
684
|
-
## Nested Relation Updates
|
|
685
|
-
|
|
686
|
-
The realtime engine handles nested relation paths (e.g., `"posts/70/tags"`). When an entity changes at a nested path:
|
|
687
|
-
|
|
688
|
-
1. The exact path is notified (`"posts/70/tags"`).
|
|
689
|
-
2. All parent paths are also notified (`"posts"`, `"posts/70"`).
|
|
690
|
-
|
|
691
|
-
This ensures that a subscription to `"posts"` receives updates even when a related `"tags"` entity changes.
|
|
692
|
-
|
|
693
|
-
## Error Handling
|
|
694
|
-
|
|
695
|
-
### SDK Error Types
|
|
696
|
-
|
|
697
|
-
The client throws `ApiError` for WebSocket errors:
|
|
698
|
-
|
|
699
|
-
```typescript
|
|
700
|
-
class ApiError extends Error {
|
|
701
|
-
code?: string; // Error code (e.g., "UNAUTHORIZED", "RATE_LIMITED")
|
|
702
|
-
error?: string; // Error description
|
|
703
|
-
}
|
|
704
|
-
```
|
|
705
|
-
|
|
706
|
-
### Subscription Error Callbacks
|
|
707
|
-
|
|
708
|
-
```typescript
|
|
709
|
-
const unsubscribe = client.data.products.listen(
|
|
710
|
-
undefined,
|
|
711
|
-
(response) => { /* success */ },
|
|
712
|
-
(error) => {
|
|
713
|
-
// error is an Error or ApiError instance
|
|
714
|
-
if (error instanceof ApiError) {
|
|
715
|
-
console.error("Code:", error.code);
|
|
716
|
-
}
|
|
717
|
-
console.error("Message:", error.message);
|
|
718
|
-
}
|
|
719
|
-
);
|
|
720
|
-
```
|
|
721
|
-
|
|
722
|
-
### Pending Request Timeout
|
|
723
|
-
|
|
724
|
-
To prevent client requests from hanging indefinitely, all pending WebSocket operations that expect a server response (such as `FETCH_COLLECTION`, `FETCH_ENTITY`, `SAVE_ENTITY`, `DELETE_ENTITY`, `COUNT_ENTITIES`, and `CHECK_UNIQUE_FIELD`) have a default timeout of 30 seconds (`requestTimeoutMs = 30000`).
|
|
725
|
-
|
|
726
|
-
If the server does not respond within this window, the client automatically deletes the pending request record and rejects the request's promise with an `ApiError`:
|
|
727
|
-
- Message: `"Request timed out"`
|
|
728
|
-
- Code: `undefined`
|
|
729
|
-
|
|
730
|
-
One-way messages that do not expect a response (like `subscribe_collection`, `subscribe_entity`, `unsubscribe`, `join_channel`, `leave_channel`, `broadcast`, `presence_track`, `presence_untrack`, and `presence_state`) resolve immediately upon transmission and do not trigger timeouts.
|
|
731
|
-
|
|
732
|
-
### Production Error Sanitization
|
|
733
|
-
|
|
734
|
-
In production (`NODE_ENV=production`):
|
|
735
|
-
- Debug logs are suppressed on both server and client.
|
|
736
|
-
- Error messages in responses are sanitized to `"An unexpected error occurred"` to prevent PII/data leaks.
|
|
737
|
-
- The `WebSocket` handler catches all unhandled errors and returns them as generic `ERROR` messages.
|
|
738
|
-
|
|
739
|
-
## When to Use Realtime
|
|
740
|
-
|
|
741
|
-
| Use Case | Method |
|
|
742
|
-
|----------|--------|
|
|
743
|
-
| Dashboard with live data | `listen()` with filters |
|
|
744
|
-
| Chat or notifications | `listen()` on messages collection |
|
|
745
|
-
| Detail page with live updates | `listenById()` |
|
|
746
|
-
| Admin panel monitoring | `listen()` with `orderBy` and `limit` |
|
|
747
|
-
| Collaborative editing cursors | Broadcast channels + presence |
|
|
748
|
-
| Typing indicators | Broadcast channels |
|
|
749
|
-
| Online user tracking | Presence tracking |
|
|
750
|
-
|
|
751
|
-
## References
|
|
752
|
-
|
|
753
|
-
- **Backend Realtime Docs:** [rebase.pro/docs/backend/realtime](https://rebase.pro/docs/backend/realtime)
|
|
754
|
-
- **SDK Realtime Docs:** [rebase.pro/docs/sdk/realtime](https://rebase.pro/docs/sdk/realtime)
|
|
755
|
-
- **GitHub:** [github.com/rebasepro/rebase](https://github.com/rebasepro/rebase)
|