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