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