@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.

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