@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.
Files changed (49) hide show
  1. package/README.md +0 -1
  2. package/dist/commands/api-keys.d.ts +1 -0
  3. package/dist/commands/generate_sdk.d.ts +1 -1
  4. package/dist/index.es.js +399 -57
  5. package/dist/index.es.js.map +1 -1
  6. package/package.json +20 -15
  7. package/templates/template/backend/package.json +0 -1
  8. package/templates/template/backend/src/index.ts +1 -1
  9. package/templates/template/config/collections/posts.ts +1 -3
  10. package/templates/template/config/collections/tags.ts +1 -3
  11. package/templates/template/config/collections/users.ts +1 -4
  12. package/templates/template/package.json +0 -1
  13. package/templates/template/scripts/example.ts +4 -1
  14. package/skills/rebase-admin/SKILL.md +0 -710
  15. package/skills/rebase-api/SKILL.md +0 -662
  16. package/skills/rebase-api/references/.gitkeep +0 -3
  17. package/skills/rebase-auth/SKILL.md +0 -1143
  18. package/skills/rebase-auth/references/.gitkeep +0 -3
  19. package/skills/rebase-backend-postgres/SKILL.md +0 -633
  20. package/skills/rebase-backend-postgres/references/.gitkeep +0 -3
  21. package/skills/rebase-basics/SKILL.md +0 -749
  22. package/skills/rebase-basics/references/.gitkeep +0 -3
  23. package/skills/rebase-collections/SKILL.md +0 -1328
  24. package/skills/rebase-collections/references/.gitkeep +0 -3
  25. package/skills/rebase-cron-jobs/SKILL.md +0 -699
  26. package/skills/rebase-cron-jobs/references/.gitkeep +0 -1
  27. package/skills/rebase-custom-functions/SKILL.md +0 -233
  28. package/skills/rebase-deployment/SKILL.md +0 -885
  29. package/skills/rebase-deployment/references/.gitkeep +0 -3
  30. package/skills/rebase-design-language/SKILL.md +0 -692
  31. package/skills/rebase-email/SKILL.md +0 -701
  32. package/skills/rebase-email/references/.gitkeep +0 -1
  33. package/skills/rebase-entity-history/SKILL.md +0 -485
  34. package/skills/rebase-entity-history/references/.gitkeep +0 -1
  35. package/skills/rebase-local-env-setup/SKILL.md +0 -189
  36. package/skills/rebase-local-env-setup/references/.gitkeep +0 -3
  37. package/skills/rebase-realtime/SKILL.md +0 -755
  38. package/skills/rebase-realtime/references/.gitkeep +0 -3
  39. package/skills/rebase-sdk/SKILL.md +0 -594
  40. package/skills/rebase-sdk/references/.gitkeep +0 -0
  41. package/skills/rebase-storage/SKILL.md +0 -765
  42. package/skills/rebase-storage/references/.gitkeep +0 -3
  43. package/skills/rebase-studio/SKILL.md +0 -746
  44. package/skills/rebase-studio/references/.gitkeep +0 -3
  45. package/skills/rebase-ui-components/SKILL.md +0 -1488
  46. package/skills/rebase-ui-components/references/.gitkeep +0 -3
  47. package/skills/rebase-webhooks/SKILL.md +0 -623
  48. package/skills/rebase-webhooks/references/.gitkeep +0 -1
  49. 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)