UNPKG

supastash

Version:

Offline-first sync engine for Supabase in React Native using SQLite

262 lines (261 loc) 9.39 kB
// DEPRECATED: Use useRealtimeData instead import { getSupastashConfig } from "../../core/config"; import log, { logError } from "../logs"; import { supabaseClientErr } from "../supabaseClientErr"; class SupastashRealtimeManager { constructor() { // Realtime connection state this.connection = null; this.subscriptions = new Map(); // Active subscriptions this.isConnected = false; // True if connected to the shared channel this.connectionStatus = "disconnected"; this.reconnectAttempts = 0; this.maxReconnectAttempts = 5; this.reconnectDelay = 1000; this.subscribeDebounceTimer = null; this.disconnectDebounceTimer = null; this.statusListeners = new Set(); } static getInstance() { if (!SupastashRealtimeManager.instance) { SupastashRealtimeManager.instance = new SupastashRealtimeManager(); } return SupastashRealtimeManager.instance; } getSubscriptionKey(table, filterString) { return `${table}::${filterString ?? ""}`; } // Creates one shared realtime connection async createConnection() { const supabase = getSupastashConfig().supabaseClient; if (!supabase) { logError("[Supastash] No supabase client found", supabaseClientErr); return null; } if (this.connection) { return this.connection; } this.connection = supabase.channel("supastash:shared-realtime"); this.connectionStatus = "connecting"; this.notifyStatusListeners("connecting"); return this.connection; } // Subscribes to the shared realtime connection async subscribeConnection() { if (!this.connection || this.isConnected) return; return new Promise((resolve, reject) => { this.connection?.subscribe((status, err) => { if (err) { this.connectionStatus = "error"; this.notifyStatusListeners("error"); reject(err); return; } switch (status) { case "SUBSCRIBED": this.isConnected = true; this.connectionStatus = "connected"; this.reconnectAttempts = 0; this.notifyStatusListeners("connected"); log("[Supastash] ✅ Shared channel connected successfully"); resolve(); break; case "CLOSED": case "CHANNEL_ERROR": this.handleConnectionError(); reject(new Error(`Connection failed with status: ${status}`)); break; case "TIMED_OUT": this.handleConnectionTimeout(); reject(new Error("Connection timed out")); break; } }); }); } handleConnectionError() { this.cleanup(); this.scheduleReconnect(); } handleConnectionTimeout() { this.cleanup(); this.scheduleReconnect(); } scheduleReconnect() { if (this.reconnectAttempts >= this.maxReconnectAttempts) { this.connectionStatus = "error"; this.notifyStatusListeners("error"); return; } const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts); this.reconnectAttempts++; setTimeout(() => { if (this.subscriptions.size > 0) { this.reconnect(); } }, delay); } async reconnect() { this.cleanup(); try { await this.createConnection(); await this.resubscribeAll(); await this.subscribeConnection(); } catch (error) { this.scheduleReconnect(); } } async resubscribeAll() { if (!this.connection) return; for (const [key, subscription] of this.subscriptions) { if (subscription.isActive) { this.bindTableListener(subscription.table, subscription.filterString, key); } } } async bindTableListener(table, filterString, key) { if (!this.connection) { await this.createConnection(); } const subscription = this.subscriptions.get(key); if (!subscription) return; subscription.isActive = true; this.subscriptions.set(key, subscription); this.connection?.on("postgres_changes", { event: "*", schema: "public", table, filter: filterString }, (payload) => { try { const subscription = this.subscriptions.get(key); if (!subscription) return; const data = payload.new || payload.old; for (const handler of subscription.handlers.values()) { handler(payload.eventType, data); } } catch (err) { log(`❌ Error in handler for ${key}: ${err}`); } }); } cleanup() { if (this.connection) { this.connection.unsubscribe(); this.connection = null; } this.isConnected = false; this.connectionStatus = "disconnected"; if (this.subscribeDebounceTimer) { clearTimeout(this.subscribeDebounceTimer); this.subscribeDebounceTimer = null; } } notifyStatusListeners(status) { this.statusListeners.forEach((listener) => { try { listener(status); } catch (error) { log(`❌ Error in status listener: ${error}`); } }); } debouncedSubscribe() { if (this.subscribeDebounceTimer) clearTimeout(this.subscribeDebounceTimer); this.subscribeDebounceTimer = setTimeout(async () => { try { if (!this.connection) { await this.createConnection(); } await this.resubscribeAll(); if (!this.isConnected) { await this.subscribeConnection(); } } catch (err) { log(`❌ subscribe failed, retrying in 1s: ${err}`); setTimeout(() => this.debouncedSubscribe(), 1000); } finally { this.subscribeDebounceTimer = null; } }, 500); } debouncedDisconnect() { if (this.disconnectDebounceTimer) { clearTimeout(this.disconnectDebounceTimer); } this.disconnectDebounceTimer = setTimeout(() => { if (this.subscriptions.size === 0) { this.cleanup(); this.notifyStatusListeners("disconnected"); } this.disconnectDebounceTimer = null; }, 2000); } // Subscribes to a table subscribe(table, hookId, handler, filterString) { const key = this.getSubscriptionKey(table, filterString); if (this.disconnectDebounceTimer) { clearTimeout(this.disconnectDebounceTimer); this.disconnectDebounceTimer = null; } let subscription = this.subscriptions.get(key); if (!subscription) { subscription = { table, filterString, handlers: new Map(), isActive: false, }; this.subscriptions.set(key, subscription); } subscription.handlers.set(hookId, handler); if (!subscription.isActive) { this.bindTableListener(table, filterString, key); this.debouncedSubscribe(); } } unsubscribe(table, hookId, filterString) { const key = this.getSubscriptionKey(table, filterString); const subscription = this.subscriptions.get(key); if (!subscription) return; subscription.handlers.delete(hookId); if (subscription.handlers.size === 0) { subscription.isActive = false; this.subscriptions.delete(key); if (this.subscriptions.size === 0) { this.debouncedDisconnect(); } } } getConnectionStatus() { return this.connectionStatus; } onStatusChange(listener) { this.statusListeners.add(listener); return () => { this.statusListeners.delete(listener); }; } getActiveSubscriptions() { return this.subscriptions.size; } forceReconnect() { this.reconnectAttempts = 0; this.reconnect(); } } // Supastash realtime manager singleton instance SupastashRealtimeManager.instance = null; export const RealtimeManager = SupastashRealtimeManager.getInstance(); export function subscribeToTable(table, filterString, queueHandler, hookId = `hook_${Date.now()}_${Math.random()}`) { RealtimeManager.subscribe(table, hookId, queueHandler, filterString); } export function unsubscribeFromTable(table, filterString, queueHandler, hookId) { RealtimeManager.unsubscribe(table, hookId, filterString); }