@volcano.dev/sdk 1.2.0-nightly.22206624052.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.

Potentially problematic release.


This version of @volcano.dev/sdk might be problematic. Click here for more details.

package/dist/index.js ADDED
@@ -0,0 +1,2588 @@
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.VolcanoAuth = {}));
5
+ })(this, (function (exports) { 'use strict';
6
+
7
+ /**
8
+ * Volcano Auth SDK - Official JavaScript client for Volcano Hosting
9
+ *
10
+ * @example
11
+ * ```javascript
12
+ * import { VolcanoAuth } from '@volcano.dev/sdk';
13
+ *
14
+ * // Basic usage (uses https://api.volcano.dev by default)
15
+ * const volcano = new VolcanoAuth({
16
+ * anonKey: 'your-anon-key'
17
+ * });
18
+ *
19
+ * // Or with custom API URL
20
+ * const volcano = new VolcanoAuth({
21
+ * apiUrl: 'https://api.yourapp.com',
22
+ * anonKey: 'your-anon-key'
23
+ * });
24
+ *
25
+ * // Sign up
26
+ * const { user, session } = await volcano.auth.signUp({
27
+ * email: 'user@example.com',
28
+ * password: 'password123'
29
+ * });
30
+ *
31
+ * // Sign in
32
+ * const { user, session } = await volcano.auth.signIn({
33
+ * email: 'user@example.com',
34
+ * password: 'password123'
35
+ * });
36
+ *
37
+ * // Invoke function
38
+ * const result = await volcano.functions.invoke('function-id', {
39
+ * action: 'getData'
40
+ * });
41
+ * ```
42
+ */
43
+
44
+ // ============================================================================
45
+ // Constants
46
+ // ============================================================================
47
+
48
+ const DEFAULT_API_URL = 'https://api.volcano.dev';
49
+ const DEFAULT_TIMEOUT_MS = 60000; // 60 seconds
50
+ const DEFAULT_UPLOAD_PART_SIZE = 25 * 1024 * 1024; // 25MB
51
+ const DEFAULT_SESSIONS_LIMIT = 20;
52
+ const STORAGE_KEY_ACCESS_TOKEN = 'volcano_access_token';
53
+ const STORAGE_KEY_REFRESH_TOKEN = 'volcano_refresh_token';
54
+
55
+ // ============================================================================
56
+ // Utility Functions
57
+ // ============================================================================
58
+
59
+ /**
60
+ * Detect if we're running in a browser/client-side environment.
61
+ */
62
+ function isBrowser() {
63
+ return typeof window !== 'undefined' && typeof window.document !== 'undefined';
64
+ }
65
+
66
+ /**
67
+ * Basic provider name sanitization - only alphanumeric and hyphens allowed
68
+ * This is NOT validation (backend validates), just prevents URL injection
69
+ * @param {string} provider - The provider name
70
+ * @throws {Error} If provider contains invalid characters
71
+ */
72
+ function sanitizeProvider(provider) {
73
+ if (!provider || typeof provider !== 'string' || !/^[a-z0-9-]+$/.test(provider)) {
74
+ throw new Error('Provider must be a non-empty string containing only lowercase letters, numbers, and hyphens');
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Fetch with timeout using AbortController
80
+ * @param {string} url - The URL to fetch
81
+ * @param {RequestInit} options - Fetch options
82
+ * @param {number} [timeoutMs] - Timeout in milliseconds (default: 60000)
83
+ * @returns {Promise<Response>}
84
+ */
85
+ async function fetchWithTimeout(url, options = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
86
+ const controller = new AbortController();
87
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
88
+
89
+ try {
90
+ const response = await fetch(url, {
91
+ ...options,
92
+ signal: controller.signal
93
+ });
94
+ return response;
95
+ } catch (error) {
96
+ if (error.name === 'AbortError') {
97
+ throw new Error(`Request timeout after ${timeoutMs}ms`);
98
+ }
99
+ throw error;
100
+ } finally {
101
+ clearTimeout(timeoutId);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Safely parse JSON from response, returns empty object on failure
107
+ * @param {Response} response
108
+ * @returns {Promise<Object>}
109
+ */
110
+ async function safeJsonParse(response) {
111
+ try {
112
+ return await response.json();
113
+ } catch {
114
+ return {};
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Decode a base64url string to UTF-8 (JWT-safe, Node/browser compatible)
120
+ * @param {string} value
121
+ * @returns {string}
122
+ */
123
+ function decodeBase64Url(value) {
124
+ const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
125
+ const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4));
126
+ const base64 = normalized + padding;
127
+
128
+ if (typeof atob === 'function') {
129
+ return atob(base64);
130
+ }
131
+
132
+ if (typeof Buffer !== 'undefined') {
133
+ return Buffer.from(base64, 'base64').toString('utf-8');
134
+ }
135
+
136
+ throw new Error('No base64 decoder available');
137
+ }
138
+
139
+ /**
140
+ * Fetch with auth header and refresh retry on 401
141
+ * @param {VolcanoAuth} volcanoAuth
142
+ * @param {string} url
143
+ * @param {RequestInit} options
144
+ * @returns {Promise<Response>}
145
+ */
146
+ async function fetchWithAuthRetry(volcanoAuth, url, options = {}) {
147
+ const doFetch = () => fetchWithTimeout(
148
+ url,
149
+ {
150
+ ...options,
151
+ headers: {
152
+ 'Authorization': `Bearer ${volcanoAuth.accessToken}`,
153
+ ...options.headers
154
+ }
155
+ },
156
+ volcanoAuth.timeout
157
+ );
158
+
159
+ let response = await doFetch();
160
+ if (response.status === 401) {
161
+ const refreshed = await volcanoAuth.refreshSession();
162
+ if (!refreshed.error) {
163
+ response = await doFetch();
164
+ }
165
+ }
166
+
167
+ return response;
168
+ }
169
+
170
+ /**
171
+ * Create an error result object
172
+ * @param {string} message - Error message
173
+ * @param {Object} [extra] - Extra fields to include
174
+ * @returns {Object}
175
+ */
176
+ function errorResult(message, extra = {}) {
177
+ return { data: null, error: new Error(message), ...extra };
178
+ }
179
+
180
+ // ============================================================================
181
+ // VolcanoAuth Class
182
+ // ============================================================================
183
+
184
+ class VolcanoAuth {
185
+ constructor(config) {
186
+ if (!config.anonKey) {
187
+ throw new Error('anonKey is required. Get your anon key from project settings.');
188
+ }
189
+
190
+ // SECURITY: Throw hard error if service key is used client-side
191
+ if (config.anonKey.startsWith('sk-') && isBrowser()) {
192
+ throw new Error(
193
+ '[VOLCANO SECURITY ERROR] Service keys (sk-*) cannot be used in client-side code. ' +
194
+ 'Service keys bypass Row Level Security and expose your database to unauthorized access. ' +
195
+ 'Use an anon key (ak-*) for browser/client-side applications. ' +
196
+ 'Service keys should only be used in secure server-side environments. ' +
197
+ 'See: https://docs.volcano.hosting/security/keys'
198
+ );
199
+ }
200
+
201
+ this.apiUrl = (config.apiUrl || DEFAULT_API_URL).replace(/\/$/, ''); // Remove trailing slash
202
+ this.anonKey = config.anonKey;
203
+ this.timeout = config.timeout || DEFAULT_TIMEOUT_MS;
204
+ this._currentDatabaseName = null;
205
+ this.currentUser = null;
206
+
207
+ // Server-side use: Allow passing accessToken directly (e.g., in Lambda functions)
208
+ if (config.accessToken) {
209
+ this.accessToken = config.accessToken;
210
+ this.refreshToken = config.refreshToken || null;
211
+ } else {
212
+ // Client-side use: Restore from localStorage if available
213
+ this.accessToken = this._getStorageItem(STORAGE_KEY_ACCESS_TOKEN);
214
+ this.refreshToken = this._getStorageItem(STORAGE_KEY_REFRESH_TOKEN);
215
+ }
216
+
217
+ // Sub-objects for organization
218
+ this.auth = {
219
+ signUp: this.signUp.bind(this),
220
+ signIn: this.signIn.bind(this),
221
+ signOut: this.signOut.bind(this),
222
+ getUser: this.getUser.bind(this),
223
+ updateUser: this.updateUser.bind(this),
224
+ refreshSession: this.refreshSession.bind(this),
225
+ onAuthStateChange: this.onAuthStateChange.bind(this),
226
+ user: () => this.currentUser,
227
+ // Anonymous user methods
228
+ signUpAnonymous: this.signUpAnonymous.bind(this),
229
+ convertAnonymous: this.convertAnonymous.bind(this),
230
+ // Email confirmation methods
231
+ confirmEmail: this.confirmEmail.bind(this),
232
+ resendConfirmation: this.resendConfirmation.bind(this),
233
+ // Password recovery methods
234
+ forgotPassword: this.forgotPassword.bind(this),
235
+ resetPassword: this.resetPassword.bind(this),
236
+ // Email change methods
237
+ requestEmailChange: this.requestEmailChange.bind(this),
238
+ confirmEmailChange: this.confirmEmailChange.bind(this),
239
+ cancelEmailChange: this.cancelEmailChange.bind(this),
240
+ // OAuth methods
241
+ signInWithOAuth: this.signInWithOAuth.bind(this),
242
+ signInWithGoogle: this.signInWithGoogle.bind(this),
243
+ signInWithGitHub: this.signInWithGitHub.bind(this),
244
+ signInWithMicrosoft: this.signInWithMicrosoft.bind(this),
245
+ signInWithApple: this.signInWithApple.bind(this),
246
+ linkOAuthProvider: this.linkOAuthProvider.bind(this),
247
+ unlinkOAuthProvider: this.unlinkOAuthProvider.bind(this),
248
+ getLinkedOAuthProviders: this.getLinkedOAuthProviders.bind(this),
249
+ refreshOAuthToken: this.refreshOAuthToken.bind(this),
250
+ getOAuthProviderToken: this.getOAuthProviderToken.bind(this),
251
+ callOAuthAPI: this.callOAuthAPI.bind(this),
252
+ // Session management methods
253
+ getSessions: this.getSessions.bind(this),
254
+ deleteSession: this.deleteSession.bind(this),
255
+ deleteAllOtherSessions: this.deleteAllOtherSessions.bind(this)
256
+ };
257
+
258
+ this.functions = {
259
+ invoke: this.invokeFunction.bind(this)
260
+ };
261
+
262
+ this.storage = {
263
+ from: this.storageBucket.bind(this)
264
+ };
265
+ }
266
+
267
+ // ========================================================================
268
+ // Storage Methods
269
+ // ========================================================================
270
+
271
+ /**
272
+ * Select a storage bucket to perform operations on
273
+ * @param {string} bucketName - The name of the bucket
274
+ * @returns {StorageFileApi} - Storage file API for the bucket
275
+ */
276
+ storageBucket(bucketName) {
277
+ return new StorageFileApi(this, bucketName);
278
+ }
279
+
280
+ // ========================================================================
281
+ // Internal Fetch Helpers
282
+ // ========================================================================
283
+
284
+ /**
285
+ * Make an authenticated request with access token
286
+ * @private
287
+ */
288
+ async _authFetch(path, options = {}) {
289
+ if (!this.accessToken) {
290
+ return { ok: false, error: new Error('No active session'), data: null };
291
+ }
292
+
293
+ try {
294
+ const response = await fetchWithTimeout(
295
+ `${this.apiUrl}${path}`,
296
+ {
297
+ ...options,
298
+ headers: {
299
+ 'Authorization': `Bearer ${this.accessToken}`,
300
+ 'Content-Type': 'application/json',
301
+ ...options.headers
302
+ }
303
+ },
304
+ this.timeout
305
+ );
306
+
307
+ const data = await safeJsonParse(response);
308
+
309
+ if (!response.ok) {
310
+ // Try token refresh on 401
311
+ if (response.status === 401 && !options._retried) {
312
+ const refreshed = await this.refreshSession();
313
+ if (!refreshed.error) {
314
+ return this._authFetch(path, { ...options, _retried: true });
315
+ }
316
+ return { ok: false, error: new Error('Session expired'), data };
317
+ }
318
+ return { ok: false, error: new Error(data.error || 'Request failed'), data };
319
+ }
320
+
321
+ return { ok: true, data, error: null };
322
+ } catch (error) {
323
+ return { ok: false, error: error instanceof Error ? error : new Error('Request failed'), data: null };
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Make a public request with anon key
329
+ * @private
330
+ */
331
+ async _anonFetch(path, options = {}) {
332
+ try {
333
+ const response = await fetchWithTimeout(
334
+ `${this.apiUrl}${path}`,
335
+ {
336
+ ...options,
337
+ headers: {
338
+ 'Authorization': `Bearer ${this.anonKey}`,
339
+ 'Content-Type': 'application/json',
340
+ ...options.headers
341
+ }
342
+ },
343
+ this.timeout
344
+ );
345
+
346
+ const data = await safeJsonParse(response);
347
+
348
+ if (!response.ok) {
349
+ return { ok: false, error: new Error(data.error || 'Request failed'), data };
350
+ }
351
+
352
+ return { ok: true, data, error: null };
353
+ } catch (error) {
354
+ return { ok: false, error: error instanceof Error ? error : new Error('Request failed'), data: null };
355
+ }
356
+ }
357
+
358
+ // ========================================================================
359
+ // Query Builder Methods
360
+ // ========================================================================
361
+
362
+ from(table) {
363
+ return new QueryBuilder(this, table, this._currentDatabaseName);
364
+ }
365
+
366
+ database(databaseName) {
367
+ this._currentDatabaseName = databaseName;
368
+ return this;
369
+ }
370
+
371
+ insert(table, values) {
372
+ return new MutationBuilder(this, table, this._currentDatabaseName, 'insert', values);
373
+ }
374
+
375
+ update(table, values) {
376
+ return new MutationBuilder(this, table, this._currentDatabaseName, 'update', values);
377
+ }
378
+
379
+ delete(table) {
380
+ return new MutationBuilder(this, table, this._currentDatabaseName, 'delete', null);
381
+ }
382
+
383
+ // ========================================================================
384
+ // Authentication Methods
385
+ // ========================================================================
386
+
387
+ async signUp({ email, password, metadata = {} }) {
388
+ const result = await this._anonFetch('/auth/signup', {
389
+ method: 'POST',
390
+ body: JSON.stringify({ email, password, user_metadata: metadata })
391
+ });
392
+
393
+ if (!result.ok) {
394
+ return { user: null, session: null, error: result.error };
395
+ }
396
+
397
+ this._setSession(result.data);
398
+ return {
399
+ user: result.data.user,
400
+ session: {
401
+ access_token: result.data.access_token,
402
+ refresh_token: result.data.refresh_token,
403
+ expires_in: result.data.expires_in
404
+ },
405
+ error: null
406
+ };
407
+ }
408
+
409
+ async signIn({ email, password }) {
410
+ const result = await this._anonFetch('/auth/signin', {
411
+ method: 'POST',
412
+ body: JSON.stringify({ email, password })
413
+ });
414
+
415
+ if (!result.ok) {
416
+ return { user: null, session: null, error: result.error };
417
+ }
418
+
419
+ this._setSession(result.data);
420
+ return {
421
+ user: result.data.user,
422
+ session: {
423
+ access_token: result.data.access_token,
424
+ refresh_token: result.data.refresh_token,
425
+ expires_in: result.data.expires_in
426
+ },
427
+ error: null
428
+ };
429
+ }
430
+
431
+ async signOut() {
432
+ if (this.refreshToken) {
433
+ try {
434
+ await this._anonFetch('/auth/logout', {
435
+ method: 'POST',
436
+ body: JSON.stringify({ refresh_token: this.refreshToken })
437
+ });
438
+ } catch (err) {
439
+ console.warn('[VolcanoAuth] Logout request failed:', err.message);
440
+ }
441
+ }
442
+ this._clearSession();
443
+ return { error: null };
444
+ }
445
+
446
+ async getUser() {
447
+ const result = await this._authFetch('/auth/user');
448
+
449
+ if (!result.ok) {
450
+ return { user: null, error: result.error };
451
+ }
452
+
453
+ this.currentUser = result.data.user;
454
+ return { user: result.data.user, error: null };
455
+ }
456
+
457
+ async updateUser({ password, metadata }) {
458
+ const result = await this._authFetch('/auth/user', {
459
+ method: 'PUT',
460
+ body: JSON.stringify({ password, user_metadata: metadata })
461
+ });
462
+
463
+ if (!result.ok) {
464
+ return { user: null, error: result.error };
465
+ }
466
+
467
+ this.currentUser = result.data.user;
468
+ return { user: result.data.user, error: null };
469
+ }
470
+
471
+ async refreshSession() {
472
+ if (!this.refreshToken) {
473
+ return { session: null, error: new Error('No refresh token') };
474
+ }
475
+
476
+ try {
477
+ const result = await this._anonFetch('/auth/refresh', {
478
+ method: 'POST',
479
+ body: JSON.stringify({ refresh_token: this.refreshToken })
480
+ });
481
+
482
+ if (!result.ok) {
483
+ this._clearSession();
484
+ return { session: null, error: result.error };
485
+ }
486
+
487
+ this._setSession(result.data);
488
+ return {
489
+ session: {
490
+ access_token: result.data.access_token,
491
+ refresh_token: result.data.refresh_token,
492
+ expires_in: result.data.expires_in
493
+ },
494
+ error: null
495
+ };
496
+ } catch (error) {
497
+ this._clearSession();
498
+ return { session: null, error: error instanceof Error ? error : new Error('Refresh failed') };
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Register a callback for auth state changes.
504
+ * @param {Function} callback - Called with user object (or null) on auth state change
505
+ * @returns {Function} Unsubscribe function
506
+ */
507
+ onAuthStateChange(callback) {
508
+ if (!this._authCallbacks) {
509
+ this._authCallbacks = [];
510
+ }
511
+ this._authCallbacks.push(callback);
512
+
513
+ // Call immediately with current state
514
+ try {
515
+ callback(this.currentUser);
516
+ } catch (err) {
517
+ console.error('[VolcanoAuth] Error in auth state callback:', err);
518
+ }
519
+
520
+ return () => {
521
+ this._authCallbacks = this._authCallbacks.filter(cb => cb !== callback);
522
+ };
523
+ }
524
+
525
+ // ========================================================================
526
+ // Anonymous User Methods
527
+ // ========================================================================
528
+
529
+ async signUpAnonymous(metadata = {}) {
530
+ const result = await this._anonFetch('/auth/signup-anonymous', {
531
+ method: 'POST',
532
+ body: JSON.stringify({ user_metadata: metadata })
533
+ });
534
+
535
+ if (!result.ok) {
536
+ return { user: null, session: null, error: result.error };
537
+ }
538
+
539
+ this._setSession(result.data);
540
+ return {
541
+ user: result.data.user,
542
+ session: {
543
+ access_token: result.data.access_token,
544
+ refresh_token: result.data.refresh_token,
545
+ expires_in: result.data.expires_in
546
+ },
547
+ error: null
548
+ };
549
+ }
550
+
551
+ async convertAnonymous({ email, password, metadata = {} }) {
552
+ const result = await this._authFetch('/auth/user/convert-anonymous', {
553
+ method: 'POST',
554
+ body: JSON.stringify({ email, password, user_metadata: metadata })
555
+ });
556
+
557
+ if (!result.ok) {
558
+ return { user: null, error: result.error };
559
+ }
560
+
561
+ this.currentUser = result.data.user;
562
+ return { user: result.data.user, error: null };
563
+ }
564
+
565
+ // ========================================================================
566
+ // Email Confirmation Methods
567
+ // ========================================================================
568
+
569
+ async confirmEmail(token) {
570
+ const result = await this._anonFetch('/auth/confirm', {
571
+ method: 'POST',
572
+ body: JSON.stringify({ token })
573
+ });
574
+
575
+ if (!result.ok) {
576
+ return { message: null, error: result.error };
577
+ }
578
+ return { message: result.data.message, error: null };
579
+ }
580
+
581
+ async resendConfirmation(email) {
582
+ const result = await this._anonFetch('/auth/resend-confirmation', {
583
+ method: 'POST',
584
+ body: JSON.stringify({ email })
585
+ });
586
+
587
+ if (!result.ok) {
588
+ return { message: null, error: result.error };
589
+ }
590
+ return { message: result.data.message, error: null };
591
+ }
592
+
593
+ // ========================================================================
594
+ // Password Recovery Methods
595
+ // ========================================================================
596
+
597
+ async forgotPassword(email) {
598
+ const result = await this._anonFetch('/auth/forgot-password', {
599
+ method: 'POST',
600
+ body: JSON.stringify({ email })
601
+ });
602
+
603
+ if (!result.ok) {
604
+ return { message: null, error: result.error };
605
+ }
606
+ return { message: result.data.message, error: null };
607
+ }
608
+
609
+ async resetPassword({ token, newPassword }) {
610
+ const result = await this._anonFetch('/auth/reset-password', {
611
+ method: 'POST',
612
+ body: JSON.stringify({ token, new_password: newPassword })
613
+ });
614
+
615
+ if (!result.ok) {
616
+ return { message: null, error: result.error };
617
+ }
618
+ return { message: result.data.message, error: null };
619
+ }
620
+
621
+ // ========================================================================
622
+ // Email Change Methods
623
+ // ========================================================================
624
+
625
+ async requestEmailChange(newEmail) {
626
+ const result = await this._authFetch('/auth/user/change-email', {
627
+ method: 'POST',
628
+ body: JSON.stringify({ new_email: newEmail })
629
+ });
630
+
631
+ if (!result.ok) {
632
+ return { message: null, newEmail: null, error: result.error };
633
+ }
634
+ return {
635
+ message: result.data.message,
636
+ newEmail: result.data.new_email,
637
+ emailChangeToken: result.data.email_change_token,
638
+ error: null
639
+ };
640
+ }
641
+
642
+ async confirmEmailChange(emailChangeToken) {
643
+ const result = await this._authFetch('/auth/user/confirm-email-change', {
644
+ method: 'POST',
645
+ body: JSON.stringify({ email_change_token: emailChangeToken })
646
+ });
647
+
648
+ if (!result.ok) {
649
+ return { user: null, error: result.error };
650
+ }
651
+
652
+ this.currentUser = result.data.user;
653
+ return { user: result.data.user, error: null };
654
+ }
655
+
656
+ async cancelEmailChange() {
657
+ const result = await this._authFetch('/auth/user/cancel-email-change', {
658
+ method: 'DELETE'
659
+ });
660
+
661
+ if (!result.ok) {
662
+ return { message: null, error: result.error };
663
+ }
664
+ return { message: result.data.message, error: null };
665
+ }
666
+
667
+ // ========================================================================
668
+ // OAuth / SSO Authentication
669
+ // ========================================================================
670
+
671
+ signInWithOAuth(provider) {
672
+ sanitizeProvider(provider);
673
+ if (!isBrowser()) {
674
+ throw new Error('OAuth sign-in is only available in browser environment. Use server-side auth flow for SSR.');
675
+ }
676
+ window.location.href = `${this.apiUrl}/auth/oauth/${provider}/authorize?anon_key=${encodeURIComponent(this.anonKey)}`;
677
+ }
678
+
679
+ signInWithGoogle() { this.signInWithOAuth('google'); }
680
+ signInWithGitHub() { this.signInWithOAuth('github'); }
681
+ signInWithMicrosoft() { this.signInWithOAuth('microsoft'); }
682
+ signInWithApple() { this.signInWithOAuth('apple'); }
683
+
684
+ async linkOAuthProvider(provider) {
685
+ sanitizeProvider(provider);
686
+ const result = await this._authFetch(`/auth/oauth/${provider}/link`, {
687
+ method: 'POST'
688
+ });
689
+
690
+ if (!result.ok) {
691
+ return { data: null, error: result.error };
692
+ }
693
+ return { data: result.data, error: null };
694
+ }
695
+
696
+ async unlinkOAuthProvider(provider) {
697
+ sanitizeProvider(provider);
698
+ const result = await this._authFetch(`/auth/oauth/${provider}/unlink`, {
699
+ method: 'DELETE'
700
+ });
701
+
702
+ if (!result.ok) {
703
+ return { error: result.error };
704
+ }
705
+ return { error: null };
706
+ }
707
+
708
+ async getLinkedOAuthProviders() {
709
+ const result = await this._authFetch('/auth/oauth/providers');
710
+
711
+ if (!result.ok) {
712
+ return { providers: null, error: result.error };
713
+ }
714
+ return { providers: result.data.providers || [], error: null };
715
+ }
716
+
717
+ async refreshOAuthToken(provider) {
718
+ sanitizeProvider(provider);
719
+ const result = await this._authFetch(`/auth/oauth/${provider}/refresh-token`, {
720
+ method: 'POST'
721
+ });
722
+
723
+ if (!result.ok) {
724
+ return { message: null, provider: null, expiresIn: null, error: result.error };
725
+ }
726
+ return {
727
+ message: result.data.message,
728
+ provider: result.data.provider,
729
+ expiresIn: result.data.expires_in,
730
+ error: null
731
+ };
732
+ }
733
+
734
+ async getOAuthProviderToken(provider) {
735
+ sanitizeProvider(provider);
736
+ const result = await this._authFetch(`/auth/oauth/${provider}/token`);
737
+
738
+ if (!result.ok) {
739
+ return { message: null, provider: null, expiresIn: null, error: result.error };
740
+ }
741
+ return {
742
+ message: result.data.message,
743
+ provider: result.data.provider,
744
+ expiresIn: result.data.expires_in,
745
+ error: null
746
+ };
747
+ }
748
+
749
+ async callOAuthAPI(provider, { endpoint, method = 'GET', body = null }) {
750
+ sanitizeProvider(provider);
751
+ const result = await this._authFetch(`/auth/oauth/${provider}/call-api`, {
752
+ method: 'POST',
753
+ body: JSON.stringify({ endpoint, method, body })
754
+ });
755
+
756
+ if (!result.ok) {
757
+ return { data: null, error: result.error };
758
+ }
759
+ return { data: result.data.data, error: null };
760
+ }
761
+
762
+ // ========================================================================
763
+ // Session Management (User's sessions)
764
+ // ========================================================================
765
+
766
+ async getSessions(options = {}) {
767
+ const { page = 1, limit = DEFAULT_SESSIONS_LIMIT } = options;
768
+ const params = new URLSearchParams();
769
+ if (page > 1) params.set('page', page.toString());
770
+ if (limit !== DEFAULT_SESSIONS_LIMIT) params.set('limit', limit.toString());
771
+
772
+ const queryString = params.toString();
773
+ const url = `/auth/user/sessions${queryString ? '?' + queryString : ''}`;
774
+ const result = await this._authFetch(url);
775
+
776
+ if (!result.ok) {
777
+ return { sessions: null, total: 0, page: 1, limit: DEFAULT_SESSIONS_LIMIT, total_pages: 0, error: result.error };
778
+ }
779
+ return {
780
+ sessions: result.data.sessions,
781
+ total: result.data.total,
782
+ page: result.data.page,
783
+ limit: result.data.limit,
784
+ total_pages: result.data.total_pages,
785
+ error: null
786
+ };
787
+ }
788
+
789
+ async deleteSession(sessionId) {
790
+ const result = await this._authFetch(`/auth/user/sessions/${encodeURIComponent(sessionId)}`, {
791
+ method: 'DELETE'
792
+ });
793
+
794
+ if (!result.ok) {
795
+ return { error: result.error };
796
+ }
797
+ return { error: null };
798
+ }
799
+
800
+ async deleteAllOtherSessions() {
801
+ const result = await this._authFetch('/auth/user/sessions', {
802
+ method: 'DELETE'
803
+ });
804
+
805
+ if (!result.ok) {
806
+ return { error: result.error };
807
+ }
808
+ return { error: null };
809
+ }
810
+
811
+ // ========================================================================
812
+ // Function Invocation
813
+ // ========================================================================
814
+
815
+ async invokeFunction(functionId, payload = {}) {
816
+ if (!functionId || typeof functionId !== 'string') {
817
+ return { data: null, error: new Error('functionId must be a non-empty string') };
818
+ }
819
+
820
+ const result = await this._authFetch(`/functions/${encodeURIComponent(functionId)}/invoke`, {
821
+ method: 'POST',
822
+ body: JSON.stringify(payload)
823
+ });
824
+
825
+ if (!result.ok) {
826
+ return { data: null, error: result.error };
827
+ }
828
+ return { data: result.data, error: null };
829
+ }
830
+
831
+ // ========================================================================
832
+ // Session Management (Internal)
833
+ // ========================================================================
834
+
835
+ _setSession(data) {
836
+ this.accessToken = data.access_token;
837
+ this.refreshToken = data.refresh_token;
838
+ this.currentUser = data.user;
839
+
840
+ this._setStorageItem(STORAGE_KEY_ACCESS_TOKEN, this.accessToken);
841
+ this._setStorageItem(STORAGE_KEY_REFRESH_TOKEN, this.refreshToken);
842
+
843
+ this._notifyAuthCallbacks(this.currentUser);
844
+ }
845
+
846
+ _clearSession() {
847
+ this.accessToken = null;
848
+ this.refreshToken = null;
849
+ this.currentUser = null;
850
+
851
+ this._removeStorageItem(STORAGE_KEY_ACCESS_TOKEN);
852
+ this._removeStorageItem(STORAGE_KEY_REFRESH_TOKEN);
853
+
854
+ this._notifyAuthCallbacks(null);
855
+ }
856
+
857
+ _notifyAuthCallbacks(user) {
858
+ if (this._authCallbacks) {
859
+ this._authCallbacks.forEach(cb => {
860
+ try {
861
+ cb(user);
862
+ } catch (err) {
863
+ console.error('[VolcanoAuth] Error in auth state callback:', err);
864
+ }
865
+ });
866
+ }
867
+ }
868
+
869
+ // ========================================================================
870
+ // Storage Helpers (Browser/Node.js compatible)
871
+ // ========================================================================
872
+
873
+ _getStorageItem(key) {
874
+ if (typeof localStorage !== 'undefined') {
875
+ return localStorage.getItem(key);
876
+ }
877
+ return null;
878
+ }
879
+
880
+ _setStorageItem(key, value) {
881
+ if (typeof localStorage !== 'undefined') {
882
+ localStorage.setItem(key, value);
883
+ }
884
+ }
885
+
886
+ _removeStorageItem(key) {
887
+ if (typeof localStorage !== 'undefined') {
888
+ localStorage.removeItem(key);
889
+ }
890
+ }
891
+
892
+ // ========================================================================
893
+ // Initialization
894
+ // ========================================================================
895
+
896
+ async initialize() {
897
+ if (this.accessToken && this.refreshToken) {
898
+ const { user, error } = await this.getUser();
899
+ return { user, error };
900
+ }
901
+ return { user: null, error: null };
902
+ }
903
+ }
904
+
905
+ // ============================================================================
906
+ // Shared Filter Mixin - Used by QueryBuilder and MutationBuilder
907
+ // ============================================================================
908
+
909
+ const FilterMixin = {
910
+ eq(column, value) {
911
+ this.filters.push({ column, operator: 'eq', value });
912
+ return this;
913
+ },
914
+ neq(column, value) {
915
+ this.filters.push({ column, operator: 'neq', value });
916
+ return this;
917
+ },
918
+ gt(column, value) {
919
+ this.filters.push({ column, operator: 'gt', value });
920
+ return this;
921
+ },
922
+ gte(column, value) {
923
+ this.filters.push({ column, operator: 'gte', value });
924
+ return this;
925
+ },
926
+ lt(column, value) {
927
+ this.filters.push({ column, operator: 'lt', value });
928
+ return this;
929
+ },
930
+ lte(column, value) {
931
+ this.filters.push({ column, operator: 'lte', value });
932
+ return this;
933
+ },
934
+ like(column, pattern) {
935
+ this.filters.push({ column, operator: 'like', value: pattern });
936
+ return this;
937
+ },
938
+ ilike(column, pattern) {
939
+ this.filters.push({ column, operator: 'ilike', value: pattern });
940
+ return this;
941
+ },
942
+ is(column, value) {
943
+ this.filters.push({ column, operator: 'is', value });
944
+ return this;
945
+ },
946
+ in(column, values) {
947
+ this.filters.push({ column, operator: 'in', value: values });
948
+ return this;
949
+ }
950
+ };
951
+
952
+ // ============================================================================
953
+ // QueryBuilder - For SELECT operations
954
+ // ============================================================================
955
+
956
+ class QueryBuilder {
957
+ constructor(volcanoAuth, table, databaseName) {
958
+ this.volcanoAuth = volcanoAuth;
959
+ this.table = table;
960
+ this.databaseName = databaseName;
961
+ this.selectColumns = [];
962
+ this.filters = [];
963
+ this.orderClauses = [];
964
+ this.limitValue = null;
965
+ this.offsetValue = null;
966
+ }
967
+
968
+ select(columns) {
969
+ if (columns === '*') {
970
+ this.selectColumns = [];
971
+ } else if (Array.isArray(columns)) {
972
+ this.selectColumns = columns;
973
+ } else {
974
+ this.selectColumns = columns.split(',').map(c => c.trim());
975
+ }
976
+ return this;
977
+ }
978
+
979
+ order(column, options = {}) {
980
+ this.orderClauses.push({
981
+ column,
982
+ ascending: options.ascending !== false
983
+ });
984
+ return this;
985
+ }
986
+
987
+ limit(count) {
988
+ this.limitValue = count;
989
+ return this;
990
+ }
991
+
992
+ offset(count) {
993
+ this.offsetValue = count;
994
+ return this;
995
+ }
996
+
997
+ async execute() {
998
+ if (!this.volcanoAuth.accessToken) {
999
+ return errorResult('No active session. Please sign in first.', { count: 0 });
1000
+ }
1001
+
1002
+ if (!this.databaseName) {
1003
+ return errorResult('Database name not set. Use .database(databaseName) first.', { count: 0 });
1004
+ }
1005
+
1006
+ const requestBody = { table: this.table };
1007
+ if (this.selectColumns.length > 0) requestBody.select = this.selectColumns;
1008
+ if (this.filters.length > 0) requestBody.filters = this.filters;
1009
+ if (this.orderClauses.length > 0) requestBody.order = this.orderClauses;
1010
+ if (this.limitValue !== null) requestBody.limit = this.limitValue;
1011
+ if (this.offsetValue !== null) requestBody.offset = this.offsetValue;
1012
+
1013
+ try {
1014
+ const response = await fetchWithAuthRetry(
1015
+ this.volcanoAuth,
1016
+ `${this.volcanoAuth.apiUrl}/databases/${encodeURIComponent(this.databaseName)}/query/select`,
1017
+ {
1018
+ method: 'POST',
1019
+ headers: {
1020
+ 'Content-Type': 'application/json'
1021
+ },
1022
+ body: JSON.stringify(requestBody)
1023
+ }
1024
+ );
1025
+
1026
+ const result = await safeJsonParse(response);
1027
+
1028
+ if (!response.ok) {
1029
+ return errorResult(result.error || 'Query failed', { count: 0 });
1030
+ }
1031
+
1032
+ return { data: result.data, error: null, count: result.count || result.data.length };
1033
+ } catch (error) {
1034
+ return { data: null, error: error instanceof Error ? error : new Error('Query failed'), count: 0 };
1035
+ }
1036
+ }
1037
+
1038
+ then(resolve, reject) {
1039
+ return this.execute().then(resolve, reject);
1040
+ }
1041
+ }
1042
+
1043
+ Object.assign(QueryBuilder.prototype, FilterMixin);
1044
+
1045
+ // ============================================================================
1046
+ // MutationBuilder - Unified builder for INSERT, UPDATE, DELETE
1047
+ // ============================================================================
1048
+
1049
+ class MutationBuilder {
1050
+ constructor(volcanoAuth, table, databaseName, operation, values) {
1051
+ this.volcanoAuth = volcanoAuth;
1052
+ this.table = table;
1053
+ this.databaseName = databaseName;
1054
+ this.operation = operation;
1055
+ this.values = values;
1056
+ this.filters = [];
1057
+ }
1058
+
1059
+ async execute() {
1060
+ if (!this.volcanoAuth.accessToken) {
1061
+ return errorResult('No active session. Please sign in first.');
1062
+ }
1063
+
1064
+ if (!this.databaseName) {
1065
+ return errorResult('Database name not set. Use .database(databaseName) first.');
1066
+ }
1067
+
1068
+ const requestBody = { table: this.table };
1069
+ if (this.values) requestBody.values = this.values;
1070
+ if (this.filters.length > 0) requestBody.filters = this.filters;
1071
+
1072
+ try {
1073
+ const response = await fetchWithAuthRetry(
1074
+ this.volcanoAuth,
1075
+ `${this.volcanoAuth.apiUrl}/databases/${encodeURIComponent(this.databaseName)}/query/${encodeURIComponent(this.operation)}`,
1076
+ {
1077
+ method: 'POST',
1078
+ headers: {
1079
+ 'Content-Type': 'application/json'
1080
+ },
1081
+ body: JSON.stringify(requestBody)
1082
+ }
1083
+ );
1084
+
1085
+ const result = await safeJsonParse(response);
1086
+
1087
+ if (!response.ok) {
1088
+ return errorResult(result.error || `${this.operation} failed`);
1089
+ }
1090
+
1091
+ return { data: result.data, error: null };
1092
+ } catch (error) {
1093
+ return { data: null, error: error instanceof Error ? error : new Error(`${this.operation} failed`) };
1094
+ }
1095
+ }
1096
+
1097
+ then(resolve, reject) {
1098
+ return this.execute().then(resolve, reject);
1099
+ }
1100
+ }
1101
+
1102
+ Object.assign(MutationBuilder.prototype, FilterMixin);
1103
+
1104
+ // ============================================================================
1105
+ // StorageFileApi - For storage operations on a specific bucket
1106
+ // ============================================================================
1107
+
1108
+ class StorageFileApi {
1109
+ constructor(volcanoAuth, bucketName) {
1110
+ this.volcanoAuth = volcanoAuth;
1111
+ this.bucketName = bucketName;
1112
+ }
1113
+
1114
+ /**
1115
+ * Check if user is authenticated
1116
+ * @private
1117
+ */
1118
+ _checkAuth() {
1119
+ if (!this.volcanoAuth.accessToken) {
1120
+ return errorResult('No active session. Please sign in first.');
1121
+ }
1122
+ return null;
1123
+ }
1124
+
1125
+ /**
1126
+ * Build a storage URL for the given path
1127
+ * @private
1128
+ */
1129
+ _buildUrl(path) {
1130
+ return `${this.volcanoAuth.apiUrl}/storage/${encodeURIComponent(this.bucketName)}/${this._encodePath(path)}`;
1131
+ }
1132
+
1133
+ /**
1134
+ * Encode a storage path for use in URLs
1135
+ * @private
1136
+ */
1137
+ _encodePath(path) {
1138
+ return path.split('/').map(segment => encodeURIComponent(segment)).join('/');
1139
+ }
1140
+
1141
+ /**
1142
+ * Make an authenticated storage request
1143
+ * @private
1144
+ */
1145
+ async _storageRequest(url, options = {}) {
1146
+ try {
1147
+ const response = await fetchWithAuthRetry(this.volcanoAuth, url, options);
1148
+
1149
+ // For blob responses (downloads), handle separately
1150
+ if (options.responseType === 'blob') {
1151
+ if (!response.ok) {
1152
+ const errorData = await safeJsonParse(response);
1153
+ return { data: null, error: new Error(errorData.error || 'Request failed') };
1154
+ }
1155
+ const blob = await response.blob();
1156
+ return { data: blob, error: null };
1157
+ }
1158
+
1159
+ const data = await safeJsonParse(response);
1160
+
1161
+ if (!response.ok) {
1162
+ return { data: null, error: new Error(data.error || 'Request failed') };
1163
+ }
1164
+
1165
+ return { data, error: null };
1166
+ } catch (error) {
1167
+ return { data: null, error: error instanceof Error ? error : new Error('Request failed') };
1168
+ }
1169
+ }
1170
+
1171
+ /**
1172
+ * Upload a file to the bucket
1173
+ */
1174
+ async upload(path, fileBody, options = {}) {
1175
+ const authError = this._checkAuth();
1176
+ if (authError) return authError;
1177
+
1178
+ try {
1179
+ const formData = new FormData();
1180
+ let file;
1181
+
1182
+ if (fileBody instanceof File) {
1183
+ file = fileBody;
1184
+ } else if (fileBody instanceof Blob) {
1185
+ const contentType = options.contentType || 'application/octet-stream';
1186
+ file = new File([fileBody], path.split('/').pop() || 'file', { type: contentType });
1187
+ } else if (fileBody instanceof ArrayBuffer) {
1188
+ const contentType = options.contentType || 'application/octet-stream';
1189
+ file = new File([fileBody], path.split('/').pop() || 'file', { type: contentType });
1190
+ } else {
1191
+ return errorResult('Invalid file body type. Expected File, Blob, or ArrayBuffer.');
1192
+ }
1193
+
1194
+ formData.append('file', file);
1195
+
1196
+ const response = await fetchWithAuthRetry(this.volcanoAuth, this._buildUrl(path), {
1197
+ method: 'POST',
1198
+ body: formData
1199
+ });
1200
+
1201
+ const data = await safeJsonParse(response);
1202
+
1203
+ if (!response.ok) {
1204
+ return errorResult(data.error || 'Upload failed');
1205
+ }
1206
+
1207
+ return { data, error: null };
1208
+ } catch (error) {
1209
+ return { data: null, error: error instanceof Error ? error : new Error('Upload failed') };
1210
+ }
1211
+ }
1212
+
1213
+ /**
1214
+ * Download a file from the bucket
1215
+ */
1216
+ async download(path, options = {}) {
1217
+ const authError = this._checkAuth();
1218
+ if (authError) return authError;
1219
+
1220
+ const headers = {};
1221
+ if (options.range) {
1222
+ headers['Range'] = options.range;
1223
+ }
1224
+
1225
+ return this._storageRequest(this._buildUrl(path), {
1226
+ method: 'GET',
1227
+ headers,
1228
+ responseType: 'blob'
1229
+ });
1230
+ }
1231
+
1232
+ /**
1233
+ * List files in the bucket
1234
+ */
1235
+ async list(prefix = '', options = {}) {
1236
+ const authError = this._checkAuth();
1237
+ if (authError) return { ...authError, nextCursor: null };
1238
+
1239
+ const params = new URLSearchParams();
1240
+ if (prefix) params.set('prefix', prefix);
1241
+ if (options.limit) params.set('limit', String(options.limit));
1242
+ if (options.cursor) params.set('cursor', options.cursor);
1243
+
1244
+ const queryString = params.toString();
1245
+ const url = `${this.volcanoAuth.apiUrl}/storage/${encodeURIComponent(this.bucketName)}${queryString ? '?' + queryString : ''}`;
1246
+
1247
+ const result = await this._storageRequest(url, {
1248
+ method: 'GET',
1249
+ headers: { 'Content-Type': 'application/json' }
1250
+ });
1251
+
1252
+ if (result.error) {
1253
+ return { data: null, error: result.error, nextCursor: null };
1254
+ }
1255
+
1256
+ return {
1257
+ data: result.data.objects || [],
1258
+ error: null,
1259
+ nextCursor: result.data.next_cursor || null
1260
+ };
1261
+ }
1262
+
1263
+ /**
1264
+ * Delete one or more files from the bucket
1265
+ */
1266
+ async remove(paths) {
1267
+ const authError = this._checkAuth();
1268
+ if (authError) return authError;
1269
+
1270
+ const pathList = Array.isArray(paths) ? paths : [paths];
1271
+ const errors = [];
1272
+ const deleted = [];
1273
+
1274
+ for (const path of pathList) {
1275
+ const result = await this._storageRequest(this._buildUrl(path), {
1276
+ method: 'DELETE'
1277
+ });
1278
+
1279
+ if (result.error) {
1280
+ errors.push({ path, error: result.error.message });
1281
+ } else {
1282
+ deleted.push(path);
1283
+ }
1284
+ }
1285
+
1286
+ if (errors.length > 0) {
1287
+ return {
1288
+ data: { deleted },
1289
+ error: new Error(`Failed to delete ${errors.length} file(s): ${errors.map(e => e.path).join(', ')}`)
1290
+ };
1291
+ }
1292
+
1293
+ return { data: { deleted }, error: null };
1294
+ }
1295
+
1296
+ /**
1297
+ * Move/rename a file within the bucket
1298
+ */
1299
+ async move(fromPath, toPath) {
1300
+ const authError = this._checkAuth();
1301
+ if (authError) return authError;
1302
+
1303
+ return this._storageRequest(
1304
+ `${this.volcanoAuth.apiUrl}/storage/${encodeURIComponent(this.bucketName)}/move`,
1305
+ {
1306
+ method: 'POST',
1307
+ headers: { 'Content-Type': 'application/json' },
1308
+ body: JSON.stringify({ from: fromPath, to: toPath })
1309
+ }
1310
+ );
1311
+ }
1312
+
1313
+ /**
1314
+ * Copy a file within the bucket
1315
+ */
1316
+ async copy(fromPath, toPath) {
1317
+ const authError = this._checkAuth();
1318
+ if (authError) return authError;
1319
+
1320
+ return this._storageRequest(
1321
+ `${this.volcanoAuth.apiUrl}/storage/${encodeURIComponent(this.bucketName)}/copy`,
1322
+ {
1323
+ method: 'POST',
1324
+ headers: { 'Content-Type': 'application/json' },
1325
+ body: JSON.stringify({ from: fromPath, to: toPath })
1326
+ }
1327
+ );
1328
+ }
1329
+
1330
+ /**
1331
+ * Get the public URL for a file (only works for files with is_public=true)
1332
+ */
1333
+ getPublicUrl(path) {
1334
+ try {
1335
+ const parts = this.volcanoAuth.anonKey.split('.');
1336
+ if (parts.length !== 3) {
1337
+ return errorResult('Invalid anon key format');
1338
+ }
1339
+
1340
+ const payload = JSON.parse(decodeBase64Url(parts[1]));
1341
+ const projectId = payload.project_id;
1342
+
1343
+ if (!projectId) {
1344
+ return errorResult('Project ID not found in anon key');
1345
+ }
1346
+
1347
+ const encodedPath = this._encodePath(path);
1348
+ const publicUrl = `${this.volcanoAuth.apiUrl}/public/${projectId}/${encodeURIComponent(this.bucketName)}/${encodedPath}`;
1349
+ return { data: { publicUrl }, error: null };
1350
+ } catch (error) {
1351
+ return errorResult('Failed to parse anon key: ' + (error instanceof Error ? error.message : 'Unknown error'));
1352
+ }
1353
+ }
1354
+
1355
+ /**
1356
+ * Update the visibility (public/private) of a file
1357
+ */
1358
+ async updateVisibility(path, isPublic) {
1359
+ const authError = this._checkAuth();
1360
+ if (authError) return authError;
1361
+
1362
+ return this._storageRequest(
1363
+ `${this._buildUrl(path)}/visibility`,
1364
+ {
1365
+ method: 'PATCH',
1366
+ headers: { 'Content-Type': 'application/json' },
1367
+ body: JSON.stringify({ is_public: isPublic })
1368
+ }
1369
+ );
1370
+ }
1371
+
1372
+ // ========================================================================
1373
+ // Resumable Upload Methods
1374
+ // ========================================================================
1375
+
1376
+ async createUploadSession(path, options) {
1377
+ const authError = this._checkAuth();
1378
+ if (authError) return authError;
1379
+
1380
+ if (!options || !options.totalSize) {
1381
+ return errorResult('totalSize is required');
1382
+ }
1383
+
1384
+ return this._storageRequest(this._buildUrl(path), {
1385
+ method: 'POST',
1386
+ headers: { 'Content-Type': 'application/json' },
1387
+ body: JSON.stringify({
1388
+ filename: path.split('/').pop() || path,
1389
+ content_type: options.contentType || 'application/octet-stream',
1390
+ total_size: options.totalSize,
1391
+ part_size: options.partSize
1392
+ })
1393
+ });
1394
+ }
1395
+
1396
+ async uploadPart(path, sessionId, partNumber, partData) {
1397
+ const authError = this._checkAuth();
1398
+ if (authError) return authError;
1399
+
1400
+ return this._storageRequest(this._buildUrl(path), {
1401
+ method: 'PUT',
1402
+ headers: {
1403
+ 'Content-Type': 'application/octet-stream',
1404
+ 'X-Upload-Session': sessionId,
1405
+ 'X-Part-Number': String(partNumber)
1406
+ },
1407
+ body: partData
1408
+ });
1409
+ }
1410
+
1411
+ async completeUploadSession(path, sessionId) {
1412
+ const authError = this._checkAuth();
1413
+ if (authError) return authError;
1414
+
1415
+ return this._storageRequest(this._buildUrl(path), {
1416
+ method: 'POST',
1417
+ headers: {
1418
+ 'Content-Type': 'application/json',
1419
+ 'X-Upload-Session': sessionId,
1420
+ 'X-Upload-Complete': 'true'
1421
+ },
1422
+ body: JSON.stringify({})
1423
+ });
1424
+ }
1425
+
1426
+ async getUploadSession(path, sessionId) {
1427
+ const authError = this._checkAuth();
1428
+ if (authError) return authError;
1429
+
1430
+ return this._storageRequest(this._buildUrl(path), {
1431
+ method: 'GET',
1432
+ headers: { 'X-Upload-Session': sessionId }
1433
+ });
1434
+ }
1435
+
1436
+ async abortUploadSession(path, sessionId) {
1437
+ const authError = this._checkAuth();
1438
+ if (authError) return { error: authError.error };
1439
+
1440
+ const result = await this._storageRequest(this._buildUrl(path), {
1441
+ method: 'DELETE',
1442
+ headers: { 'X-Upload-Session': sessionId }
1443
+ });
1444
+
1445
+ return { error: result.error };
1446
+ }
1447
+
1448
+ /**
1449
+ * Upload a large file using resumable upload with automatic chunking
1450
+ */
1451
+ async uploadResumable(path, fileBody, options = {}) {
1452
+ const authError = this._checkAuth();
1453
+ if (authError) return authError;
1454
+
1455
+ const totalSize = fileBody.size;
1456
+ const contentType = options.contentType || (fileBody instanceof File ? fileBody.type : 'application/octet-stream') || 'application/octet-stream';
1457
+ const partSize = options.partSize || DEFAULT_UPLOAD_PART_SIZE;
1458
+ const onProgress = options.onProgress;
1459
+
1460
+ try {
1461
+ const { data: session, error: sessionError } = await this.createUploadSession(path, {
1462
+ totalSize,
1463
+ contentType,
1464
+ partSize
1465
+ });
1466
+
1467
+ if (sessionError) {
1468
+ return { data: null, error: sessionError };
1469
+ }
1470
+
1471
+ const sessionId = session.session_id;
1472
+ const totalParts = session.total_parts;
1473
+ const actualPartSize = session.part_size;
1474
+
1475
+ let uploaded = 0;
1476
+ for (let partNumber = 1; partNumber <= totalParts; partNumber++) {
1477
+ const start = (partNumber - 1) * actualPartSize;
1478
+ const end = Math.min(start + actualPartSize, totalSize);
1479
+ const partData = fileBody.slice(start, end);
1480
+
1481
+ const { error: partError } = await this.uploadPart(path, sessionId, partNumber, partData);
1482
+
1483
+ if (partError) {
1484
+ const { error: abortError } = await this.abortUploadSession(path, sessionId);
1485
+ if (abortError) {
1486
+ console.warn(`[Storage] Failed to abort upload session ${sessionId}:`, abortError.message);
1487
+ }
1488
+ return { data: null, error: partError };
1489
+ }
1490
+
1491
+ uploaded = end;
1492
+ if (onProgress) {
1493
+ onProgress(uploaded, totalSize);
1494
+ }
1495
+ }
1496
+
1497
+ return this.completeUploadSession(path, sessionId);
1498
+ } catch (error) {
1499
+ return { data: null, error: error instanceof Error ? error : new Error('Resumable upload failed') };
1500
+ }
1501
+ }
1502
+ }
1503
+
1504
+ // ============================================================================
1505
+ // Realtime Import Note
1506
+ // ============================================================================
1507
+
1508
+ // Realtime is available via separate import: import { VolcanoRealtime } from '@volcano.dev/sdk/realtime'
1509
+ // This improves tree-shaking - centrifuge (~5.5MB) is only loaded when realtime is used
1510
+ //
1511
+ // To use realtime:
1512
+ // 1. Install centrifuge: npm install centrifuge
1513
+ // 2. Import directly: import { VolcanoRealtime } from '@volcano.dev/sdk/realtime'
1514
+
1515
+ /**
1516
+ * Lazy-load the realtime module
1517
+ * @returns {Promise<{VolcanoRealtime: any, RealtimeChannel: any}>}
1518
+ */
1519
+ async function loadRealtime() {
1520
+ const module = await Promise.resolve().then(function () { return realtime; });
1521
+ return {
1522
+ VolcanoRealtime: module.VolcanoRealtime,
1523
+ RealtimeChannel: module.RealtimeChannel
1524
+ };
1525
+ }
1526
+
1527
+ // ============================================================================
1528
+ // Exports
1529
+ // ============================================================================
1530
+
1531
+ // Browser global exports
1532
+ if (typeof window !== 'undefined') {
1533
+ window.VolcanoAuth = VolcanoAuth;
1534
+ window.QueryBuilder = QueryBuilder;
1535
+ window.StorageFileApi = StorageFileApi;
1536
+ window.isBrowser = isBrowser;
1537
+ window.loadRealtime = loadRealtime;
1538
+ }
1539
+
1540
+ // CommonJS exports
1541
+ if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
1542
+ module.exports = VolcanoAuth;
1543
+ module.exports.VolcanoAuth = VolcanoAuth;
1544
+ module.exports.default = VolcanoAuth;
1545
+ module.exports.QueryBuilder = QueryBuilder;
1546
+ module.exports.StorageFileApi = StorageFileApi;
1547
+ module.exports.isBrowser = isBrowser;
1548
+ module.exports.loadRealtime = loadRealtime;
1549
+ }
1550
+
1551
+ // AMD exports
1552
+ if (typeof define === 'function' && define.amd) {
1553
+ define([], function() {
1554
+ return VolcanoAuth;
1555
+ });
1556
+ }
1557
+
1558
+ /**
1559
+ * Volcano Realtime SDK - WebSocket client for real-time messaging
1560
+ *
1561
+ * This module provides real-time capabilities including:
1562
+ * - Broadcast: Pub/sub messaging between clients
1563
+ * - Presence: Track online users and their state
1564
+ * - Postgres Changes: Subscribe to database INSERT/UPDATE/DELETE events
1565
+ *
1566
+ * @example
1567
+ * ```javascript
1568
+ * import { VolcanoRealtime } from '@volcano.dev/sdk/realtime';
1569
+ *
1570
+ * const realtime = new VolcanoRealtime({
1571
+ * apiUrl: 'https://api.yourapp.com',
1572
+ * anonKey: 'your-anon-key',
1573
+ * accessToken: 'your-access-token'
1574
+ * });
1575
+ *
1576
+ * // Connect to realtime server
1577
+ * await realtime.connect();
1578
+ *
1579
+ * // Subscribe to a broadcast channel
1580
+ * const channel = realtime.channel('chat-room');
1581
+ * channel.on('message', (payload) => console.log('New message:', payload));
1582
+ * await channel.subscribe();
1583
+ *
1584
+ * // Send a message
1585
+ * channel.send({ text: 'Hello, world!' });
1586
+ *
1587
+ * // Subscribe to database changes
1588
+ * const dbChannel = realtime.channel('public:messages');
1589
+ * dbChannel.onPostgresChanges('*', 'public', 'messages', (payload) => {
1590
+ * console.log('Database change:', payload);
1591
+ * });
1592
+ * await dbChannel.subscribe();
1593
+ *
1594
+ * // Track presence
1595
+ * const presenceChannel = realtime.channel('lobby', { type: 'presence' });
1596
+ * presenceChannel.onPresenceSync((state) => {
1597
+ * console.log('Online users:', Object.keys(state));
1598
+ * });
1599
+ * await presenceChannel.subscribe();
1600
+ * presenceChannel.track({ status: 'online' });
1601
+ * ```
1602
+ */
1603
+
1604
+ // Centrifuge client - dynamically imported
1605
+ let Centrifuge = null;
1606
+
1607
+ /**
1608
+ * Dynamically imports the Centrifuge client
1609
+ */
1610
+ async function loadCentrifuge() {
1611
+ if (Centrifuge) return Centrifuge;
1612
+
1613
+ try {
1614
+ // Try ES module import
1615
+ const module = await import('centrifuge');
1616
+ Centrifuge = module.Centrifuge || module.default;
1617
+ return Centrifuge;
1618
+ } catch {
1619
+ throw new Error(
1620
+ 'Centrifuge client not found. Please install it: npm install centrifuge'
1621
+ );
1622
+ }
1623
+ }
1624
+
1625
+ // Load WebSocket for Node.js environments
1626
+ let WebSocketImpl = null;
1627
+ async function loadWebSocket() {
1628
+ if (WebSocketImpl) return WebSocketImpl;
1629
+
1630
+ // Check if we're in a browser environment
1631
+ if (typeof window !== 'undefined' && window.WebSocket) {
1632
+ WebSocketImpl = window.WebSocket;
1633
+ return WebSocketImpl;
1634
+ }
1635
+
1636
+ // Node.js environment - try to load ws package
1637
+ try {
1638
+ const ws = await import('ws');
1639
+ WebSocketImpl = ws.default || ws.WebSocket || ws;
1640
+ return WebSocketImpl;
1641
+ } catch {
1642
+ throw new Error(
1643
+ 'WebSocket implementation not found. In Node.js, please install: npm install ws'
1644
+ );
1645
+ }
1646
+ }
1647
+
1648
+ /**
1649
+ * VolcanoRealtime - Main realtime client
1650
+ *
1651
+ * Channel names use simple format: type:name (e.g., "broadcast:chat")
1652
+ * The server automatically handles project isolation - clients never
1653
+ * need to know about project IDs.
1654
+ *
1655
+ * Authentication options:
1656
+ * 1. User token: anonKey (required) + accessToken (user JWT)
1657
+ * 2. Service key: anonKey (optional) + accessToken (service role key)
1658
+ */
1659
+ class VolcanoRealtime {
1660
+ /**
1661
+ * Create a new VolcanoRealtime client
1662
+ * @param {Object} config - Configuration options
1663
+ * @param {string} config.apiUrl - Volcano API URL
1664
+ * @param {string} [config.anonKey] - Anon key (required for user tokens, optional for service keys)
1665
+ * @param {string} config.accessToken - Access token (user JWT) or service role key (sk-...)
1666
+ * @param {Function} [config.getToken] - Function to get/refresh token
1667
+ * @param {Object} [config.volcanoClient] - VolcanoAuth client for auto-fetching lightweight notifications
1668
+ * @param {string} [config.databaseName] - Database name for auto-fetch queries
1669
+ * @param {Object} [config.fetchConfig] - Configuration for auto-fetch behavior
1670
+ */
1671
+ constructor(config) {
1672
+ if (!config.apiUrl) throw new Error('apiUrl is required');
1673
+ // anonKey is optional for service role keys (they contain project ID)
1674
+ // But we need either anonKey or accessToken
1675
+ if (config.anonKey === undefined) throw new Error('anonKey is required');
1676
+
1677
+ this.apiUrl = config.apiUrl.replace(/\/$/, ''); // Remove trailing slash
1678
+ this.anonKey = config.anonKey || ''; // Allow empty string for service keys
1679
+ this.accessToken = config.accessToken;
1680
+ this.getToken = config.getToken;
1681
+
1682
+ this._client = null;
1683
+ this._channels = new Map();
1684
+ this._connected = false;
1685
+ this._connectionPromise = null;
1686
+
1687
+ // Callbacks
1688
+ this._onConnect = [];
1689
+ this._onDisconnect = [];
1690
+ this._onError = [];
1691
+
1692
+ // Auto-fetch support (Phase 3)
1693
+ this._volcanoClient = config.volcanoClient || null;
1694
+ this._fetchConfig = {
1695
+ batchWindowMs: config.fetchConfig?.batchWindowMs || 20,
1696
+ maxBatchSize: config.fetchConfig?.maxBatchSize || 50,
1697
+ enabled: config.fetchConfig?.enabled !== false,
1698
+ };
1699
+
1700
+ // Database name for auto-fetch queries (optional)
1701
+ this._databaseName = config.databaseName || null;
1702
+ }
1703
+
1704
+ /**
1705
+ * Set the VolcanoAuth client for auto-fetching
1706
+ * @param {Object} volcanoClient - VolcanoAuth client instance
1707
+ */
1708
+ setVolcanoClient(volcanoClient) {
1709
+ this._volcanoClient = volcanoClient;
1710
+ }
1711
+
1712
+ /**
1713
+ * Get the configured VolcanoAuth client
1714
+ * @returns {Object|null} The VolcanoAuth client or null
1715
+ */
1716
+ getVolcanoClient() {
1717
+ return this._volcanoClient;
1718
+ }
1719
+
1720
+ /**
1721
+ * Get the fetch configuration
1722
+ * @returns {Object} The fetch configuration
1723
+ */
1724
+ getFetchConfig() {
1725
+ return { ...this._fetchConfig };
1726
+ }
1727
+
1728
+ /**
1729
+ * Set the database name for auto-fetch queries
1730
+ * @param {string} databaseName
1731
+ */
1732
+ setDatabaseName(databaseName) {
1733
+ this._databaseName = databaseName;
1734
+ }
1735
+
1736
+ /**
1737
+ * Get the configured database name
1738
+ * @returns {string|null}
1739
+ */
1740
+ getDatabaseName() {
1741
+ return this._databaseName;
1742
+ }
1743
+
1744
+
1745
+ /**
1746
+ * Get the WebSocket URL for realtime connections
1747
+ */
1748
+ get wsUrl() {
1749
+ const url = new URL(this.apiUrl);
1750
+ const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
1751
+ return `${protocol}//${url.host}/realtime/v1/websocket`;
1752
+ }
1753
+
1754
+ /**
1755
+ * Connect to the realtime server
1756
+ */
1757
+ async connect() {
1758
+ if (this._connected) return;
1759
+ if (this._connectionPromise) return this._connectionPromise;
1760
+
1761
+ this._connectionPromise = this._doConnect();
1762
+ try {
1763
+ await this._connectionPromise;
1764
+ } finally {
1765
+ this._connectionPromise = null;
1766
+ }
1767
+ }
1768
+
1769
+ async _doConnect() {
1770
+ const CentrifugeClient = await loadCentrifuge();
1771
+ const WebSocket = await loadWebSocket();
1772
+
1773
+ const wsUrl = `${this.wsUrl}?apikey=${encodeURIComponent(this.anonKey)}`;
1774
+
1775
+ this._client = new CentrifugeClient(wsUrl, {
1776
+ token: this.accessToken,
1777
+ getToken: this.getToken ? async () => {
1778
+ const token = await this.getToken();
1779
+ this.accessToken = token;
1780
+ return token;
1781
+ } : undefined,
1782
+ debug: false,
1783
+ websocket: WebSocket,
1784
+ });
1785
+
1786
+ // Set up event handlers (store references for cleanup)
1787
+ this._clientHandlers = {
1788
+ connected: (ctx) => {
1789
+ this._connected = true;
1790
+ this._onConnect.forEach(cb => cb(ctx));
1791
+ },
1792
+ disconnected: (ctx) => {
1793
+ this._connected = false;
1794
+ this._onDisconnect.forEach(cb => cb(ctx));
1795
+ },
1796
+ error: (ctx) => {
1797
+ this._onError.forEach(cb => cb(ctx));
1798
+ },
1799
+ publication: (ctx) => {
1800
+ this._handleServerPublication(ctx);
1801
+ },
1802
+ join: (ctx) => {
1803
+ this._handleServerJoin(ctx);
1804
+ },
1805
+ leave: (ctx) => {
1806
+ this._handleServerLeave(ctx);
1807
+ },
1808
+ subscribed: (ctx) => {
1809
+ this._handleServerSubscribed(ctx);
1810
+ },
1811
+ };
1812
+
1813
+ this._client.on('connected', this._clientHandlers.connected);
1814
+ this._client.on('disconnected', this._clientHandlers.disconnected);
1815
+ this._client.on('error', this._clientHandlers.error);
1816
+ this._client.on('publication', this._clientHandlers.publication);
1817
+ this._client.on('join', this._clientHandlers.join);
1818
+ this._client.on('leave', this._clientHandlers.leave);
1819
+ this._client.on('subscribed', this._clientHandlers.subscribed);
1820
+
1821
+ // Connect and wait for connected event
1822
+ return new Promise((resolve, reject) => {
1823
+ const timeout = setTimeout(() => {
1824
+ reject(new Error('Connection timeout'));
1825
+ }, 10000);
1826
+
1827
+ const onConnected = () => {
1828
+ clearTimeout(timeout);
1829
+ this._client.off('connected', onConnected);
1830
+ this._client.off('error', onError);
1831
+ resolve();
1832
+ };
1833
+
1834
+ const onError = (ctx) => {
1835
+ clearTimeout(timeout);
1836
+ this._client.off('connected', onConnected);
1837
+ this._client.off('error', onError);
1838
+ reject(new Error(ctx.error?.message || 'Connection failed'));
1839
+ };
1840
+
1841
+ this._client.on('connected', onConnected);
1842
+ this._client.on('error', onError);
1843
+ this._client.connect();
1844
+ });
1845
+ }
1846
+
1847
+ /**
1848
+ * Disconnect from the realtime server
1849
+ */
1850
+ disconnect() {
1851
+ // Unsubscribe all channels first to clean up their timers
1852
+ for (const channel of this._channels.values()) {
1853
+ try {
1854
+ channel.unsubscribe();
1855
+ } catch {
1856
+ // Ignore errors during cleanup
1857
+ }
1858
+ }
1859
+ this._channels.clear();
1860
+
1861
+ if (this._client) {
1862
+ // Remove event handlers first to prevent memory leaks
1863
+ if (this._clientHandlers) {
1864
+ this._client.off('connected', this._clientHandlers.connected);
1865
+ this._client.off('disconnected', this._clientHandlers.disconnected);
1866
+ this._client.off('error', this._clientHandlers.error);
1867
+ this._client.off('publication', this._clientHandlers.publication);
1868
+ this._client.off('join', this._clientHandlers.join);
1869
+ this._client.off('leave', this._clientHandlers.leave);
1870
+ this._client.off('subscribed', this._clientHandlers.subscribed);
1871
+ this._clientHandlers = null;
1872
+ }
1873
+
1874
+ // Manually trigger disconnect callbacks
1875
+ this._onDisconnect.forEach(cb => cb({ reason: 'manual' }));
1876
+
1877
+ // Disconnect the client
1878
+ this._client.disconnect();
1879
+ this._client = null;
1880
+ this._connected = false;
1881
+ }
1882
+ }
1883
+
1884
+ /**
1885
+ * Check if connected to the realtime server
1886
+ */
1887
+ isConnected() {
1888
+ return this._connected;
1889
+ }
1890
+
1891
+ /**
1892
+ * Create or get a channel
1893
+ * @param {string} name - Channel name
1894
+ * @param {Object} [options] - Channel options
1895
+ * @param {string} [options.type='broadcast'] - Channel type: 'broadcast', 'presence', 'postgres'
1896
+ * @param {boolean} [options.autoFetch=true] - Enable auto-fetch for lightweight notifications
1897
+ * @param {number} [options.fetchBatchWindowMs] - Batch window for fetch requests
1898
+ * @param {number} [options.fetchMaxBatchSize] - Max batch size for fetch requests
1899
+ */
1900
+ channel(name, options = {}) {
1901
+ const type = options.type || 'broadcast';
1902
+ const fullName = this._formatChannelName(name, type);
1903
+
1904
+ if (this._channels.has(fullName)) {
1905
+ return this._channels.get(fullName);
1906
+ }
1907
+
1908
+ const channel = new RealtimeChannel(this, fullName, type, options);
1909
+ this._channels.set(fullName, channel);
1910
+ return channel;
1911
+ }
1912
+
1913
+ /**
1914
+ * Format channel name for subscription
1915
+ * Format: type:name
1916
+ *
1917
+ * The server automatically adds the project ID prefix based on
1918
+ * the authenticated connection. Clients never need to know about project IDs.
1919
+ */
1920
+ _formatChannelName(name, type) {
1921
+ return `${type}:${name}`;
1922
+ }
1923
+
1924
+ /**
1925
+ * Handle publications from server-side subscriptions
1926
+ * The server uses project-prefixed channels: "projectId:type:name"
1927
+ * We extract the type:name portion and route to the SDK channel
1928
+ */
1929
+ _handleServerPublication(ctx) {
1930
+ const serverChannel = ctx.channel;
1931
+
1932
+ // Server channel format: projectId:type:name
1933
+ // We need to extract type:name to match our SDK channel
1934
+ const parts = serverChannel.split(':');
1935
+ if (parts.length < 3) {
1936
+ // Not a valid server channel format, ignore
1937
+ return;
1938
+ }
1939
+
1940
+ // Skip projectId, reconstruct type:name
1941
+ const sdkChannel = parts.slice(1).join(':');
1942
+
1943
+ // Find the SDK channel and deliver the message
1944
+ const channel = this._channels.get(sdkChannel);
1945
+ if (channel) {
1946
+ channel._handlePublication(ctx);
1947
+ }
1948
+ }
1949
+
1950
+ /**
1951
+ * Handle join events from server-side subscriptions
1952
+ */
1953
+ _handleServerJoin(ctx) {
1954
+ const serverChannel = ctx.channel;
1955
+ const parts = serverChannel.split(':');
1956
+ if (parts.length < 3) return;
1957
+
1958
+ const sdkChannel = parts.slice(1).join(':');
1959
+ const channel = this._channels.get(sdkChannel);
1960
+ if (channel && channel._type === 'presence') {
1961
+ // Update presence state
1962
+ if (ctx.info) {
1963
+ channel._presenceState[ctx.info.client] = ctx.info;
1964
+ }
1965
+ channel._triggerPresenceSync();
1966
+ channel._triggerEvent('join', ctx.info);
1967
+ }
1968
+ }
1969
+
1970
+ /**
1971
+ * Handle leave events from server-side subscriptions
1972
+ */
1973
+ _handleServerLeave(ctx) {
1974
+ const serverChannel = ctx.channel;
1975
+ const parts = serverChannel.split(':');
1976
+ if (parts.length < 3) return;
1977
+
1978
+ const sdkChannel = parts.slice(1).join(':');
1979
+ const channel = this._channels.get(sdkChannel);
1980
+ if (channel && channel._type === 'presence') {
1981
+ // Update presence state
1982
+ if (ctx.info) {
1983
+ delete channel._presenceState[ctx.info.client];
1984
+ }
1985
+ channel._triggerPresenceSync();
1986
+ channel._triggerEvent('leave', ctx.info);
1987
+ }
1988
+ }
1989
+
1990
+ /**
1991
+ * Handle subscribed events - includes initial presence state
1992
+ */
1993
+ _handleServerSubscribed(ctx) {
1994
+ const serverChannel = ctx.channel;
1995
+ const parts = serverChannel.split(':');
1996
+ if (parts.length < 3) return;
1997
+
1998
+ const sdkChannel = parts.slice(1).join(':');
1999
+ const channel = this._channels.get(sdkChannel);
2000
+
2001
+ // For presence channels, populate initial state from subscribe response
2002
+ if (channel && channel._type === 'presence' && ctx.data) {
2003
+ // data contains initial presence information
2004
+ if (ctx.data.presence) {
2005
+ channel._presenceState = {};
2006
+ for (const [clientId, info] of Object.entries(ctx.data.presence)) {
2007
+ channel._presenceState[clientId] = info;
2008
+ }
2009
+ channel._triggerPresenceSync();
2010
+ }
2011
+ }
2012
+ }
2013
+
2014
+ /**
2015
+ * Get the underlying Centrifuge client
2016
+ */
2017
+ getClient() {
2018
+ return this._client;
2019
+ }
2020
+
2021
+ /**
2022
+ * Register callback for connection events
2023
+ */
2024
+ onConnect(callback) {
2025
+ this._onConnect.push(callback);
2026
+ return () => {
2027
+ this._onConnect = this._onConnect.filter(cb => cb !== callback);
2028
+ };
2029
+ }
2030
+
2031
+ /**
2032
+ * Register callback for disconnection events
2033
+ */
2034
+ onDisconnect(callback) {
2035
+ this._onDisconnect.push(callback);
2036
+ return () => {
2037
+ this._onDisconnect = this._onDisconnect.filter(cb => cb !== callback);
2038
+ };
2039
+ }
2040
+
2041
+ /**
2042
+ * Register callback for error events
2043
+ */
2044
+ onError(callback) {
2045
+ this._onError.push(callback);
2046
+ return () => {
2047
+ this._onError = this._onError.filter(cb => cb !== callback);
2048
+ };
2049
+ }
2050
+
2051
+ /**
2052
+ * Remove a specific channel
2053
+ * @param {string} name - Channel name
2054
+ * @param {string} [type='broadcast'] - Channel type
2055
+ */
2056
+ removeChannel(name, type = 'broadcast') {
2057
+ const fullName = this._formatChannelName(name, type);
2058
+ const channel = this._channels.get(fullName);
2059
+ if (channel) {
2060
+ channel.unsubscribe();
2061
+ this._channels.delete(fullName);
2062
+ }
2063
+ }
2064
+
2065
+ /**
2066
+ * Remove all channels and listeners
2067
+ */
2068
+ removeAllChannels() {
2069
+ for (const channel of this._channels.values()) {
2070
+ channel.unsubscribe();
2071
+ }
2072
+ this._channels.clear();
2073
+ }
2074
+ }
2075
+
2076
+ /**
2077
+ * RealtimeChannel - Represents a subscription to a realtime channel
2078
+ */
2079
+ class RealtimeChannel {
2080
+ constructor(realtime, name, type, options) {
2081
+ this._realtime = realtime;
2082
+ this._name = name;
2083
+ this._type = type;
2084
+ this._options = options;
2085
+ this._subscription = null;
2086
+ this._callbacks = new Map();
2087
+ this._presenceState = {};
2088
+
2089
+ // Auto-fetch support (Phase 3)
2090
+ const parentFetchConfig = realtime.getFetchConfig();
2091
+ this._fetchConfig = {
2092
+ batchWindowMs: options.fetchBatchWindowMs || parentFetchConfig.batchWindowMs,
2093
+ maxBatchSize: options.fetchMaxBatchSize || parentFetchConfig.maxBatchSize,
2094
+ enabled: options.autoFetch !== false && parentFetchConfig.enabled,
2095
+ };
2096
+ this._pendingFetches = new Map(); // table -> { ids: Map<id, {resolve, reject}>, timer }
2097
+
2098
+ // Event handler references for cleanup
2099
+ this._eventHandlers = {};
2100
+ this._presenceTimeoutId = null;
2101
+ }
2102
+
2103
+ /**
2104
+ * Get channel name
2105
+ */
2106
+ get name() {
2107
+ return this._name;
2108
+ }
2109
+
2110
+ /**
2111
+ * Subscribe to the channel
2112
+ */
2113
+ async subscribe() {
2114
+ if (this._subscription) return;
2115
+
2116
+ const client = this._realtime.getClient();
2117
+ if (!client) {
2118
+ throw new Error('Not connected to realtime server');
2119
+ }
2120
+
2121
+ this._subscription = client.newSubscription(this._name, {
2122
+ // Enable presence for presence channels
2123
+ presence: this._type === 'presence',
2124
+ joinLeave: this._type === 'presence',
2125
+ // Enable recovery for all channels
2126
+ recover: true,
2127
+ });
2128
+
2129
+ // Set up message handler (store reference for cleanup)
2130
+ this._eventHandlers.publication = (ctx) => {
2131
+ const event = ctx.data?.event || 'message';
2132
+ const callbacks = this._callbacks.get(event) || [];
2133
+ callbacks.forEach(cb => cb(ctx.data, ctx));
2134
+
2135
+ // Also trigger wildcard listeners
2136
+ const wildcardCallbacks = this._callbacks.get('*') || [];
2137
+ wildcardCallbacks.forEach(cb => cb(ctx.data, ctx));
2138
+ };
2139
+ this._subscription.on('publication', this._eventHandlers.publication);
2140
+
2141
+ // Set up presence handlers for presence channels
2142
+ if (this._type === 'presence') {
2143
+ this._eventHandlers.presence = (ctx) => {
2144
+ this._updatePresenceState(ctx);
2145
+ this._triggerPresenceSync();
2146
+ };
2147
+ this._subscription.on('presence', this._eventHandlers.presence);
2148
+
2149
+ this._eventHandlers.join = (ctx) => {
2150
+ this._presenceState[ctx.info.client] = ctx.info.data;
2151
+ this._triggerPresenceSync();
2152
+ this._triggerEvent('join', ctx.info);
2153
+ };
2154
+ this._subscription.on('join', this._eventHandlers.join);
2155
+
2156
+ this._eventHandlers.leave = (ctx) => {
2157
+ delete this._presenceState[ctx.info.client];
2158
+ this._triggerPresenceSync();
2159
+ this._triggerEvent('leave', ctx.info);
2160
+ };
2161
+ this._subscription.on('leave', this._eventHandlers.leave);
2162
+
2163
+ // After subscribing, immediately fetch current presence for late joiners
2164
+ // For server-side subscriptions, use client.presence() not subscription.presence()
2165
+ this._eventHandlers.subscribed = async () => {
2166
+ // Small delay to ensure subscription is fully active
2167
+ this._presenceTimeoutId = setTimeout(async () => {
2168
+ this._presenceTimeoutId = null;
2169
+ try {
2170
+ const client = this._realtime.getClient();
2171
+ if (client && this._subscription) {
2172
+ // Use client-level presence() for server-side subscriptions
2173
+ const presence = await client.presence(this._name);
2174
+
2175
+ // Centrifuge returns presence data in `clients` field
2176
+ if (presence && presence.clients) {
2177
+ this._presenceState = {};
2178
+ for (const [clientId, info] of Object.entries(presence.clients)) {
2179
+ this._presenceState[clientId] = info;
2180
+ }
2181
+ this._triggerPresenceSync();
2182
+ }
2183
+ }
2184
+ } catch (err) {
2185
+ // Ignore errors - presence might not be available yet
2186
+ }
2187
+ }, 150);
2188
+ };
2189
+ this._subscription.on('subscribed', this._eventHandlers.subscribed);
2190
+ }
2191
+
2192
+ await this._subscription.subscribe();
2193
+ }
2194
+
2195
+ /**
2196
+ * Unsubscribe from the channel
2197
+ */
2198
+ unsubscribe() {
2199
+ // Cancel pending presence fetch timeout
2200
+ if (this._presenceTimeoutId) {
2201
+ clearTimeout(this._presenceTimeoutId);
2202
+ this._presenceTimeoutId = null;
2203
+ }
2204
+
2205
+ // Clear all pending fetch timers to prevent memory leaks
2206
+ if (this._pendingFetches) {
2207
+ for (const batch of this._pendingFetches.values()) {
2208
+ if (batch.timer) {
2209
+ clearTimeout(batch.timer);
2210
+ }
2211
+ // Reject any pending promises
2212
+ for (const { reject } of batch.ids.values()) {
2213
+ reject(new Error('Channel unsubscribed'));
2214
+ }
2215
+ }
2216
+ this._pendingFetches.clear();
2217
+ }
2218
+
2219
+ if (this._subscription) {
2220
+ // Remove event listeners before unsubscribing
2221
+ for (const [event, handler] of Object.entries(this._eventHandlers)) {
2222
+ try {
2223
+ this._subscription.off(event, handler);
2224
+ } catch {
2225
+ // Ignore errors if listener already removed
2226
+ }
2227
+ }
2228
+ this._eventHandlers = {};
2229
+
2230
+ this._subscription.unsubscribe();
2231
+ // Also remove from Centrifuge client registry to allow re-subscription
2232
+ const client = this._realtime.getClient();
2233
+ if (client) {
2234
+ try {
2235
+ client.removeSubscription(this._subscription);
2236
+ } catch {
2237
+ // Ignore errors if subscription already removed
2238
+ }
2239
+ }
2240
+ this._subscription = null;
2241
+ }
2242
+ this._callbacks.clear();
2243
+ this._presenceState = {};
2244
+ }
2245
+
2246
+ /**
2247
+ * Handle publication from server-side subscription
2248
+ * Called by VolcanoRealtime when a message arrives on the internal channel
2249
+ */
2250
+ _handlePublication(ctx) {
2251
+ const data = ctx.data;
2252
+
2253
+ // Check if this is a lightweight notification (Phase 3)
2254
+ if (data?.mode === 'lightweight') {
2255
+ this._handleLightweightNotification(data, ctx);
2256
+ return;
2257
+ }
2258
+
2259
+ // Full payload - deliver immediately
2260
+ this._deliverPayload(data, ctx);
2261
+ }
2262
+
2263
+ /**
2264
+ * Handle a lightweight notification by auto-fetching the record data
2265
+ * @param {Object} data - Lightweight notification data
2266
+ * @param {Object} ctx - Publication context
2267
+ */
2268
+ async _handleLightweightNotification(data, ctx) {
2269
+ const volcanoClient = this._realtime.getVolcanoClient();
2270
+
2271
+ // DELETE notifications may include old_record, deliver immediately
2272
+ if (data.type === 'DELETE') {
2273
+ // Convert lightweight DELETE to full format for backward compatibility
2274
+ const oldRecord = data.old_record !== undefined
2275
+ ? data.old_record
2276
+ : (data.id !== undefined ? { id: data.id } : undefined);
2277
+ const fullPayload = {
2278
+ type: data.type,
2279
+ schema: data.schema,
2280
+ table: data.table,
2281
+ old_record: oldRecord,
2282
+ id: data.id,
2283
+ timestamp: data.timestamp,
2284
+ };
2285
+ this._deliverPayload(fullPayload, ctx);
2286
+ return;
2287
+ }
2288
+
2289
+ // If no volcanoClient or auto-fetch disabled, deliver lightweight as-is
2290
+ if (!volcanoClient || !this._fetchConfig.enabled) {
2291
+ this._deliverPayload(data, ctx);
2292
+ return;
2293
+ }
2294
+
2295
+ // Auto-fetch the record for INSERT/UPDATE
2296
+ try {
2297
+ const record = await this._fetchRow(data.schema, data.table, data.id);
2298
+
2299
+ // Convert to full payload format for backward compatibility
2300
+ const fullPayload = {
2301
+ type: data.type,
2302
+ schema: data.schema,
2303
+ table: data.table,
2304
+ record: record,
2305
+ timestamp: data.timestamp,
2306
+ };
2307
+
2308
+ this._deliverPayload(fullPayload, ctx);
2309
+ } catch (err) {
2310
+ // On fetch error, still deliver the lightweight notification
2311
+ // so the client knows something changed, even if we couldn't get the data
2312
+ console.warn(`[Realtime] Failed to fetch record for ${data.schema}.${data.table}:${data.id}:`, err.message);
2313
+ this._deliverPayload(data, ctx);
2314
+ }
2315
+ }
2316
+
2317
+ /**
2318
+ * Fetch a row from the database, batching requests for efficiency
2319
+ * @param {string} schema - Schema name
2320
+ * @param {string} table - Table name
2321
+ * @param {*} id - Primary key value
2322
+ * @returns {Promise<Object>} The fetched record
2323
+ */
2324
+ _fetchRow(schema, table, id) {
2325
+ const tableKey = `${schema}.${table}`;
2326
+
2327
+ return new Promise((resolve, reject) => {
2328
+ // Get or create pending batch for this table
2329
+ if (!this._pendingFetches.has(tableKey)) {
2330
+ this._pendingFetches.set(tableKey, {
2331
+ ids: new Map(),
2332
+ timer: null,
2333
+ schema: schema,
2334
+ table: table,
2335
+ });
2336
+ }
2337
+
2338
+ const batch = this._pendingFetches.get(tableKey);
2339
+
2340
+ // Add this ID to the batch
2341
+ batch.ids.set(String(id), { resolve, reject });
2342
+
2343
+ // Check if we should flush due to size
2344
+ if (batch.ids.size >= this._fetchConfig.maxBatchSize) {
2345
+ this._flushFetch(schema, table);
2346
+ return;
2347
+ }
2348
+
2349
+ // Set timer for batch window if not already set
2350
+ if (!batch.timer) {
2351
+ batch.timer = setTimeout(() => {
2352
+ this._flushFetch(schema, table);
2353
+ }, this._fetchConfig.batchWindowMs);
2354
+ }
2355
+ });
2356
+ }
2357
+
2358
+ /**
2359
+ * Flush pending fetch requests for a table
2360
+ * @param {string} schema - Schema name
2361
+ * @param {string} table - Table name
2362
+ */
2363
+ async _flushFetch(schema, table) {
2364
+ const tableKey = `${schema}.${table}`;
2365
+ const batch = this._pendingFetches.get(tableKey);
2366
+
2367
+ if (!batch || batch.ids.size === 0) {
2368
+ return;
2369
+ }
2370
+
2371
+ // Clear timer and remove from pending
2372
+ if (batch.timer) {
2373
+ clearTimeout(batch.timer);
2374
+ }
2375
+ this._pendingFetches.delete(tableKey);
2376
+
2377
+ // Get all IDs to fetch
2378
+ const idsToFetch = Array.from(batch.ids.keys());
2379
+ const callbacks = new Map(batch.ids);
2380
+
2381
+ try {
2382
+ const volcanoClient = this._realtime.getVolcanoClient();
2383
+
2384
+ if (!volcanoClient?.from || typeof volcanoClient.from !== 'function') {
2385
+ throw new Error('volcanoClient.from not available');
2386
+ }
2387
+
2388
+ const databaseName = this._realtime.getDatabaseName?.() || volcanoClient._currentDatabaseName || null;
2389
+ let dbClient = volcanoClient;
2390
+ if (databaseName) {
2391
+ if (typeof volcanoClient.database !== 'function') {
2392
+ throw new Error('volcanoClient.database not available');
2393
+ }
2394
+ dbClient = volcanoClient.database(databaseName);
2395
+ } else if (typeof volcanoClient.database === 'function') {
2396
+ throw new Error('Database name not set. Call volcanoClient.database(name) or pass databaseName to VolcanoRealtime.');
2397
+ }
2398
+
2399
+ const tableName = schema && schema !== 'public' ? `${schema}.${table}` : table;
2400
+
2401
+ // Fetch all records in a single query using IN clause
2402
+ // Assumes primary key column is 'id' - this is a common convention
2403
+ const { data, error } = await dbClient
2404
+ .from(tableName)
2405
+ .select('*')
2406
+ .in('id', idsToFetch);
2407
+
2408
+ if (error) {
2409
+ // Reject all pending callbacks
2410
+ for (const cb of callbacks.values()) {
2411
+ cb.reject(new Error(error.message || 'Database fetch failed'));
2412
+ }
2413
+ return;
2414
+ }
2415
+
2416
+ // Build a map of id -> record
2417
+ const recordMap = new Map();
2418
+ for (const record of (data || [])) {
2419
+ recordMap.set(String(record.id), record);
2420
+ }
2421
+
2422
+ // Resolve callbacks
2423
+ for (const [id, cb] of callbacks) {
2424
+ const record = recordMap.get(id);
2425
+ if (record) {
2426
+ cb.resolve(record);
2427
+ } else {
2428
+ // Record not found - could be RLS denial or row deleted
2429
+ cb.reject(new Error(`Record not found or access denied: ${table}:${id}`));
2430
+ }
2431
+ }
2432
+ } catch (err) {
2433
+ // Reject all pending callbacks on error
2434
+ for (const cb of callbacks.values()) {
2435
+ cb.reject(err);
2436
+ }
2437
+ }
2438
+ }
2439
+
2440
+ /**
2441
+ * Deliver a payload to registered callbacks
2442
+ * @param {Object} data - Payload data
2443
+ * @param {Object} ctx - Publication context
2444
+ */
2445
+ _deliverPayload(data, ctx) {
2446
+ const event = data?.event || data?.type || 'message';
2447
+ const callbacks = this._callbacks.get(event) || [];
2448
+ callbacks.forEach(cb => cb(data, ctx));
2449
+
2450
+ // Also trigger wildcard listeners
2451
+ const wildcardCallbacks = this._callbacks.get('*') || [];
2452
+ wildcardCallbacks.forEach(cb => cb(data, ctx));
2453
+ }
2454
+
2455
+ /**
2456
+ * Listen for events on the channel
2457
+ * @param {string} event - Event name or '*' for all events
2458
+ * @param {Function} callback - Callback function
2459
+ */
2460
+ on(event, callback) {
2461
+ if (!this._callbacks.has(event)) {
2462
+ this._callbacks.set(event, []);
2463
+ }
2464
+ this._callbacks.get(event).push(callback);
2465
+
2466
+ // Return unsubscribe function
2467
+ return () => {
2468
+ const callbacks = this._callbacks.get(event) || [];
2469
+ this._callbacks.set(event, callbacks.filter(cb => cb !== callback));
2470
+ };
2471
+ }
2472
+
2473
+ /**
2474
+ * Send a message to the channel (broadcast only)
2475
+ * @param {Object} data - Message data
2476
+ */
2477
+ async send(data) {
2478
+ if (this._type !== 'broadcast') {
2479
+ throw new Error('send() is only available for broadcast channels');
2480
+ }
2481
+
2482
+ if (!this._subscription) {
2483
+ throw new Error('Channel not subscribed');
2484
+ }
2485
+
2486
+ await this._subscription.publish(data);
2487
+ }
2488
+
2489
+ /**
2490
+ * Listen for database changes (postgres channels only)
2491
+ * @param {string} event - Event type: 'INSERT', 'UPDATE', 'DELETE', or '*'
2492
+ * @param {string} schema - Schema name
2493
+ * @param {string} table - Table name
2494
+ * @param {Function} callback - Callback function
2495
+ */
2496
+ onPostgresChanges(event, schema, table, callback) {
2497
+ if (this._type !== 'postgres') {
2498
+ throw new Error('onPostgresChanges() is only available for postgres channels');
2499
+ }
2500
+
2501
+ // Filter callback to only match the requested event type
2502
+ return this.on('*', (data, ctx) => {
2503
+ if (data.schema !== schema || data.table !== table) return;
2504
+ if (event !== '*' && data.type !== event) return;
2505
+ callback(data, ctx);
2506
+ });
2507
+ }
2508
+
2509
+ /**
2510
+ * Listen for presence state sync
2511
+ * @param {Function} callback - Callback with presence state
2512
+ */
2513
+ onPresenceSync(callback) {
2514
+ if (this._type !== 'presence') {
2515
+ throw new Error('onPresenceSync() is only available for presence channels');
2516
+ }
2517
+
2518
+ return this.on('presence_sync', callback);
2519
+ }
2520
+
2521
+ /**
2522
+ * Track this client's presence
2523
+ * @param {Object} state - Presence state data (optional, for client-side state tracking)
2524
+ *
2525
+ * Note: Presence data is automatically sent from the server based on your
2526
+ * user metadata (from sign-up). Custom presence data should be included
2527
+ * when creating the anonymous user.
2528
+ */
2529
+ async track(state = {}) {
2530
+ if (this._type !== 'presence') {
2531
+ throw new Error('track() is only available for presence channels');
2532
+ }
2533
+
2534
+ // Store local presence state for client-side access
2535
+ this._myPresenceState = state;
2536
+
2537
+ // Presence is automatically managed by Centrifuge based on subscription
2538
+ // The connection data (from user metadata) is what other clients see
2539
+ // Note: Custom state is stored locally for client-side access
2540
+ }
2541
+
2542
+ /**
2543
+ * Get current presence state
2544
+ */
2545
+ getPresenceState() {
2546
+ return { ...this._presenceState };
2547
+ }
2548
+
2549
+ _updatePresenceState(ctx) {
2550
+ this._presenceState = {};
2551
+ if (ctx.clients) {
2552
+ for (const [clientId, info] of Object.entries(ctx.clients)) {
2553
+ this._presenceState[clientId] = info.data;
2554
+ }
2555
+ }
2556
+ }
2557
+
2558
+ _triggerPresenceSync() {
2559
+ this._triggerEvent('presence_sync', this._presenceState);
2560
+ }
2561
+
2562
+ _triggerEvent(event, data) {
2563
+ const callbacks = this._callbacks.get(event) || [];
2564
+ callbacks.forEach(cb => cb(data));
2565
+ }
2566
+ }
2567
+
2568
+ // Export for CommonJS
2569
+ if (typeof module !== 'undefined' && module.exports) {
2570
+ module.exports = { VolcanoRealtime, RealtimeChannel };
2571
+ }
2572
+
2573
+ var realtime = /*#__PURE__*/Object.freeze({
2574
+ __proto__: null,
2575
+ RealtimeChannel: RealtimeChannel,
2576
+ VolcanoRealtime: VolcanoRealtime
2577
+ });
2578
+
2579
+ exports.QueryBuilder = QueryBuilder;
2580
+ exports.StorageFileApi = StorageFileApi;
2581
+ exports.VolcanoAuth = VolcanoAuth;
2582
+ exports.default = VolcanoAuth;
2583
+ exports.isBrowser = isBrowser;
2584
+ exports.loadRealtime = loadRealtime;
2585
+
2586
+ Object.defineProperty(exports, '__esModule', { value: true });
2587
+
2588
+ }));