@adventurelabs/scout-core 1.0.36 → 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,284 +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) {
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
+ }
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) => {
10
112
  console.log("[DB Listener] Tag INSERT received:", payload);
11
- // Broadcast payload contains the record directly
12
- const tagData = payload.new || payload;
13
- dispatch(addTag(tagData));
14
- }
15
- function handleTagDeletes(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) => {
16
120
  console.log("[DB Listener] Tag DELETE received:", payload);
17
- console.log("[DB Listener] Tag DELETE - payload structure:", {
18
- hasOld: !!payload.old,
19
- oldId: payload.old?.id,
20
- oldEventId: payload.old?.event_id,
21
- oldClassName: payload.old?.class_name,
22
- fullPayload: payload,
23
- });
24
- // Broadcast payload contains the old record
25
- const tagData = payload.old || payload;
26
- if (!tagData || !tagData.id) {
121
+ if (!payload.old || !payload.old.id) {
27
122
  console.error("[DB Listener] Tag DELETE - Invalid payload, missing tag data");
28
123
  return;
29
124
  }
30
- console.log("[DB Listener] Tag DELETE - Dispatching deleteTag action with ID:", tagData.id);
31
- dispatch(deleteTag(tagData));
32
- }
33
- function handleTagUpdates(payload) {
125
+ dispatch(deleteTag(payload.old));
126
+ }, [dispatch]);
127
+ const handleTagUpdates = useCallback((payload) => {
34
128
  console.log("[DB Listener] Tag UPDATE received:", payload);
35
- // Broadcast payload contains the new record
36
- const tagData = payload.new || payload;
37
- dispatch(updateTag(tagData));
38
- }
39
- async function handleDeviceInserts(payload) {
129
+ if (!payload.new) {
130
+ console.error("[DB Listener] Tag UPDATE - Invalid payload, missing new data");
131
+ return;
132
+ }
133
+ dispatch(updateTag(payload.new));
134
+ }, [dispatch]);
135
+ const handleDeviceInserts = useCallback((payload) => {
40
136
  console.log("[DB Listener] Device INSERT received:", payload);
41
- // Broadcast payload contains the record directly
42
- const deviceData = payload.new || payload;
43
- dispatch(addDevice(deviceData));
44
- }
45
- function handleDeviceDeletes(payload) {
137
+ if (!payload.new) {
138
+ console.error("[DB Listener] Device INSERT - Invalid payload, missing new data");
139
+ return;
140
+ }
141
+ dispatch(addDevice(payload.new));
142
+ }, [dispatch]);
143
+ const handleDeviceDeletes = useCallback((payload) => {
46
144
  console.log("[DB Listener] Device DELETE received:", payload);
47
- // Broadcast payload contains the old record
48
- const deviceData = payload.old || payload;
49
- dispatch(deleteDevice(deviceData));
50
- }
51
- async function handleDeviceUpdates(payload) {
145
+ if (!payload.old) {
146
+ console.error("[DB Listener] Device DELETE - Invalid payload, missing old data");
147
+ return;
148
+ }
149
+ dispatch(deleteDevice(payload.old));
150
+ }, [dispatch]);
151
+ const handleDeviceUpdates = useCallback((payload) => {
52
152
  console.log("[DB Listener] Device UPDATE received:", payload);
53
- // Broadcast payload contains the new record
54
- const deviceData = payload.new || payload;
55
- dispatch(updateDevice(deviceData));
56
- }
57
- function handlePlanInserts(payload) {
153
+ if (!payload.new) {
154
+ console.error("[DB Listener] Device UPDATE - Invalid payload, missing new data");
155
+ return;
156
+ }
157
+ dispatch(updateDevice(payload.new));
158
+ }, [dispatch]);
159
+ const handlePlanInserts = useCallback((payload) => {
58
160
  console.log("[DB Listener] Plan INSERT received:", payload);
59
- // Broadcast payload contains the record directly
60
- const planData = payload.new || payload;
61
- dispatch(addPlan(planData));
62
- }
63
- function handlePlanDeletes(payload) {
161
+ if (!payload.new) {
162
+ console.error("[DB Listener] Plan INSERT - Invalid payload, missing new data");
163
+ return;
164
+ }
165
+ dispatch(addPlan(payload.new));
166
+ }, [dispatch]);
167
+ const handlePlanDeletes = useCallback((payload) => {
64
168
  console.log("[DB Listener] Plan DELETE received:", payload);
65
- // Broadcast payload contains the old record
66
- const planData = payload.old || payload;
67
- dispatch(deletePlan(planData));
68
- }
69
- function handlePlanUpdates(payload) {
169
+ if (!payload.old) {
170
+ console.error("[DB Listener] Plan DELETE - Invalid payload, missing old data");
171
+ return;
172
+ }
173
+ dispatch(deletePlan(payload.old));
174
+ }, [dispatch]);
175
+ const handlePlanUpdates = useCallback((payload) => {
70
176
  console.log("[DB Listener] Plan UPDATE received:", payload);
71
- // Broadcast payload contains the new record
72
- const planData = payload.new || payload;
73
- dispatch(updatePlan(planData));
74
- }
75
- function handleSessionInserts(payload) {
177
+ if (!payload.new) {
178
+ console.error("[DB Listener] Plan UPDATE - Invalid payload, missing new data");
179
+ return;
180
+ }
181
+ dispatch(updatePlan(payload.new));
182
+ }, [dispatch]);
183
+ const handleSessionInserts = useCallback((payload) => {
76
184
  console.log("[DB Listener] Session INSERT received:", payload);
77
- // Broadcast payload contains the record directly
78
- const sessionData = payload.new || payload;
79
- dispatch(addSessionToStore(sessionData));
80
- }
81
- function handleSessionDeletes(payload) {
185
+ if (!payload.new) {
186
+ console.error("[DB Listener] Session INSERT - Invalid payload, missing new data");
187
+ return;
188
+ }
189
+ dispatch(addSessionToStore(payload.new));
190
+ }, [dispatch]);
191
+ const handleSessionDeletes = useCallback((payload) => {
82
192
  console.log("[DB Listener] Session DELETE received:", payload);
83
- // Broadcast payload contains the old record
84
- const sessionData = payload.old || payload;
85
- dispatch(deleteSessionFromStore(sessionData));
86
- }
87
- function handleSessionUpdates(payload) {
193
+ if (!payload.old) {
194
+ console.error("[DB Listener] Session DELETE - Invalid payload, missing old data");
195
+ return;
196
+ }
197
+ dispatch(deleteSessionFromStore(payload.old));
198
+ }, [dispatch]);
199
+ const handleSessionUpdates = useCallback((payload) => {
88
200
  console.log("[DB Listener] Session UPDATE received:", payload);
89
- // Broadcast payload contains the new record
90
- const sessionData = payload.new || payload;
91
- dispatch(updateSessionInStore(sessionData));
92
- }
93
- function handleConnectivityInserts(payload) {
201
+ if (!payload.new) {
202
+ console.error("[DB Listener] Session UPDATE - Invalid payload, missing new data");
203
+ return;
204
+ }
205
+ dispatch(updateSessionInStore(payload.new));
206
+ }, [dispatch]);
207
+ const handleConnectivityInserts = useCallback((payload) => {
94
208
  console.log("[DB Listener] Connectivity INSERT received:", payload);
95
209
  // For now, we'll just log connectivity changes since they're related to sessions
96
210
  // In the future, we might want to update session connectivity data
97
- }
98
- function handleConnectivityDeletes(payload) {
211
+ }, []);
212
+ const handleConnectivityDeletes = useCallback((payload) => {
99
213
  console.log("[DB Listener] Connectivity DELETE received:", payload);
100
214
  // For now, we'll just log connectivity changes since they're related to sessions
101
215
  // In the future, we might want to update session connectivity data
102
- }
103
- function handleConnectivityUpdates(payload) {
216
+ }, []);
217
+ const handleConnectivityUpdates = useCallback((payload) => {
104
218
  console.log("[DB Listener] Connectivity UPDATE received:", payload);
105
219
  // For now, we'll just log connectivity changes since they're related to sessions
106
220
  // In the future, we might want to update session connectivity data
107
- }
108
- useEffect(() => {
109
- console.log("=== SCOUT DB LISTENER DEBUG ===");
110
- console.log("[DB Listener] Using shared Supabase client from ScoutRefreshProvider context");
111
- if (!scoutSupabase) {
112
- console.error("[DB Listener] No Supabase client available from ScoutRefreshProvider context");
113
- 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;
114
250
  }
115
- supabase.current = scoutSupabase;
116
- // Test authentication first
117
- 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;
118
309
  try {
119
- const { data: { user }, error, } = await scoutSupabase.auth.getUser();
120
- console.log("[DB Listener] Auth test - User:", user ? "authenticated" : "anonymous");
121
- 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);
122
332
  }
123
- catch (err) {
124
- console.warn("[DB Listener] Auth test failed:", err);
333
+ catch (error) {
334
+ console.error(`[DB Listener] Failed to set up ${config.name} channel:`, error);
125
335
  }
126
- };
127
- testAuth();
128
- // Set up authentication for Realtime Authorization (required for broadcast)
129
- const setupRealtimeAuth = async () => {
130
- try {
131
- await scoutSupabase.realtime.setAuth();
132
- console.log("[DB Listener] ✅ Realtime authentication set up successfully");
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();
133
370
  }
134
- catch (err) {
135
- console.warn("[DB Listener] ❌ Failed to set up realtime authentication:", err);
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");
136
385
  }
137
- };
138
- setupRealtimeAuth();
139
- // Create channels for each table using broadcast
140
- const createBroadcastChannel = (tableName) => {
141
- const channelName = `scout_broadcast_${tableName}_${Date.now()}`;
142
- console.log(`[DB Listener] Creating broadcast channel for ${tableName}:`, channelName);
143
- return scoutSupabase.channel(channelName, {
144
- config: { private: true }, // Required for broadcast with Realtime Authorization
145
- });
146
- };
147
- // Plans channel
148
- const plansChannel = createBroadcastChannel("plans");
149
- plansChannel
150
- .on("broadcast", { event: "INSERT" }, (payload) => {
151
- console.log("[DB Listener] Plans INSERT received:", payload);
152
- handlePlanInserts(payload);
153
- })
154
- .on("broadcast", { event: "UPDATE" }, (payload) => {
155
- console.log("[DB Listener] Plans UPDATE received:", payload);
156
- handlePlanUpdates(payload);
157
- })
158
- .on("broadcast", { event: "DELETE" }, (payload) => {
159
- console.log("[DB Listener] Plans DELETE received:", payload);
160
- handlePlanDeletes(payload);
161
- })
162
- .subscribe((status) => {
163
- console.log(`[DB Listener] Plans channel status:`, status);
164
- });
165
- // Devices channel
166
- const devicesChannel = createBroadcastChannel("devices");
167
- devicesChannel
168
- .on("broadcast", { event: "INSERT" }, (payload) => {
169
- console.log("[DB Listener] Devices INSERT received:", payload);
170
- handleDeviceInserts(payload);
171
- })
172
- .on("broadcast", { event: "UPDATE" }, (payload) => {
173
- console.log("[DB Listener] Devices UPDATE received:", payload);
174
- handleDeviceUpdates(payload);
175
- })
176
- .on("broadcast", { event: "DELETE" }, (payload) => {
177
- console.log("[DB Listener] Devices DELETE received:", payload);
178
- handleDeviceDeletes(payload);
179
- })
180
- .subscribe((status) => {
181
- console.log(`[DB Listener] Devices channel status:`, status);
182
- });
183
- // Tags channel
184
- const tagsChannel = createBroadcastChannel("tags");
185
- tagsChannel
186
- .on("broadcast", { event: "INSERT" }, (payload) => {
187
- console.log("[DB Listener] Tags INSERT received:", payload);
188
- handleTagInserts(payload);
189
- })
190
- .on("broadcast", { event: "UPDATE" }, (payload) => {
191
- console.log("[DB Listener] Tags UPDATE received:", payload);
192
- handleTagUpdates(payload);
193
- })
194
- .on("broadcast", { event: "DELETE" }, (payload) => {
195
- console.log("[DB Listener] Tags DELETE received:", payload);
196
- handleTagDeletes(payload);
197
- })
198
- .subscribe((status) => {
199
- console.log(`[DB Listener] Tags channel status:`, status);
200
- });
201
- // Sessions channel
202
- const sessionsChannel = createBroadcastChannel("sessions");
203
- sessionsChannel
204
- .on("broadcast", { event: "INSERT" }, (payload) => {
205
- console.log("[DB Listener] Sessions INSERT received:", payload);
206
- handleSessionInserts(payload);
207
- })
208
- .on("broadcast", { event: "UPDATE" }, (payload) => {
209
- console.log("[DB Listener] Sessions UPDATE received:", payload);
210
- handleSessionUpdates(payload);
211
- })
212
- .on("broadcast", { event: "DELETE" }, (payload) => {
213
- console.log("[DB Listener] Sessions DELETE received:", payload);
214
- handleSessionDeletes(payload);
215
- })
216
- .subscribe((status) => {
217
- console.log(`[DB Listener] Sessions channel status:`, status);
218
- });
219
- // Connectivity channel
220
- const connectivityChannel = createBroadcastChannel("connectivity");
221
- connectivityChannel
222
- .on("broadcast", { event: "INSERT" }, (payload) => {
223
- console.log("[DB Listener] Connectivity INSERT received:", payload);
224
- handleConnectivityInserts(payload);
225
- })
226
- .on("broadcast", { event: "UPDATE" }, (payload) => {
227
- console.log("[DB Listener] Connectivity UPDATE received:", payload);
228
- handleConnectivityUpdates(payload);
229
- })
230
- .on("broadcast", { event: "DELETE" }, (payload) => {
231
- console.log("[DB Listener] Connectivity DELETE received:", payload);
232
- handleConnectivityDeletes(payload);
233
- })
234
- .subscribe((status) => {
235
- console.log(`[DB Listener] Connectivity channel status:`, status);
236
- });
237
- // Add all channels to the channels array
238
- channels.current.push(plansChannel, devicesChannel, tagsChannel, sessionsChannel, connectivityChannel);
239
- // Test the connection with system events
240
- const testChannelName = `test_connection_${Date.now()}`;
241
- console.log("[DB Listener] Creating test channel:", testChannelName);
242
- const testChannel = scoutSupabase.channel(testChannelName);
243
- testChannel
244
- .on("system", { event: "disconnect" }, () => {
245
- console.log("[DB Listener] 🔌 Disconnected from Supabase");
246
- })
247
- .on("system", { event: "reconnect" }, () => {
248
- console.log("[DB Listener] 🔗 Reconnected to Supabase");
249
- })
250
- .on("system", { event: "error" }, (error) => {
251
- console.warn("[DB Listener] ❌ System error:", error);
252
- })
253
- .subscribe((status) => {
254
- console.log("[DB Listener] Test channel status:", status);
255
- });
256
- channels.current.push(testChannel);
257
- // Test a simple database query to verify connection
258
- const testDbConnection = async () => {
259
- try {
260
- const { data, error } = await scoutSupabase
261
- .from("tags")
262
- .select("count")
263
- .limit(1);
264
- console.log("[DB Listener] DB connection test - Success:", !!data);
265
- console.log("[DB Listener] DB connection test - Error:", error);
386
+ // Set up realtime authentication
387
+ const authSuccess = await setupRealtimeAuth();
388
+ if (!authSuccess) {
389
+ throw new Error("Realtime authentication failed");
266
390
  }
267
- catch (err) {
268
- console.error("[DB Listener] DB connection test failed:", err);
391
+ // Set up channels
392
+ const channelsSuccess = await setupChannels();
393
+ if (!channelsSuccess) {
394
+ throw new Error("Channel setup failed");
269
395
  }
270
- };
271
- testDbConnection();
272
- 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();
273
438
  // Cleanup function
274
439
  return () => {
275
- console.log("[DB Listener] 🧹 Cleaning up channels");
276
- channels.current.forEach((channel) => {
277
- if (channel) {
278
- scoutSupabase.removeChannel(channel);
279
- }
280
- });
281
- channels.current = [];
440
+ console.log("[DB Listener] 🧹 Cleaning up hook");
441
+ isDestroyedRef.current = true;
442
+ cancelReconnection();
443
+ cleanupChannels();
282
444
  };
283
- }, [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
+ };
284
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.36",
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",