@adventurelabs/scout-core 1.0.35 → 1.0.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,8 +54,48 @@ export default function ScoutLayout({
54
54
 
55
55
  ### Hooks
56
56
 
57
- - `useScoutDbListener` - Real-time database listening for plans, devices, and tags
57
+ - `useScoutDbListener` - Real-time database listening for plans, devices, and tags with robust disconnect handling
58
58
  - `useScoutRefresh` - Data refresh utilities
59
+ - `useConnectionStatus` - Connection status monitoring and manual reconnection controls
60
+
61
+ #### Robust Connection Features
62
+
63
+ The `useScoutDbListener` hook includes several features to handle network disconnections and connection issues:
64
+
65
+ - **Automatic Reconnection**: Automatically attempts to reconnect when the connection is lost
66
+ - **Exponential Backoff**: Uses exponential backoff with jitter to avoid overwhelming the server
67
+ - **Connection State Tracking**: Provides real-time connection status (connected, connecting, disconnected, error)
68
+ - **Error Handling**: Comprehensive error handling with detailed error messages
69
+ - **Manual Reconnection**: Allows manual reconnection attempts via the `reconnect()` function
70
+ - **Retry Limits**: Configurable maximum retry attempts to prevent infinite reconnection loops
71
+ - **Graceful Cleanup**: Proper cleanup of resources when the component unmounts
72
+
73
+ Example usage:
74
+
75
+ ```tsx
76
+ import { useConnectionStatus } from "@adventurelabs/scout-core";
77
+
78
+ function ConnectionStatus() {
79
+ const { isConnected, isConnecting, lastError, retryCount, reconnect } =
80
+ useConnectionStatus();
81
+
82
+ if (isConnecting) {
83
+ return <div>Connecting to database...</div>;
84
+ }
85
+
86
+ if (lastError) {
87
+ return (
88
+ <div>
89
+ <p>Connection error: {lastError}</p>
90
+ <p>Retry attempts: {retryCount}</p>
91
+ <button onClick={reconnect}>Reconnect</button>
92
+ </div>
93
+ );
94
+ }
95
+
96
+ return <div>Status: {isConnected ? "Connected" : "Disconnected"}</div>;
97
+ }
98
+ ```
59
99
 
60
100
  ### Store
61
101
 
@@ -1,3 +1,30 @@
1
1
  import { SupabaseClient } from "@supabase/supabase-js";
2
2
  import { Database } from "../types/supabase";
3
- export declare function useScoutDbListener(scoutSupabase: SupabaseClient<Database>): void;
3
+ declare enum ConnectionState {
4
+ DISCONNECTED = "disconnected",
5
+ CONNECTING = "connecting",
6
+ CONNECTED = "connected",
7
+ RECONNECTING = "reconnecting",
8
+ ERROR = "error"
9
+ }
10
+ /**
11
+ * Hook for listening to real-time database changes with robust disconnect handling.
12
+ *
13
+ * Features:
14
+ * - Automatic reconnection with exponential backoff
15
+ * - Connection state tracking
16
+ * - Error handling and retry logic
17
+ * - Manual reconnection capability
18
+ *
19
+ * @param scoutSupabase - The Supabase client instance
20
+ * @returns Connection status and control functions
21
+ */
22
+ export declare function useScoutDbListener(scoutSupabase: SupabaseClient<Database>): {
23
+ connectionState: ConnectionState;
24
+ lastError: string | null;
25
+ retryCount: number;
26
+ reconnect: () => void;
27
+ isConnected: boolean;
28
+ isConnecting: boolean;
29
+ };
30
+ export {};
@@ -1,188 +1,461 @@
1
1
  "use client";
2
2
  import { useAppDispatch } from "../store/hooks";
3
- import { useEffect, useRef } from "react";
3
+ import { useEffect, useRef, useState, useCallback } from "react";
4
4
  import { addDevice, addPlan, addTag, addSessionToStore, deleteDevice, deletePlan, deleteSessionFromStore, deleteTag, updateDevice, updatePlan, updateSessionInStore, updateTag, } from "../store/scout";
5
+ // Connection state enum
6
+ var ConnectionState;
7
+ (function (ConnectionState) {
8
+ ConnectionState["DISCONNECTED"] = "disconnected";
9
+ ConnectionState["CONNECTING"] = "connecting";
10
+ ConnectionState["CONNECTED"] = "connected";
11
+ ConnectionState["RECONNECTING"] = "reconnecting";
12
+ ConnectionState["ERROR"] = "error";
13
+ })(ConnectionState || (ConnectionState = {}));
14
+ // Reconnection configuration
15
+ const RECONNECTION_CONFIG = {
16
+ MAX_RETRIES: 10,
17
+ INITIAL_DELAY: 1000, // 1 second
18
+ MAX_DELAY: 30000, // 30 seconds
19
+ BACKOFF_MULTIPLIER: 2,
20
+ JITTER_FACTOR: 0.1, // 10% jitter
21
+ };
22
+ /**
23
+ * Hook for listening to real-time database changes with robust disconnect handling.
24
+ *
25
+ * Features:
26
+ * - Automatic reconnection with exponential backoff
27
+ * - Connection state tracking
28
+ * - Error handling and retry logic
29
+ * - Manual reconnection capability
30
+ *
31
+ * @param scoutSupabase - The Supabase client instance
32
+ * @returns Connection status and control functions
33
+ */
5
34
  export function useScoutDbListener(scoutSupabase) {
6
35
  const supabase = useRef(null);
7
36
  const channels = useRef([]);
8
37
  const dispatch = useAppDispatch();
9
- function handleTagInserts(payload) {
10
- console.log("[DB Listener] Tag INSERT received:", payload.new);
11
- dispatch(addTag(payload.new));
12
- }
13
- function handleTagDeletes(payload) {
14
- console.log("[DB Listener] Tag DELETE received:", payload.old);
15
- console.log("[DB Listener] Tag DELETE - payload structure:", {
16
- hasOld: !!payload.old,
17
- oldId: payload.old?.id,
18
- oldEventId: payload.old?.event_id,
19
- oldClassName: payload.old?.class_name,
20
- fullPayload: payload,
38
+ // Connection state management
39
+ const [connectionState, setConnectionState] = useState(ConnectionState.DISCONNECTED);
40
+ const [lastError, setLastError] = useState(null);
41
+ const [retryCount, setRetryCount] = useState(0);
42
+ // Reconnection management
43
+ const reconnectTimeoutRef = useRef(null);
44
+ const isInitializingRef = useRef(false);
45
+ const isDestroyedRef = useRef(false);
46
+ // Calculate exponential backoff delay with jitter
47
+ const calculateBackoffDelay = useCallback((attempt) => {
48
+ const baseDelay = Math.min(RECONNECTION_CONFIG.INITIAL_DELAY *
49
+ Math.pow(RECONNECTION_CONFIG.BACKOFF_MULTIPLIER, attempt), RECONNECTION_CONFIG.MAX_DELAY);
50
+ const jitter = baseDelay * RECONNECTION_CONFIG.JITTER_FACTOR * (Math.random() - 0.5);
51
+ return Math.max(100, baseDelay + jitter); // Minimum 100ms delay
52
+ }, []);
53
+ // Clean up all channels
54
+ const cleanupChannels = useCallback(() => {
55
+ console.log("[DB Listener] 🧹 Cleaning up channels");
56
+ channels.current.forEach((channel) => {
57
+ if (channel && supabase.current) {
58
+ try {
59
+ supabase.current.removeChannel(channel);
60
+ }
61
+ catch (error) {
62
+ console.warn("[DB Listener] Error removing channel:", error);
63
+ }
64
+ }
21
65
  });
66
+ channels.current = [];
67
+ }, []);
68
+ // Cancel any pending reconnection attempts
69
+ const cancelReconnection = useCallback(() => {
70
+ if (reconnectTimeoutRef.current) {
71
+ clearTimeout(reconnectTimeoutRef.current);
72
+ reconnectTimeoutRef.current = null;
73
+ }
74
+ }, []);
75
+ // Test database connection
76
+ const testDbConnection = useCallback(async () => {
77
+ if (!supabase.current)
78
+ return false;
79
+ try {
80
+ const { data, error } = await supabase.current
81
+ .from("tags")
82
+ .select("count")
83
+ .limit(1);
84
+ if (error) {
85
+ console.warn("[DB Listener] DB connection test failed:", error);
86
+ return false;
87
+ }
88
+ console.log("[DB Listener] ✅ DB connection test successful");
89
+ return true;
90
+ }
91
+ catch (err) {
92
+ console.error("[DB Listener] DB connection test failed:", err);
93
+ return false;
94
+ }
95
+ }, []);
96
+ // Set up realtime authentication
97
+ const setupRealtimeAuth = useCallback(async () => {
98
+ if (!supabase.current)
99
+ return false;
100
+ try {
101
+ await supabase.current.realtime.setAuth();
102
+ console.log("[DB Listener] ✅ Realtime authentication set up successfully");
103
+ return true;
104
+ }
105
+ catch (err) {
106
+ console.warn("[DB Listener] ❌ Failed to set up realtime authentication:", err);
107
+ return false;
108
+ }
109
+ }, []);
110
+ // Event handlers
111
+ const handleTagInserts = useCallback((payload) => {
112
+ console.log("[DB Listener] Tag INSERT received:", payload);
113
+ if (!payload.new) {
114
+ console.error("[DB Listener] Tag INSERT - Invalid payload, missing new data");
115
+ return;
116
+ }
117
+ dispatch(addTag(payload.new));
118
+ }, [dispatch]);
119
+ const handleTagDeletes = useCallback((payload) => {
120
+ console.log("[DB Listener] Tag DELETE received:", payload);
22
121
  if (!payload.old || !payload.old.id) {
23
122
  console.error("[DB Listener] Tag DELETE - Invalid payload, missing tag data");
24
123
  return;
25
124
  }
26
- console.log("[DB Listener] Tag DELETE - Dispatching deleteTag action with ID:", payload.old.id);
27
125
  dispatch(deleteTag(payload.old));
28
- }
29
- function handleTagUpdates(payload) {
30
- console.log("[DB Listener] Tag UPDATE received:", payload.new);
126
+ }, [dispatch]);
127
+ const handleTagUpdates = useCallback((payload) => {
128
+ console.log("[DB Listener] Tag UPDATE received:", payload);
129
+ if (!payload.new) {
130
+ console.error("[DB Listener] Tag UPDATE - Invalid payload, missing new data");
131
+ return;
132
+ }
31
133
  dispatch(updateTag(payload.new));
32
- }
33
- async function handleDeviceInserts(payload) {
34
- console.log("[DB Listener] Device INSERT received:", payload.new);
35
- // For now, just dispatch the raw payload since we don't have the device helper
134
+ }, [dispatch]);
135
+ const handleDeviceInserts = useCallback((payload) => {
136
+ console.log("[DB Listener] Device INSERT received:", payload);
137
+ if (!payload.new) {
138
+ console.error("[DB Listener] Device INSERT - Invalid payload, missing new data");
139
+ return;
140
+ }
36
141
  dispatch(addDevice(payload.new));
37
- }
38
- function handleDeviceDeletes(payload) {
39
- console.log("[DB Listener] Device DELETE received:", payload.old);
142
+ }, [dispatch]);
143
+ const handleDeviceDeletes = useCallback((payload) => {
144
+ console.log("[DB Listener] Device DELETE received:", payload);
145
+ if (!payload.old) {
146
+ console.error("[DB Listener] Device DELETE - Invalid payload, missing old data");
147
+ return;
148
+ }
40
149
  dispatch(deleteDevice(payload.old));
41
- }
42
- async function handleDeviceUpdates(payload) {
43
- console.log("[DB Listener] Device UPDATE received:", payload.new);
44
- // For now, just dispatch the raw payload since we don't have the device helper
150
+ }, [dispatch]);
151
+ const handleDeviceUpdates = useCallback((payload) => {
152
+ console.log("[DB Listener] Device UPDATE received:", payload);
153
+ if (!payload.new) {
154
+ console.error("[DB Listener] Device UPDATE - Invalid payload, missing new data");
155
+ return;
156
+ }
45
157
  dispatch(updateDevice(payload.new));
46
- }
47
- function handlePlanInserts(payload) {
48
- console.log("[DB Listener] Plan INSERT received:", payload.new);
158
+ }, [dispatch]);
159
+ const handlePlanInserts = useCallback((payload) => {
160
+ console.log("[DB Listener] Plan INSERT received:", payload);
161
+ if (!payload.new) {
162
+ console.error("[DB Listener] Plan INSERT - Invalid payload, missing new data");
163
+ return;
164
+ }
49
165
  dispatch(addPlan(payload.new));
50
- }
51
- function handlePlanDeletes(payload) {
52
- console.log("[DB Listener] Plan DELETE received:", payload.old);
166
+ }, [dispatch]);
167
+ const handlePlanDeletes = useCallback((payload) => {
168
+ console.log("[DB Listener] Plan DELETE received:", payload);
169
+ if (!payload.old) {
170
+ console.error("[DB Listener] Plan DELETE - Invalid payload, missing old data");
171
+ return;
172
+ }
53
173
  dispatch(deletePlan(payload.old));
54
- }
55
- function handlePlanUpdates(payload) {
56
- console.log("[DB Listener] Plan UPDATE received:", payload.new);
174
+ }, [dispatch]);
175
+ const handlePlanUpdates = useCallback((payload) => {
176
+ console.log("[DB Listener] Plan UPDATE received:", payload);
177
+ if (!payload.new) {
178
+ console.error("[DB Listener] Plan UPDATE - Invalid payload, missing new data");
179
+ return;
180
+ }
57
181
  dispatch(updatePlan(payload.new));
58
- }
59
- function handleSessionInserts(payload) {
60
- console.log("[DB Listener] Session INSERT received:", payload.new);
182
+ }, [dispatch]);
183
+ const handleSessionInserts = useCallback((payload) => {
184
+ console.log("[DB Listener] Session INSERT received:", payload);
185
+ if (!payload.new) {
186
+ console.error("[DB Listener] Session INSERT - Invalid payload, missing new data");
187
+ return;
188
+ }
61
189
  dispatch(addSessionToStore(payload.new));
62
- }
63
- function handleSessionDeletes(payload) {
64
- console.log("[DB Listener] Session DELETE received:", payload.old);
190
+ }, [dispatch]);
191
+ const handleSessionDeletes = useCallback((payload) => {
192
+ console.log("[DB Listener] Session DELETE received:", payload);
193
+ if (!payload.old) {
194
+ console.error("[DB Listener] Session DELETE - Invalid payload, missing old data");
195
+ return;
196
+ }
65
197
  dispatch(deleteSessionFromStore(payload.old));
66
- }
67
- function handleSessionUpdates(payload) {
68
- console.log("[DB Listener] Session UPDATE received:", payload.new);
198
+ }, [dispatch]);
199
+ const handleSessionUpdates = useCallback((payload) => {
200
+ console.log("[DB Listener] Session UPDATE received:", payload);
201
+ if (!payload.new) {
202
+ console.error("[DB Listener] Session UPDATE - Invalid payload, missing new data");
203
+ return;
204
+ }
69
205
  dispatch(updateSessionInStore(payload.new));
70
- }
71
- function handleConnectivityInserts(payload) {
72
- console.log("[DB Listener] Connectivity INSERT received:", payload.new);
206
+ }, [dispatch]);
207
+ const handleConnectivityInserts = useCallback((payload) => {
208
+ console.log("[DB Listener] Connectivity INSERT received:", payload);
73
209
  // For now, we'll just log connectivity changes since they're related to sessions
74
210
  // In the future, we might want to update session connectivity data
75
- }
76
- function handleConnectivityDeletes(payload) {
77
- console.log("[DB Listener] Connectivity DELETE received:", payload.old);
211
+ }, []);
212
+ const handleConnectivityDeletes = useCallback((payload) => {
213
+ console.log("[DB Listener] Connectivity DELETE received:", payload);
78
214
  // For now, we'll just log connectivity changes since they're related to sessions
79
215
  // In the future, we might want to update session connectivity data
80
- }
81
- function handleConnectivityUpdates(payload) {
82
- console.log("[DB Listener] Connectivity UPDATE received:", payload.new);
216
+ }, []);
217
+ const handleConnectivityUpdates = useCallback((payload) => {
218
+ console.log("[DB Listener] Connectivity UPDATE received:", payload);
83
219
  // For now, we'll just log connectivity changes since they're related to sessions
84
220
  // In the future, we might want to update session connectivity data
85
- }
86
- useEffect(() => {
87
- console.log("=== SCOUT DB LISTENER DEBUG ===");
88
- console.log("[DB Listener] Using shared Supabase client from ScoutRefreshProvider context");
89
- if (!scoutSupabase) {
90
- console.error("[DB Listener] No Supabase client available from ScoutRefreshProvider context");
91
- return;
221
+ }, []);
222
+ // Create a channel with proper error handling
223
+ const createChannel = useCallback((tableName) => {
224
+ if (!supabase.current)
225
+ return null;
226
+ const channelName = `scout_broadcast_${tableName}_${Date.now()}`;
227
+ console.log(`[DB Listener] Creating broadcast channel for ${tableName}:`, channelName);
228
+ try {
229
+ const channel = supabase.current.channel(channelName, {
230
+ config: { private: true },
231
+ });
232
+ // Add system event handlers for connection monitoring
233
+ channel
234
+ .on("system", { event: "disconnect" }, () => {
235
+ console.log(`[DB Listener] 🔌 ${tableName} channel disconnected`);
236
+ if (connectionState === ConnectionState.CONNECTED) {
237
+ setConnectionState(ConnectionState.DISCONNECTED);
238
+ setLastError("Channel disconnected");
239
+ scheduleReconnection();
240
+ }
241
+ })
242
+ .on("system", { event: "reconnect" }, () => {
243
+ console.log(`[DB Listener] 🔗 ${tableName} channel reconnected`);
244
+ })
245
+ .on("system", { event: "error" }, (error) => {
246
+ console.warn(`[DB Listener] ❌ ${tableName} channel error:`, error);
247
+ setLastError(`Channel error: ${error}`);
248
+ });
249
+ return channel;
92
250
  }
93
- supabase.current = scoutSupabase;
94
- // Test authentication first
95
- const testAuth = async () => {
251
+ catch (error) {
252
+ console.error(`[DB Listener] Failed to create ${tableName} channel:`, error);
253
+ return null;
254
+ }
255
+ }, [connectionState]);
256
+ // Set up all channels
257
+ const setupChannels = useCallback(async () => {
258
+ if (!supabase.current)
259
+ return false;
260
+ cleanupChannels();
261
+ const channelConfigs = [
262
+ {
263
+ name: "plans",
264
+ handlers: {
265
+ INSERT: handlePlanInserts,
266
+ UPDATE: handlePlanUpdates,
267
+ DELETE: handlePlanDeletes,
268
+ },
269
+ },
270
+ {
271
+ name: "devices",
272
+ handlers: {
273
+ INSERT: handleDeviceInserts,
274
+ UPDATE: handleDeviceUpdates,
275
+ DELETE: handleDeviceDeletes,
276
+ },
277
+ },
278
+ {
279
+ name: "tags",
280
+ handlers: {
281
+ INSERT: handleTagInserts,
282
+ UPDATE: handleTagUpdates,
283
+ DELETE: handleTagDeletes,
284
+ },
285
+ },
286
+ {
287
+ name: "sessions",
288
+ handlers: {
289
+ INSERT: handleSessionInserts,
290
+ UPDATE: handleSessionUpdates,
291
+ DELETE: handleSessionDeletes,
292
+ },
293
+ },
294
+ {
295
+ name: "connectivity",
296
+ handlers: {
297
+ INSERT: handleConnectivityInserts,
298
+ UPDATE: handleConnectivityUpdates,
299
+ DELETE: handleConnectivityDeletes,
300
+ },
301
+ },
302
+ ];
303
+ let successCount = 0;
304
+ const totalChannels = channelConfigs.length;
305
+ for (const config of channelConfigs) {
306
+ const channel = createChannel(config.name);
307
+ if (!channel)
308
+ continue;
96
309
  try {
97
- const { data: { user }, error, } = await scoutSupabase.auth.getUser();
98
- console.log("[DB Listener] Auth test - User:", user ? "authenticated" : "anonymous");
99
- console.log("[DB Listener] Auth test - Error:", error);
310
+ // Set up event handlers
311
+ Object.entries(config.handlers).forEach(([event, handler]) => {
312
+ channel.on("broadcast", { event }, handler);
313
+ });
314
+ // Subscribe to the channel
315
+ const _subscription = channel.subscribe((status) => {
316
+ console.log(`[DB Listener] ${config.name} channel status:`, status);
317
+ if (status === "SUBSCRIBED") {
318
+ successCount++;
319
+ if (successCount === totalChannels) {
320
+ setConnectionState(ConnectionState.CONNECTED);
321
+ setRetryCount(0);
322
+ setLastError(null);
323
+ console.log("[DB Listener] ✅ All channels successfully subscribed");
324
+ }
325
+ }
326
+ else if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") {
327
+ console.error(`[DB Listener] ${config.name} channel failed to subscribe:`, status);
328
+ setLastError(`Channel subscription failed: ${status}`);
329
+ }
330
+ });
331
+ channels.current.push(channel);
100
332
  }
101
- catch (err) {
102
- console.error("[DB Listener] Auth test failed:", err);
333
+ catch (error) {
334
+ console.error(`[DB Listener] Failed to set up ${config.name} channel:`, error);
103
335
  }
104
- };
105
- testAuth();
106
- // Create a single channel for all operations with unique name
107
- const channelName = `scout_realtime_${Date.now()}`;
108
- console.log("[DB Listener] Creating channel:", channelName);
109
- const mainChannel = scoutSupabase.channel(channelName);
110
- // Subscribe to all events
111
- mainChannel
112
- .on("postgres_changes", { event: "INSERT", schema: "public", table: "plans" }, handlePlanInserts)
113
- .on("postgres_changes", { event: "DELETE", schema: "public", table: "plans" }, handlePlanDeletes)
114
- .on("postgres_changes", { event: "UPDATE", schema: "public", table: "plans" }, handlePlanUpdates)
115
- .on("postgres_changes", { event: "INSERT", schema: "public", table: "devices" }, handleDeviceInserts)
116
- .on("postgres_changes", { event: "DELETE", schema: "public", table: "devices" }, handleDeviceDeletes)
117
- .on("postgres_changes", { event: "UPDATE", schema: "public", table: "devices" }, handleDeviceUpdates)
118
- .on("postgres_changes", { event: "INSERT", schema: "public", table: "tags" }, handleTagInserts)
119
- .on("postgres_changes", { event: "DELETE", schema: "public", table: "tags" }, handleTagDeletes)
120
- .on("postgres_changes", { event: "UPDATE", schema: "public", table: "tags" }, handleTagUpdates)
121
- .on("postgres_changes", { event: "INSERT", schema: "public", table: "connectivity" }, handleConnectivityInserts)
122
- .on("postgres_changes", { event: "DELETE", schema: "public", table: "connectivity" }, handleConnectivityDeletes)
123
- .on("postgres_changes", { event: "UPDATE", schema: "public", table: "connectivity" }, handleConnectivityUpdates)
124
- .on("postgres_changes", { event: "INSERT", schema: "public", table: "sessions" }, handleSessionInserts)
125
- .on("postgres_changes", { event: "DELETE", schema: "public", table: "sessions" }, handleSessionDeletes)
126
- .on("postgres_changes", { event: "UPDATE", schema: "public", table: "sessions" }, handleSessionUpdates)
127
- .subscribe((status) => {
128
- console.log("[DB Listener] Subscription status:", status);
129
- if (status === "SUBSCRIBED") {
130
- console.log("[DB Listener] ✅ Successfully subscribed to real-time updates");
336
+ }
337
+ return successCount > 0;
338
+ }, [
339
+ cleanupChannels,
340
+ createChannel,
341
+ handlePlanInserts,
342
+ handlePlanUpdates,
343
+ handlePlanDeletes,
344
+ handleDeviceInserts,
345
+ handleDeviceUpdates,
346
+ handleDeviceDeletes,
347
+ handleTagInserts,
348
+ handleTagUpdates,
349
+ handleTagDeletes,
350
+ handleSessionInserts,
351
+ handleSessionUpdates,
352
+ handleSessionDeletes,
353
+ handleConnectivityInserts,
354
+ handleConnectivityUpdates,
355
+ handleConnectivityDeletes,
356
+ ]);
357
+ // Schedule reconnection with exponential backoff
358
+ const scheduleReconnection = useCallback(() => {
359
+ if (isDestroyedRef.current ||
360
+ retryCount >= RECONNECTION_CONFIG.MAX_RETRIES) {
361
+ console.log("[DB Listener] Max reconnection attempts reached or hook destroyed");
362
+ setConnectionState(ConnectionState.ERROR);
363
+ return;
364
+ }
365
+ const delay = calculateBackoffDelay(retryCount);
366
+ console.log(`[DB Listener] Scheduling reconnection attempt ${retryCount + 1} in ${delay}ms`);
367
+ reconnectTimeoutRef.current = setTimeout(() => {
368
+ if (!isDestroyedRef.current) {
369
+ initializeConnection();
131
370
  }
132
- else if (status === "CHANNEL_ERROR") {
133
- console.error("[DB Listener] ❌ Channel error occurred");
371
+ }, delay);
372
+ }, [retryCount, calculateBackoffDelay]);
373
+ // Initialize connection
374
+ const initializeConnection = useCallback(async () => {
375
+ if (isDestroyedRef.current || isInitializingRef.current)
376
+ return;
377
+ isInitializingRef.current = true;
378
+ setConnectionState(ConnectionState.CONNECTING);
379
+ try {
380
+ console.log("[DB Listener] 🔄 Initializing connection...");
381
+ // Test database connection
382
+ const dbConnected = await testDbConnection();
383
+ if (!dbConnected) {
384
+ throw new Error("Database connection test failed");
134
385
  }
135
- else if (status === "TIMED_OUT") {
136
- console.error("[DB Listener] Subscription timed out");
386
+ // Set up realtime authentication
387
+ const authSuccess = await setupRealtimeAuth();
388
+ if (!authSuccess) {
389
+ throw new Error("Realtime authentication failed");
137
390
  }
138
- else if (status === "CLOSED") {
139
- console.log("[DB Listener] 🔒 Channel closed");
391
+ // Set up channels
392
+ const channelsSuccess = await setupChannels();
393
+ if (!channelsSuccess) {
394
+ throw new Error("Channel setup failed");
140
395
  }
141
- });
142
- channels.current.push(mainChannel);
143
- // Test the connection with system events
144
- const testChannelName = `test_connection_${Date.now()}`;
145
- console.log("[DB Listener] Creating test channel:", testChannelName);
146
- const testChannel = scoutSupabase.channel(testChannelName);
147
- testChannel
148
- .on("system", { event: "disconnect" }, () => {
149
- console.log("[DB Listener] 🔌 Disconnected from Supabase");
150
- })
151
- .on("system", { event: "reconnect" }, () => {
152
- console.log("[DB Listener] 🔗 Reconnected to Supabase");
153
- })
154
- .on("system", { event: "error" }, (error) => {
155
- console.error("[DB Listener] ❌ System error:", error);
156
- })
157
- .subscribe((status) => {
158
- console.log("[DB Listener] Test channel status:", status);
159
- });
160
- channels.current.push(testChannel);
161
- // Test a simple database query to verify connection
162
- const testDbConnection = async () => {
163
- try {
164
- const { data, error } = await scoutSupabase
165
- .from("tags")
166
- .select("count")
167
- .limit(1);
168
- console.log("[DB Listener] DB connection test - Success:", !!data);
169
- console.log("[DB Listener] DB connection test - Error:", error);
170
- }
171
- catch (err) {
172
- console.error("[DB Listener] DB connection test failed:", err);
173
- }
174
- };
175
- testDbConnection();
176
- console.log("=== END SCOUT DB LISTENER DEBUG ===");
396
+ console.log("[DB Listener] ✅ Connection initialized successfully");
397
+ }
398
+ catch (error) {
399
+ console.error("[DB Listener] Connection initialization failed:", error);
400
+ setLastError(error instanceof Error ? error.message : "Unknown error");
401
+ setConnectionState(ConnectionState.ERROR);
402
+ setRetryCount((prev) => prev + 1);
403
+ // Schedule reconnection
404
+ scheduleReconnection();
405
+ }
406
+ finally {
407
+ isInitializingRef.current = false;
408
+ }
409
+ }, [
410
+ testDbConnection,
411
+ setupRealtimeAuth,
412
+ setupChannels,
413
+ scheduleReconnection,
414
+ ]);
415
+ // Manual reconnection function
416
+ const reconnect = useCallback(() => {
417
+ if (isDestroyedRef.current)
418
+ return;
419
+ console.log("[DB Listener] 🔄 Manual reconnection requested");
420
+ cancelReconnection();
421
+ setRetryCount(0);
422
+ setLastError(null);
423
+ initializeConnection();
424
+ }, [cancelReconnection, initializeConnection]);
425
+ // Main effect
426
+ useEffect(() => {
427
+ console.log("=== SCOUT DB LISTENER INITIALIZATION ===");
428
+ if (!scoutSupabase) {
429
+ console.error("[DB Listener] No Supabase client available");
430
+ setConnectionState(ConnectionState.ERROR);
431
+ setLastError("No Supabase client available");
432
+ return;
433
+ }
434
+ supabase.current = scoutSupabase;
435
+ isDestroyedRef.current = false;
436
+ // Initialize connection
437
+ initializeConnection();
177
438
  // Cleanup function
178
439
  return () => {
179
- console.log("[DB Listener] 🧹 Cleaning up channels");
180
- channels.current.forEach((channel) => {
181
- if (channel) {
182
- scoutSupabase.removeChannel(channel);
183
- }
184
- });
185
- channels.current = [];
440
+ console.log("[DB Listener] 🧹 Cleaning up hook");
441
+ isDestroyedRef.current = true;
442
+ cancelReconnection();
443
+ cleanupChannels();
186
444
  };
187
- }, [scoutSupabase, dispatch]);
445
+ }, [
446
+ scoutSupabase,
447
+ initializeConnection,
448
+ cancelReconnection,
449
+ cleanupChannels,
450
+ ]);
451
+ // Return connection state and manual reconnect function
452
+ return {
453
+ connectionState,
454
+ lastError,
455
+ retryCount,
456
+ reconnect,
457
+ isConnected: connectionState === ConnectionState.CONNECTED,
458
+ isConnecting: connectionState === ConnectionState.CONNECTING ||
459
+ connectionState === ConnectionState.RECONNECTING,
460
+ };
188
461
  }
@@ -1,6 +1,13 @@
1
1
  import { ReactNode } from "react";
2
2
  import { SupabaseClient } from "@supabase/supabase-js";
3
3
  import { Database } from "../types/supabase";
4
+ interface ConnectionStatus {
5
+ isConnected: boolean;
6
+ isConnecting: boolean;
7
+ lastError: string | null;
8
+ retryCount: number;
9
+ reconnect: () => void;
10
+ }
4
11
  export declare function useSupabase(): SupabaseClient<Database, "public", {
5
12
  Tables: {
6
13
  actions: {
@@ -883,7 +890,9 @@ export declare function useSupabase(): SupabaseClient<Database, "public", {
883
890
  };
884
891
  };
885
892
  }>;
893
+ export declare function useConnectionStatus(): ConnectionStatus;
886
894
  export interface ScoutRefreshProviderProps {
887
895
  children: ReactNode;
888
896
  }
889
897
  export declare function ScoutRefreshProvider({ children }: ScoutRefreshProviderProps): import("react/jsx-runtime").JSX.Element;
898
+ export {};
@@ -6,6 +6,7 @@ import { createContext, useContext, useRef } from "react";
6
6
  import { createBrowserClient } from "@supabase/ssr";
7
7
  // Create context for the Supabase client
8
8
  const SupabaseContext = createContext(null);
9
+ const ConnectionStatusContext = createContext(null);
9
10
  // Hook to use the Supabase client
10
11
  export function useSupabase() {
11
12
  const supabase = useContext(SupabaseContext);
@@ -14,6 +15,14 @@ export function useSupabase() {
14
15
  }
15
16
  return supabase;
16
17
  }
18
+ // Hook to use connection status
19
+ export function useConnectionStatus() {
20
+ const connectionStatus = useContext(ConnectionStatusContext);
21
+ if (!connectionStatus) {
22
+ throw new Error("useConnectionStatus must be used within a ScoutRefreshProvider");
23
+ }
24
+ return connectionStatus;
25
+ }
17
26
  export function ScoutRefreshProvider({ children }) {
18
27
  const url = process.env.NEXT_PUBLIC_SUPABASE_URL || "";
19
28
  const anon_key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "";
@@ -23,7 +32,15 @@ export function ScoutRefreshProvider({ children }) {
23
32
  supabaseRef.current = createBrowserClient(url, anon_key);
24
33
  console.log("[ScoutRefreshProvider] Created Supabase client");
25
34
  }
26
- useScoutDbListener(supabaseRef.current);
35
+ // Use the enhanced DB listener with connection status
36
+ const connectionStatus = useScoutDbListener(supabaseRef.current);
27
37
  useScoutRefresh();
28
- return (_jsx(SupabaseContext.Provider, { value: supabaseRef.current, children: children }));
38
+ // Log connection status changes for debugging
39
+ if (connectionStatus.lastError) {
40
+ console.warn("[ScoutRefreshProvider] DB Listener error:", connectionStatus.lastError);
41
+ }
42
+ if (connectionStatus.isConnected) {
43
+ console.log("[ScoutRefreshProvider] ✅ DB Listener connected");
44
+ }
45
+ return (_jsx(SupabaseContext.Provider, { value: supabaseRef.current, children: _jsx(ConnectionStatusContext.Provider, { value: connectionStatus, children: children }) }));
29
46
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adventurelabs/scout-core",
3
- "version": "1.0.35",
3
+ "version": "1.0.37",
4
4
  "description": "Core utilities and helpers for Adventure Labs Scout applications",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",