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