@volcano.dev/sdk 1.2.0-nightly.22454064035.1

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.
@@ -0,0 +1,1016 @@
1
+ /**
2
+ * Volcano Realtime SDK - WebSocket client for real-time messaging
3
+ *
4
+ * This module provides real-time capabilities including:
5
+ * - Broadcast: Pub/sub messaging between clients
6
+ * - Presence: Track online users and their state
7
+ * - Postgres Changes: Subscribe to database INSERT/UPDATE/DELETE events
8
+ *
9
+ * @example
10
+ * ```javascript
11
+ * import { VolcanoRealtime } from '@volcano.dev/sdk/realtime';
12
+ *
13
+ * const realtime = new VolcanoRealtime({
14
+ * apiUrl: 'https://api.yourapp.com',
15
+ * anonKey: 'your-anon-key',
16
+ * accessToken: 'your-access-token'
17
+ * });
18
+ *
19
+ * // Connect to realtime server
20
+ * await realtime.connect();
21
+ *
22
+ * // Subscribe to a broadcast channel
23
+ * const channel = realtime.channel('chat-room');
24
+ * channel.on('message', (payload) => console.log('New message:', payload));
25
+ * await channel.subscribe();
26
+ *
27
+ * // Send a message
28
+ * channel.send({ text: 'Hello, world!' });
29
+ *
30
+ * // Subscribe to database changes
31
+ * const dbChannel = realtime.channel('public:messages');
32
+ * dbChannel.onPostgresChanges('*', 'public', 'messages', (payload) => {
33
+ * console.log('Database change:', payload);
34
+ * });
35
+ * await dbChannel.subscribe();
36
+ *
37
+ * // Track presence
38
+ * const presenceChannel = realtime.channel('lobby', { type: 'presence' });
39
+ * presenceChannel.onPresenceSync((state) => {
40
+ * console.log('Online users:', Object.keys(state));
41
+ * });
42
+ * await presenceChannel.subscribe();
43
+ * presenceChannel.track({ status: 'online' });
44
+ * ```
45
+ */
46
+
47
+ // Centrifuge client - dynamically imported
48
+ let Centrifuge = null;
49
+
50
+ /**
51
+ * Dynamically imports the Centrifuge client
52
+ */
53
+ async function loadCentrifuge() {
54
+ if (Centrifuge) return Centrifuge;
55
+
56
+ try {
57
+ // Try ES module import
58
+ const module = await import('centrifuge');
59
+ Centrifuge = module.Centrifuge || module.default;
60
+ return Centrifuge;
61
+ } catch {
62
+ throw new Error(
63
+ 'Centrifuge client not found. Please install it: npm install centrifuge'
64
+ );
65
+ }
66
+ }
67
+
68
+ // Load WebSocket for Node.js environments
69
+ let WebSocketImpl = null;
70
+ async function loadWebSocket() {
71
+ if (WebSocketImpl) return WebSocketImpl;
72
+
73
+ // Check if we're in a browser environment
74
+ if (typeof window !== 'undefined' && window.WebSocket) {
75
+ WebSocketImpl = window.WebSocket;
76
+ return WebSocketImpl;
77
+ }
78
+
79
+ // Node.js environment - try to load ws package
80
+ try {
81
+ const ws = await import('ws');
82
+ WebSocketImpl = ws.default || ws.WebSocket || ws;
83
+ return WebSocketImpl;
84
+ } catch {
85
+ throw new Error(
86
+ 'WebSocket implementation not found. In Node.js, please install: npm install ws'
87
+ );
88
+ }
89
+ }
90
+
91
+ /**
92
+ * VolcanoRealtime - Main realtime client
93
+ *
94
+ * Channel names use simple format: type:name (e.g., "broadcast:chat")
95
+ * The server automatically handles project isolation - clients never
96
+ * need to know about project IDs.
97
+ *
98
+ * Authentication options:
99
+ * 1. User token: anonKey (required) + accessToken (user JWT)
100
+ * 2. Service key: anonKey (optional) + accessToken (service role key)
101
+ */
102
+ class VolcanoRealtime {
103
+ /**
104
+ * Create a new VolcanoRealtime client
105
+ * @param {Object} config - Configuration options
106
+ * @param {string} config.apiUrl - Volcano API URL
107
+ * @param {string} [config.anonKey] - Anon key (required for user tokens, optional for service keys)
108
+ * @param {string} config.accessToken - Access token (user JWT) or service role key (sk-...)
109
+ * @param {Function} [config.getToken] - Function to get/refresh token
110
+ * @param {Object} [config.volcanoClient] - VolcanoAuth client for auto-fetching lightweight notifications
111
+ * @param {string} [config.databaseName] - Database name for auto-fetch queries
112
+ * @param {Object} [config.fetchConfig] - Configuration for auto-fetch behavior
113
+ */
114
+ constructor(config) {
115
+ if (!config.apiUrl) throw new Error('apiUrl is required');
116
+ // anonKey is optional for service role keys (they contain project ID)
117
+ // But we need either anonKey or accessToken
118
+ if (config.anonKey === undefined) throw new Error('anonKey is required');
119
+
120
+ this.apiUrl = config.apiUrl.replace(/\/$/, ''); // Remove trailing slash
121
+ this.anonKey = config.anonKey || ''; // Allow empty string for service keys
122
+ this.accessToken = config.accessToken;
123
+ this.getToken = config.getToken;
124
+
125
+ this._client = null;
126
+ this._channels = new Map();
127
+ this._connected = false;
128
+ this._connectionPromise = null;
129
+
130
+ // Callbacks
131
+ this._onConnect = [];
132
+ this._onDisconnect = [];
133
+ this._onError = [];
134
+
135
+ // Auto-fetch support (Phase 3)
136
+ this._volcanoClient = config.volcanoClient || null;
137
+ this._fetchConfig = {
138
+ batchWindowMs: config.fetchConfig?.batchWindowMs || 20,
139
+ maxBatchSize: config.fetchConfig?.maxBatchSize || 50,
140
+ enabled: config.fetchConfig?.enabled !== false,
141
+ };
142
+
143
+ // Database name for auto-fetch queries (optional)
144
+ this._databaseName = config.databaseName || null;
145
+ }
146
+
147
+ /**
148
+ * Set the VolcanoAuth client for auto-fetching
149
+ * @param {Object} volcanoClient - VolcanoAuth client instance
150
+ */
151
+ setVolcanoClient(volcanoClient) {
152
+ this._volcanoClient = volcanoClient;
153
+ }
154
+
155
+ /**
156
+ * Get the configured VolcanoAuth client
157
+ * @returns {Object|null} The VolcanoAuth client or null
158
+ */
159
+ getVolcanoClient() {
160
+ return this._volcanoClient;
161
+ }
162
+
163
+ /**
164
+ * Get the fetch configuration
165
+ * @returns {Object} The fetch configuration
166
+ */
167
+ getFetchConfig() {
168
+ return { ...this._fetchConfig };
169
+ }
170
+
171
+ /**
172
+ * Set the database name for auto-fetch queries
173
+ * @param {string} databaseName
174
+ */
175
+ setDatabaseName(databaseName) {
176
+ this._databaseName = databaseName;
177
+ }
178
+
179
+ /**
180
+ * Get the configured database name
181
+ * @returns {string|null}
182
+ */
183
+ getDatabaseName() {
184
+ return this._databaseName;
185
+ }
186
+
187
+
188
+ /**
189
+ * Get the WebSocket URL for realtime connections
190
+ */
191
+ get wsUrl() {
192
+ const url = new URL(this.apiUrl);
193
+ const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
194
+ return `${protocol}//${url.host}/realtime/v1/websocket`;
195
+ }
196
+
197
+ /**
198
+ * Connect to the realtime server
199
+ */
200
+ async connect() {
201
+ if (this._connected) return;
202
+ if (this._connectionPromise) return this._connectionPromise;
203
+
204
+ this._connectionPromise = this._doConnect();
205
+ try {
206
+ await this._connectionPromise;
207
+ } finally {
208
+ this._connectionPromise = null;
209
+ }
210
+ }
211
+
212
+ async _doConnect() {
213
+ const CentrifugeClient = await loadCentrifuge();
214
+ const WebSocket = await loadWebSocket();
215
+
216
+ const wsUrl = `${this.wsUrl}?apikey=${encodeURIComponent(this.anonKey)}`;
217
+
218
+ this._client = new CentrifugeClient(wsUrl, {
219
+ token: this.accessToken,
220
+ getToken: this.getToken ? async () => {
221
+ const token = await this.getToken();
222
+ this.accessToken = token;
223
+ return token;
224
+ } : undefined,
225
+ debug: false,
226
+ websocket: WebSocket,
227
+ });
228
+
229
+ // Set up event handlers (store references for cleanup)
230
+ this._clientHandlers = {
231
+ connected: (ctx) => {
232
+ this._connected = true;
233
+ this._onConnect.forEach(cb => cb(ctx));
234
+ },
235
+ disconnected: (ctx) => {
236
+ this._connected = false;
237
+ this._onDisconnect.forEach(cb => cb(ctx));
238
+ },
239
+ error: (ctx) => {
240
+ this._onError.forEach(cb => cb(ctx));
241
+ },
242
+ publication: (ctx) => {
243
+ this._handleServerPublication(ctx);
244
+ },
245
+ join: (ctx) => {
246
+ this._handleServerJoin(ctx);
247
+ },
248
+ leave: (ctx) => {
249
+ this._handleServerLeave(ctx);
250
+ },
251
+ subscribed: (ctx) => {
252
+ this._handleServerSubscribed(ctx);
253
+ },
254
+ };
255
+
256
+ this._client.on('connected', this._clientHandlers.connected);
257
+ this._client.on('disconnected', this._clientHandlers.disconnected);
258
+ this._client.on('error', this._clientHandlers.error);
259
+ this._client.on('publication', this._clientHandlers.publication);
260
+ this._client.on('join', this._clientHandlers.join);
261
+ this._client.on('leave', this._clientHandlers.leave);
262
+ this._client.on('subscribed', this._clientHandlers.subscribed);
263
+
264
+ // Connect and wait for connected event
265
+ return new Promise((resolve, reject) => {
266
+ const timeout = setTimeout(() => {
267
+ reject(new Error('Connection timeout'));
268
+ }, 10000);
269
+
270
+ const onConnected = () => {
271
+ clearTimeout(timeout);
272
+ this._client.off('connected', onConnected);
273
+ this._client.off('error', onError);
274
+ resolve();
275
+ };
276
+
277
+ const onError = (ctx) => {
278
+ clearTimeout(timeout);
279
+ this._client.off('connected', onConnected);
280
+ this._client.off('error', onError);
281
+ reject(new Error(ctx.error?.message || 'Connection failed'));
282
+ };
283
+
284
+ this._client.on('connected', onConnected);
285
+ this._client.on('error', onError);
286
+ this._client.connect();
287
+ });
288
+ }
289
+
290
+ /**
291
+ * Disconnect from the realtime server
292
+ */
293
+ disconnect() {
294
+ // Unsubscribe all channels first to clean up their timers
295
+ for (const channel of this._channels.values()) {
296
+ try {
297
+ channel.unsubscribe();
298
+ } catch {
299
+ // Ignore errors during cleanup
300
+ }
301
+ }
302
+ this._channels.clear();
303
+
304
+ if (this._client) {
305
+ // Remove event handlers first to prevent memory leaks
306
+ if (this._clientHandlers) {
307
+ this._client.off('connected', this._clientHandlers.connected);
308
+ this._client.off('disconnected', this._clientHandlers.disconnected);
309
+ this._client.off('error', this._clientHandlers.error);
310
+ this._client.off('publication', this._clientHandlers.publication);
311
+ this._client.off('join', this._clientHandlers.join);
312
+ this._client.off('leave', this._clientHandlers.leave);
313
+ this._client.off('subscribed', this._clientHandlers.subscribed);
314
+ this._clientHandlers = null;
315
+ }
316
+
317
+ // Manually trigger disconnect callbacks
318
+ this._onDisconnect.forEach(cb => cb({ reason: 'manual' }));
319
+
320
+ // Disconnect the client
321
+ this._client.disconnect();
322
+ this._client = null;
323
+ this._connected = false;
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Check if connected to the realtime server
329
+ */
330
+ isConnected() {
331
+ return this._connected;
332
+ }
333
+
334
+ /**
335
+ * Create or get a channel
336
+ * @param {string} name - Channel name
337
+ * @param {Object} [options] - Channel options
338
+ * @param {string} [options.type='broadcast'] - Channel type: 'broadcast', 'presence', 'postgres'
339
+ * @param {boolean} [options.autoFetch=true] - Enable auto-fetch for lightweight notifications
340
+ * @param {number} [options.fetchBatchWindowMs] - Batch window for fetch requests
341
+ * @param {number} [options.fetchMaxBatchSize] - Max batch size for fetch requests
342
+ */
343
+ channel(name, options = {}) {
344
+ const type = options.type || 'broadcast';
345
+ const fullName = this._formatChannelName(name, type);
346
+
347
+ if (this._channels.has(fullName)) {
348
+ return this._channels.get(fullName);
349
+ }
350
+
351
+ const channel = new RealtimeChannel(this, fullName, type, options);
352
+ this._channels.set(fullName, channel);
353
+ return channel;
354
+ }
355
+
356
+ /**
357
+ * Format channel name for subscription
358
+ * Format: type:name
359
+ *
360
+ * The server automatically adds the project ID prefix based on
361
+ * the authenticated connection. Clients never need to know about project IDs.
362
+ */
363
+ _formatChannelName(name, type) {
364
+ return `${type}:${name}`;
365
+ }
366
+
367
+ /**
368
+ * Handle publications from server-side subscriptions
369
+ * The server uses project-prefixed channels: "projectId:type:name"
370
+ * We extract the type:name portion and route to the SDK channel
371
+ */
372
+ _handleServerPublication(ctx) {
373
+ const serverChannel = ctx.channel;
374
+
375
+ // Server channel format: projectId:type:name
376
+ // We need to extract type:name to match our SDK channel
377
+ const parts = serverChannel.split(':');
378
+ if (parts.length < 3) {
379
+ // Not a valid server channel format, ignore
380
+ return;
381
+ }
382
+
383
+ // Skip projectId, reconstruct type:name
384
+ const sdkChannel = parts.slice(1).join(':');
385
+
386
+ // Find the SDK channel and deliver the message
387
+ const channel = this._channels.get(sdkChannel);
388
+ if (channel) {
389
+ channel._handlePublication(ctx);
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Handle join events from server-side subscriptions
395
+ */
396
+ _handleServerJoin(ctx) {
397
+ const serverChannel = ctx.channel;
398
+ const parts = serverChannel.split(':');
399
+ if (parts.length < 3) return;
400
+
401
+ const sdkChannel = parts.slice(1).join(':');
402
+ const channel = this._channels.get(sdkChannel);
403
+ if (channel && channel._type === 'presence') {
404
+ // Update presence state
405
+ if (ctx.info) {
406
+ channel._presenceState[ctx.info.client] = ctx.info;
407
+ }
408
+ channel._triggerPresenceSync();
409
+ channel._triggerEvent('join', ctx.info);
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Handle leave events from server-side subscriptions
415
+ */
416
+ _handleServerLeave(ctx) {
417
+ const serverChannel = ctx.channel;
418
+ const parts = serverChannel.split(':');
419
+ if (parts.length < 3) return;
420
+
421
+ const sdkChannel = parts.slice(1).join(':');
422
+ const channel = this._channels.get(sdkChannel);
423
+ if (channel && channel._type === 'presence') {
424
+ // Update presence state
425
+ if (ctx.info) {
426
+ delete channel._presenceState[ctx.info.client];
427
+ }
428
+ channel._triggerPresenceSync();
429
+ channel._triggerEvent('leave', ctx.info);
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Handle subscribed events - includes initial presence state
435
+ */
436
+ _handleServerSubscribed(ctx) {
437
+ const serverChannel = ctx.channel;
438
+ const parts = serverChannel.split(':');
439
+ if (parts.length < 3) return;
440
+
441
+ const sdkChannel = parts.slice(1).join(':');
442
+ const channel = this._channels.get(sdkChannel);
443
+
444
+ // For presence channels, populate initial state from subscribe response
445
+ if (channel && channel._type === 'presence' && ctx.data) {
446
+ // data contains initial presence information
447
+ if (ctx.data.presence) {
448
+ channel._presenceState = {};
449
+ for (const [clientId, info] of Object.entries(ctx.data.presence)) {
450
+ channel._presenceState[clientId] = info;
451
+ }
452
+ channel._triggerPresenceSync();
453
+ }
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Get the underlying Centrifuge client
459
+ */
460
+ getClient() {
461
+ return this._client;
462
+ }
463
+
464
+ /**
465
+ * Register callback for connection events
466
+ */
467
+ onConnect(callback) {
468
+ this._onConnect.push(callback);
469
+ return () => {
470
+ this._onConnect = this._onConnect.filter(cb => cb !== callback);
471
+ };
472
+ }
473
+
474
+ /**
475
+ * Register callback for disconnection events
476
+ */
477
+ onDisconnect(callback) {
478
+ this._onDisconnect.push(callback);
479
+ return () => {
480
+ this._onDisconnect = this._onDisconnect.filter(cb => cb !== callback);
481
+ };
482
+ }
483
+
484
+ /**
485
+ * Register callback for error events
486
+ */
487
+ onError(callback) {
488
+ this._onError.push(callback);
489
+ return () => {
490
+ this._onError = this._onError.filter(cb => cb !== callback);
491
+ };
492
+ }
493
+
494
+ /**
495
+ * Remove a specific channel
496
+ * @param {string} name - Channel name
497
+ * @param {string} [type='broadcast'] - Channel type
498
+ */
499
+ removeChannel(name, type = 'broadcast') {
500
+ const fullName = this._formatChannelName(name, type);
501
+ const channel = this._channels.get(fullName);
502
+ if (channel) {
503
+ channel.unsubscribe();
504
+ this._channels.delete(fullName);
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Remove all channels and listeners
510
+ */
511
+ removeAllChannels() {
512
+ for (const channel of this._channels.values()) {
513
+ channel.unsubscribe();
514
+ }
515
+ this._channels.clear();
516
+ }
517
+ }
518
+
519
+ /**
520
+ * RealtimeChannel - Represents a subscription to a realtime channel
521
+ */
522
+ class RealtimeChannel {
523
+ constructor(realtime, name, type, options) {
524
+ this._realtime = realtime;
525
+ this._name = name;
526
+ this._type = type;
527
+ this._options = options;
528
+ this._subscription = null;
529
+ this._callbacks = new Map();
530
+ this._presenceState = {};
531
+
532
+ // Auto-fetch support (Phase 3)
533
+ const parentFetchConfig = realtime.getFetchConfig();
534
+ this._fetchConfig = {
535
+ batchWindowMs: options.fetchBatchWindowMs || parentFetchConfig.batchWindowMs,
536
+ maxBatchSize: options.fetchMaxBatchSize || parentFetchConfig.maxBatchSize,
537
+ enabled: options.autoFetch !== false && parentFetchConfig.enabled,
538
+ };
539
+ this._pendingFetches = new Map(); // table -> { ids: Map<id, {resolve, reject}>, timer }
540
+
541
+ // Event handler references for cleanup
542
+ this._eventHandlers = {};
543
+ this._presenceTimeoutId = null;
544
+ }
545
+
546
+ /**
547
+ * Get channel name
548
+ */
549
+ get name() {
550
+ return this._name;
551
+ }
552
+
553
+ /**
554
+ * Subscribe to the channel
555
+ */
556
+ async subscribe() {
557
+ if (this._subscription) return;
558
+
559
+ const client = this._realtime.getClient();
560
+ if (!client) {
561
+ throw new Error('Not connected to realtime server');
562
+ }
563
+
564
+ this._subscription = client.newSubscription(this._name, {
565
+ // Enable presence for presence channels
566
+ presence: this._type === 'presence',
567
+ joinLeave: this._type === 'presence',
568
+ // Enable recovery for all channels
569
+ recover: true,
570
+ });
571
+
572
+ // Set up message handler (store reference for cleanup)
573
+ this._eventHandlers.publication = (ctx) => {
574
+ const event = ctx.data?.event || 'message';
575
+ const callbacks = this._callbacks.get(event) || [];
576
+ callbacks.forEach(cb => cb(ctx.data, ctx));
577
+
578
+ // Also trigger wildcard listeners
579
+ const wildcardCallbacks = this._callbacks.get('*') || [];
580
+ wildcardCallbacks.forEach(cb => cb(ctx.data, ctx));
581
+ };
582
+ this._subscription.on('publication', this._eventHandlers.publication);
583
+
584
+ // Set up presence handlers for presence channels
585
+ if (this._type === 'presence') {
586
+ this._eventHandlers.presence = (ctx) => {
587
+ this._updatePresenceState(ctx);
588
+ this._triggerPresenceSync();
589
+ };
590
+ this._subscription.on('presence', this._eventHandlers.presence);
591
+
592
+ this._eventHandlers.join = (ctx) => {
593
+ this._presenceState[ctx.info.client] = ctx.info.data;
594
+ this._triggerPresenceSync();
595
+ this._triggerEvent('join', ctx.info);
596
+ };
597
+ this._subscription.on('join', this._eventHandlers.join);
598
+
599
+ this._eventHandlers.leave = (ctx) => {
600
+ delete this._presenceState[ctx.info.client];
601
+ this._triggerPresenceSync();
602
+ this._triggerEvent('leave', ctx.info);
603
+ };
604
+ this._subscription.on('leave', this._eventHandlers.leave);
605
+
606
+ // After subscribing, immediately fetch current presence for late joiners
607
+ // For server-side subscriptions, use client.presence() not subscription.presence()
608
+ this._eventHandlers.subscribed = async () => {
609
+ // Small delay to ensure subscription is fully active
610
+ this._presenceTimeoutId = setTimeout(async () => {
611
+ this._presenceTimeoutId = null;
612
+ try {
613
+ const client = this._realtime.getClient();
614
+ if (client && this._subscription) {
615
+ // Use client-level presence() for server-side subscriptions
616
+ const presence = await client.presence(this._name);
617
+
618
+ // Centrifuge returns presence data in `clients` field
619
+ if (presence && presence.clients) {
620
+ this._presenceState = {};
621
+ for (const [clientId, info] of Object.entries(presence.clients)) {
622
+ this._presenceState[clientId] = info;
623
+ }
624
+ this._triggerPresenceSync();
625
+ }
626
+ }
627
+ } catch (err) {
628
+ // Ignore errors - presence might not be available yet
629
+ }
630
+ }, 150);
631
+ };
632
+ this._subscription.on('subscribed', this._eventHandlers.subscribed);
633
+ }
634
+
635
+ await this._subscription.subscribe();
636
+ }
637
+
638
+ /**
639
+ * Unsubscribe from the channel
640
+ */
641
+ unsubscribe() {
642
+ // Cancel pending presence fetch timeout
643
+ if (this._presenceTimeoutId) {
644
+ clearTimeout(this._presenceTimeoutId);
645
+ this._presenceTimeoutId = null;
646
+ }
647
+
648
+ // Clear all pending fetch timers to prevent memory leaks
649
+ if (this._pendingFetches) {
650
+ for (const batch of this._pendingFetches.values()) {
651
+ if (batch.timer) {
652
+ clearTimeout(batch.timer);
653
+ }
654
+ // Reject any pending promises
655
+ for (const { reject } of batch.ids.values()) {
656
+ reject(new Error('Channel unsubscribed'));
657
+ }
658
+ }
659
+ this._pendingFetches.clear();
660
+ }
661
+
662
+ if (this._subscription) {
663
+ // Remove event listeners before unsubscribing
664
+ for (const [event, handler] of Object.entries(this._eventHandlers)) {
665
+ try {
666
+ this._subscription.off(event, handler);
667
+ } catch {
668
+ // Ignore errors if listener already removed
669
+ }
670
+ }
671
+ this._eventHandlers = {};
672
+
673
+ this._subscription.unsubscribe();
674
+ // Also remove from Centrifuge client registry to allow re-subscription
675
+ const client = this._realtime.getClient();
676
+ if (client) {
677
+ try {
678
+ client.removeSubscription(this._subscription);
679
+ } catch {
680
+ // Ignore errors if subscription already removed
681
+ }
682
+ }
683
+ this._subscription = null;
684
+ }
685
+ this._callbacks.clear();
686
+ this._presenceState = {};
687
+ }
688
+
689
+ /**
690
+ * Handle publication from server-side subscription
691
+ * Called by VolcanoRealtime when a message arrives on the internal channel
692
+ */
693
+ _handlePublication(ctx) {
694
+ const data = ctx.data;
695
+
696
+ // Check if this is a lightweight notification (Phase 3)
697
+ if (data?.mode === 'lightweight') {
698
+ this._handleLightweightNotification(data, ctx);
699
+ return;
700
+ }
701
+
702
+ // Full payload - deliver immediately
703
+ this._deliverPayload(data, ctx);
704
+ }
705
+
706
+ /**
707
+ * Handle a lightweight notification by auto-fetching the record data
708
+ * @param {Object} data - Lightweight notification data
709
+ * @param {Object} ctx - Publication context
710
+ */
711
+ async _handleLightweightNotification(data, ctx) {
712
+ const volcanoClient = this._realtime.getVolcanoClient();
713
+
714
+ // DELETE notifications may include old_record, deliver immediately
715
+ if (data.type === 'DELETE') {
716
+ // Convert lightweight DELETE to full format for backward compatibility
717
+ const oldRecord = data.old_record !== undefined
718
+ ? data.old_record
719
+ : (data.id !== undefined ? { id: data.id } : undefined);
720
+ const fullPayload = {
721
+ type: data.type,
722
+ schema: data.schema,
723
+ table: data.table,
724
+ old_record: oldRecord,
725
+ id: data.id,
726
+ timestamp: data.timestamp,
727
+ };
728
+ this._deliverPayload(fullPayload, ctx);
729
+ return;
730
+ }
731
+
732
+ // If no volcanoClient or auto-fetch disabled, deliver lightweight as-is
733
+ if (!volcanoClient || !this._fetchConfig.enabled) {
734
+ this._deliverPayload(data, ctx);
735
+ return;
736
+ }
737
+
738
+ // Auto-fetch the record for INSERT/UPDATE
739
+ try {
740
+ const record = await this._fetchRow(data.schema, data.table, data.id);
741
+
742
+ // Convert to full payload format for backward compatibility
743
+ const fullPayload = {
744
+ type: data.type,
745
+ schema: data.schema,
746
+ table: data.table,
747
+ record: record,
748
+ timestamp: data.timestamp,
749
+ };
750
+
751
+ this._deliverPayload(fullPayload, ctx);
752
+ } catch (err) {
753
+ // On fetch error, still deliver the lightweight notification
754
+ // so the client knows something changed, even if we couldn't get the data
755
+ console.warn(`[Realtime] Failed to fetch record for ${data.schema}.${data.table}:${data.id}:`, err.message);
756
+ this._deliverPayload(data, ctx);
757
+ }
758
+ }
759
+
760
+ /**
761
+ * Fetch a row from the database, batching requests for efficiency
762
+ * @param {string} schema - Schema name
763
+ * @param {string} table - Table name
764
+ * @param {*} id - Primary key value
765
+ * @returns {Promise<Object>} The fetched record
766
+ */
767
+ _fetchRow(schema, table, id) {
768
+ const tableKey = `${schema}.${table}`;
769
+
770
+ return new Promise((resolve, reject) => {
771
+ // Get or create pending batch for this table
772
+ if (!this._pendingFetches.has(tableKey)) {
773
+ this._pendingFetches.set(tableKey, {
774
+ ids: new Map(),
775
+ timer: null,
776
+ schema: schema,
777
+ table: table,
778
+ });
779
+ }
780
+
781
+ const batch = this._pendingFetches.get(tableKey);
782
+
783
+ // Add this ID to the batch
784
+ batch.ids.set(String(id), { resolve, reject });
785
+
786
+ // Check if we should flush due to size
787
+ if (batch.ids.size >= this._fetchConfig.maxBatchSize) {
788
+ this._flushFetch(schema, table);
789
+ return;
790
+ }
791
+
792
+ // Set timer for batch window if not already set
793
+ if (!batch.timer) {
794
+ batch.timer = setTimeout(() => {
795
+ this._flushFetch(schema, table);
796
+ }, this._fetchConfig.batchWindowMs);
797
+ }
798
+ });
799
+ }
800
+
801
+ /**
802
+ * Flush pending fetch requests for a table
803
+ * @param {string} schema - Schema name
804
+ * @param {string} table - Table name
805
+ */
806
+ async _flushFetch(schema, table) {
807
+ const tableKey = `${schema}.${table}`;
808
+ const batch = this._pendingFetches.get(tableKey);
809
+
810
+ if (!batch || batch.ids.size === 0) {
811
+ return;
812
+ }
813
+
814
+ // Clear timer and remove from pending
815
+ if (batch.timer) {
816
+ clearTimeout(batch.timer);
817
+ }
818
+ this._pendingFetches.delete(tableKey);
819
+
820
+ // Get all IDs to fetch
821
+ const idsToFetch = Array.from(batch.ids.keys());
822
+ const callbacks = new Map(batch.ids);
823
+
824
+ try {
825
+ const volcanoClient = this._realtime.getVolcanoClient();
826
+
827
+ if (!volcanoClient?.from || typeof volcanoClient.from !== 'function') {
828
+ throw new Error('volcanoClient.from not available');
829
+ }
830
+
831
+ const databaseName = this._realtime.getDatabaseName?.() || volcanoClient._currentDatabaseName || null;
832
+ let dbClient = volcanoClient;
833
+ if (databaseName) {
834
+ if (typeof volcanoClient.database !== 'function') {
835
+ throw new Error('volcanoClient.database not available');
836
+ }
837
+ dbClient = volcanoClient.database(databaseName);
838
+ } else if (typeof volcanoClient.database === 'function') {
839
+ throw new Error('Database name not set. Call volcanoClient.database(name) or pass databaseName to VolcanoRealtime.');
840
+ }
841
+
842
+ const tableName = schema && schema !== 'public' ? `${schema}.${table}` : table;
843
+
844
+ // Fetch all records in a single query using IN clause
845
+ // Assumes primary key column is 'id' - this is a common convention
846
+ const { data, error } = await dbClient
847
+ .from(tableName)
848
+ .select('*')
849
+ .in('id', idsToFetch);
850
+
851
+ if (error) {
852
+ // Reject all pending callbacks
853
+ for (const cb of callbacks.values()) {
854
+ cb.reject(new Error(error.message || 'Database fetch failed'));
855
+ }
856
+ return;
857
+ }
858
+
859
+ // Build a map of id -> record
860
+ const recordMap = new Map();
861
+ for (const record of (data || [])) {
862
+ recordMap.set(String(record.id), record);
863
+ }
864
+
865
+ // Resolve callbacks
866
+ for (const [id, cb] of callbacks) {
867
+ const record = recordMap.get(id);
868
+ if (record) {
869
+ cb.resolve(record);
870
+ } else {
871
+ // Record not found - could be RLS denial or row deleted
872
+ cb.reject(new Error(`Record not found or access denied: ${table}:${id}`));
873
+ }
874
+ }
875
+ } catch (err) {
876
+ // Reject all pending callbacks on error
877
+ for (const cb of callbacks.values()) {
878
+ cb.reject(err);
879
+ }
880
+ }
881
+ }
882
+
883
+ /**
884
+ * Deliver a payload to registered callbacks
885
+ * @param {Object} data - Payload data
886
+ * @param {Object} ctx - Publication context
887
+ */
888
+ _deliverPayload(data, ctx) {
889
+ const event = data?.event || data?.type || 'message';
890
+ const callbacks = this._callbacks.get(event) || [];
891
+ callbacks.forEach(cb => cb(data, ctx));
892
+
893
+ // Also trigger wildcard listeners
894
+ const wildcardCallbacks = this._callbacks.get('*') || [];
895
+ wildcardCallbacks.forEach(cb => cb(data, ctx));
896
+ }
897
+
898
+ /**
899
+ * Listen for events on the channel
900
+ * @param {string} event - Event name or '*' for all events
901
+ * @param {Function} callback - Callback function
902
+ */
903
+ on(event, callback) {
904
+ if (!this._callbacks.has(event)) {
905
+ this._callbacks.set(event, []);
906
+ }
907
+ this._callbacks.get(event).push(callback);
908
+
909
+ // Return unsubscribe function
910
+ return () => {
911
+ const callbacks = this._callbacks.get(event) || [];
912
+ this._callbacks.set(event, callbacks.filter(cb => cb !== callback));
913
+ };
914
+ }
915
+
916
+ /**
917
+ * Send a message to the channel (broadcast only)
918
+ * @param {Object} data - Message data
919
+ */
920
+ async send(data) {
921
+ if (this._type !== 'broadcast') {
922
+ throw new Error('send() is only available for broadcast channels');
923
+ }
924
+
925
+ if (!this._subscription) {
926
+ throw new Error('Channel not subscribed');
927
+ }
928
+
929
+ await this._subscription.publish(data);
930
+ }
931
+
932
+ /**
933
+ * Listen for database changes (postgres channels only)
934
+ * @param {string} event - Event type: 'INSERT', 'UPDATE', 'DELETE', or '*'
935
+ * @param {string} schema - Schema name
936
+ * @param {string} table - Table name
937
+ * @param {Function} callback - Callback function
938
+ */
939
+ onPostgresChanges(event, schema, table, callback) {
940
+ if (this._type !== 'postgres') {
941
+ throw new Error('onPostgresChanges() is only available for postgres channels');
942
+ }
943
+
944
+ // Filter callback to only match the requested event type
945
+ return this.on('*', (data, ctx) => {
946
+ if (data.schema !== schema || data.table !== table) return;
947
+ if (event !== '*' && data.type !== event) return;
948
+ callback(data, ctx);
949
+ });
950
+ }
951
+
952
+ /**
953
+ * Listen for presence state sync
954
+ * @param {Function} callback - Callback with presence state
955
+ */
956
+ onPresenceSync(callback) {
957
+ if (this._type !== 'presence') {
958
+ throw new Error('onPresenceSync() is only available for presence channels');
959
+ }
960
+
961
+ return this.on('presence_sync', callback);
962
+ }
963
+
964
+ /**
965
+ * Track this client's presence
966
+ * @param {Object} state - Presence state data (optional, for client-side state tracking)
967
+ *
968
+ * Note: Presence data is automatically sent from the server based on your
969
+ * user metadata (from sign-up). Custom presence data should be included
970
+ * when creating the anonymous user.
971
+ */
972
+ async track(state = {}) {
973
+ if (this._type !== 'presence') {
974
+ throw new Error('track() is only available for presence channels');
975
+ }
976
+
977
+ // Store local presence state for client-side access
978
+ this._myPresenceState = state;
979
+
980
+ // Presence is automatically managed by Centrifuge based on subscription
981
+ // The connection data (from user metadata) is what other clients see
982
+ // Note: Custom state is stored locally for client-side access
983
+ }
984
+
985
+ /**
986
+ * Get current presence state
987
+ */
988
+ getPresenceState() {
989
+ return { ...this._presenceState };
990
+ }
991
+
992
+ _updatePresenceState(ctx) {
993
+ this._presenceState = {};
994
+ if (ctx.clients) {
995
+ for (const [clientId, info] of Object.entries(ctx.clients)) {
996
+ this._presenceState[clientId] = info.data;
997
+ }
998
+ }
999
+ }
1000
+
1001
+ _triggerPresenceSync() {
1002
+ this._triggerEvent('presence_sync', this._presenceState);
1003
+ }
1004
+
1005
+ _triggerEvent(event, data) {
1006
+ const callbacks = this._callbacks.get(event) || [];
1007
+ callbacks.forEach(cb => cb(data));
1008
+ }
1009
+ }
1010
+
1011
+ // Export for CommonJS
1012
+ if (typeof module !== 'undefined' && module.exports) {
1013
+ module.exports = { VolcanoRealtime, RealtimeChannel };
1014
+ }
1015
+
1016
+ export { RealtimeChannel, VolcanoRealtime };