@volcano.dev/sdk 1.2.0-nightly.22454064035.1

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