@volcano.dev/sdk 1.2.0-nightly.22454064035.1

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