@adventurelabs/scout-core 1.0.36 โ†’ 1.0.38

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,65 @@
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
+ ERROR = "error"
8
+ }
9
+ /**
10
+ * Hook for listening to real-time database changes with robust disconnect handling.
11
+ *
12
+ * Features:
13
+ * - Automatic reconnection with exponential backoff
14
+ * - Connection state tracking
15
+ * - Error handling and retry logic
16
+ * - Manual reconnection capability
17
+ *
18
+ * @param scoutSupabase - The Supabase client instance
19
+ * @returns Connection status and control functions
20
+ */
21
+ export declare function useScoutDbListener(scoutSupabase: SupabaseClient<Database>): {
22
+ connectionState: ConnectionState;
23
+ lastError: string | null;
24
+ retryCount: number;
25
+ reconnect: () => void;
26
+ isConnected: boolean;
27
+ isConnecting: boolean;
28
+ };
29
+ export {};
30
+ /**
31
+ * Return type for useScoutDbListener hook
32
+ *
33
+ * @example
34
+ * ```tsx
35
+ * function MyComponent() {
36
+ * const {
37
+ * isConnected,
38
+ * isConnecting,
39
+ * lastError,
40
+ * retryCount,
41
+ * reconnect
42
+ * } = useConnectionStatus();
43
+ *
44
+ * if (isConnecting) {
45
+ * return <div>Connecting to database...</div>;
46
+ * }
47
+ *
48
+ * if (lastError) {
49
+ * return (
50
+ * <div>
51
+ * <p>Connection error: {lastError}</p>
52
+ * <p>Retry attempts: {retryCount}</p>
53
+ * <button onClick={reconnect}>Reconnect</button>
54
+ * </div>
55
+ * );
56
+ * }
57
+ *
58
+ * if (!isConnected) {
59
+ * return <div>Disconnected from database</div>;
60
+ * }
61
+ *
62
+ * return <div>Connected to database</div>;
63
+ * }
64
+ * ```
65
+ */
@@ -1,284 +1,327 @@
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["ERROR"] = "error";
12
+ })(ConnectionState || (ConnectionState = {}));
13
+ // Reconnection configuration
14
+ const RECONNECTION_CONFIG = {
15
+ MAX_RETRIES: 10,
16
+ INITIAL_DELAY: 1000, // 1 second
17
+ MAX_DELAY: 30000, // 30 seconds
18
+ BACKOFF_MULTIPLIER: 2,
19
+ JITTER_FACTOR: 0.1, // 10% jitter
20
+ };
21
+ /**
22
+ * Hook for listening to real-time database changes with robust disconnect handling.
23
+ *
24
+ * Features:
25
+ * - Automatic reconnection with exponential backoff
26
+ * - Connection state tracking
27
+ * - Error handling and retry logic
28
+ * - Manual reconnection capability
29
+ *
30
+ * @param scoutSupabase - The Supabase client instance
31
+ * @returns Connection status and control functions
32
+ */
5
33
  export function useScoutDbListener(scoutSupabase) {
6
34
  const supabase = useRef(null);
7
35
  const channels = useRef([]);
8
36
  const dispatch = useAppDispatch();
9
- function handleTagInserts(payload) {
10
- 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) {
16
- 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,
37
+ // Connection state management
38
+ const [connectionState, setConnectionState] = useState(ConnectionState.DISCONNECTED);
39
+ const [lastError, setLastError] = useState(null);
40
+ const [retryCount, setRetryCount] = useState(0);
41
+ // Reconnection management
42
+ const reconnectTimeoutRef = useRef(null);
43
+ const isInitializingRef = useRef(false);
44
+ const isDestroyedRef = useRef(false);
45
+ // Calculate exponential backoff delay with jitter
46
+ const calculateBackoffDelay = useCallback((attempt) => {
47
+ const baseDelay = Math.min(RECONNECTION_CONFIG.INITIAL_DELAY *
48
+ Math.pow(RECONNECTION_CONFIG.BACKOFF_MULTIPLIER, attempt), RECONNECTION_CONFIG.MAX_DELAY);
49
+ const jitter = baseDelay * RECONNECTION_CONFIG.JITTER_FACTOR * (Math.random() - 0.5);
50
+ return Math.max(100, baseDelay + jitter); // Minimum 100ms delay
51
+ }, []);
52
+ // Clean up all channels
53
+ const cleanupChannels = useCallback(() => {
54
+ console.log("[DB Listener] ๐Ÿงน Cleaning up channels");
55
+ channels.current.forEach((channel) => {
56
+ if (channel && supabase.current) {
57
+ try {
58
+ supabase.current.removeChannel(channel);
59
+ }
60
+ catch (error) {
61
+ console.warn("[DB Listener] Error removing channel:", error);
62
+ }
63
+ }
23
64
  });
24
- // Broadcast payload contains the old record
25
- const tagData = payload.old || payload;
26
- if (!tagData || !tagData.id) {
27
- console.error("[DB Listener] Tag DELETE - Invalid payload, missing tag data");
28
- return;
29
- }
30
- console.log("[DB Listener] Tag DELETE - Dispatching deleteTag action with ID:", tagData.id);
31
- dispatch(deleteTag(tagData));
32
- }
33
- function handleTagUpdates(payload) {
34
- 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) {
40
- 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) {
46
- 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) {
52
- 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) {
58
- 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) {
64
- 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) {
70
- 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) {
76
- 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) {
82
- 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) {
88
- 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) {
94
- console.log("[DB Listener] Connectivity INSERT received:", payload);
95
- // For now, we'll just log connectivity changes since they're related to sessions
96
- // In the future, we might want to update session connectivity data
97
- }
98
- function handleConnectivityDeletes(payload) {
99
- console.log("[DB Listener] Connectivity DELETE received:", payload);
100
- // For now, we'll just log connectivity changes since they're related to sessions
101
- // In the future, we might want to update session connectivity data
102
- }
103
- function handleConnectivityUpdates(payload) {
104
- console.log("[DB Listener] Connectivity UPDATE received:", payload);
105
- // For now, we'll just log connectivity changes since they're related to sessions
106
- // 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;
65
+ channels.current = [];
66
+ }, []);
67
+ // Cancel any pending reconnection attempts
68
+ const cancelReconnection = useCallback(() => {
69
+ if (reconnectTimeoutRef.current) {
70
+ clearTimeout(reconnectTimeoutRef.current);
71
+ reconnectTimeoutRef.current = null;
114
72
  }
115
- supabase.current = scoutSupabase;
116
- // Test authentication first
117
- const testAuth = async () => {
118
- 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);
73
+ }, []);
74
+ // Test database connection
75
+ const testDbConnection = useCallback(async () => {
76
+ if (!supabase.current)
77
+ return false;
78
+ try {
79
+ const { data, error } = await supabase.current
80
+ .from("tags")
81
+ .select("count")
82
+ .limit(1);
83
+ if (error) {
84
+ console.warn("[DB Listener] DB connection test failed:", error);
85
+ return false;
122
86
  }
123
- catch (err) {
124
- console.warn("[DB Listener] Auth test failed:", err);
87
+ console.log("[DB Listener] โœ… DB connection test successful");
88
+ return true;
89
+ }
90
+ catch (err) {
91
+ console.error("[DB Listener] DB connection test failed:", err);
92
+ return false;
93
+ }
94
+ }, []);
95
+ // Set up realtime authentication
96
+ const setupRealtimeAuth = useCallback(async () => {
97
+ if (!supabase.current)
98
+ return false;
99
+ try {
100
+ await supabase.current.realtime.setAuth();
101
+ console.log("[DB Listener] โœ… Realtime authentication set up successfully");
102
+ return true;
103
+ }
104
+ catch (err) {
105
+ console.warn("[DB Listener] โŒ Failed to set up realtime authentication:", err);
106
+ return false;
107
+ }
108
+ }, []);
109
+ // Generic event handler factory
110
+ const createEventHandler = useCallback((action, dataKey, entityName) => {
111
+ return (payload) => {
112
+ console.log(`[DB Listener] ${entityName} ${payload.event} received:`, payload);
113
+ const data = payload[dataKey];
114
+ if (!data) {
115
+ console.error(`[DB Listener] ${entityName} ${payload.event} - Invalid payload, missing ${dataKey} data`);
116
+ return;
125
117
  }
118
+ action(data);
126
119
  };
127
- testAuth();
128
- // Set up authentication for Realtime Authorization (required for broadcast)
129
- const setupRealtimeAuth = async () => {
120
+ }, []);
121
+ // Create event handlers using the factory
122
+ const handlers = useCallback(() => ({
123
+ tags: {
124
+ INSERT: createEventHandler(dispatch.bind(null, addTag), "new", "Tag"),
125
+ UPDATE: createEventHandler(dispatch.bind(null, updateTag), "new", "Tag"),
126
+ DELETE: createEventHandler(dispatch.bind(null, deleteTag), "old", "Tag"),
127
+ },
128
+ devices: {
129
+ INSERT: createEventHandler(dispatch.bind(null, addDevice), "new", "Device"),
130
+ UPDATE: createEventHandler(dispatch.bind(null, updateDevice), "new", "Device"),
131
+ DELETE: createEventHandler(dispatch.bind(null, deleteDevice), "old", "Device"),
132
+ },
133
+ plans: {
134
+ INSERT: createEventHandler(dispatch.bind(null, addPlan), "new", "Plan"),
135
+ UPDATE: createEventHandler(dispatch.bind(null, updatePlan), "new", "Plan"),
136
+ DELETE: createEventHandler(dispatch.bind(null, deletePlan), "old", "Plan"),
137
+ },
138
+ sessions: {
139
+ INSERT: createEventHandler(dispatch.bind(null, addSessionToStore), "new", "Session"),
140
+ UPDATE: createEventHandler(dispatch.bind(null, updateSessionInStore), "new", "Session"),
141
+ DELETE: createEventHandler(dispatch.bind(null, deleteSessionFromStore), "old", "Session"),
142
+ },
143
+ connectivity: {
144
+ INSERT: (payload) => console.log("[DB Listener] Connectivity INSERT received:", payload),
145
+ UPDATE: (payload) => console.log("[DB Listener] Connectivity UPDATE received:", payload),
146
+ DELETE: (payload) => console.log("[DB Listener] Connectivity DELETE received:", payload),
147
+ },
148
+ }), [createEventHandler, dispatch]);
149
+ // Create a channel with proper error handling
150
+ const createChannel = useCallback((tableName) => {
151
+ if (!supabase.current)
152
+ return null;
153
+ const channelName = `scout_broadcast_${tableName}_${Date.now()}`;
154
+ console.log(`[DB Listener] Creating broadcast channel for ${tableName}:`, channelName);
155
+ try {
156
+ const channel = supabase.current.channel(channelName, {
157
+ config: { private: true },
158
+ });
159
+ // Add system event handlers for connection monitoring
160
+ channel
161
+ .on("system", { event: "disconnect" }, () => {
162
+ console.log(`[DB Listener] ๐Ÿ”Œ ${tableName} channel disconnected`);
163
+ setConnectionState(ConnectionState.DISCONNECTED);
164
+ setLastError("Channel disconnected");
165
+ })
166
+ .on("system", { event: "reconnect" }, () => {
167
+ console.log(`[DB Listener] ๐Ÿ”— ${tableName} channel reconnected`);
168
+ })
169
+ .on("system", { event: "error" }, (error) => {
170
+ console.warn(`[DB Listener] โŒ ${tableName} channel error:`, error);
171
+ setLastError(`Channel error: ${error}`);
172
+ });
173
+ return channel;
174
+ }
175
+ catch (error) {
176
+ console.error(`[DB Listener] Failed to create ${tableName} channel:`, error);
177
+ return null;
178
+ }
179
+ }, []);
180
+ // Set up all channels
181
+ const setupChannels = useCallback(async () => {
182
+ if (!supabase.current)
183
+ return false;
184
+ cleanupChannels();
185
+ const tableHandlers = handlers();
186
+ const tables = Object.keys(tableHandlers);
187
+ let successCount = 0;
188
+ const totalChannels = tables.length;
189
+ for (const tableName of tables) {
190
+ const channel = createChannel(tableName);
191
+ if (!channel)
192
+ continue;
130
193
  try {
131
- await scoutSupabase.realtime.setAuth();
132
- console.log("[DB Listener] โœ… Realtime authentication set up successfully");
194
+ // Set up event handlers
195
+ const tableHandler = tableHandlers[tableName];
196
+ Object.entries(tableHandler).forEach(([event, handler]) => {
197
+ channel.on("broadcast", { event }, handler);
198
+ });
199
+ // Subscribe to the channel
200
+ channel.subscribe((status) => {
201
+ console.log(`[DB Listener] ${tableName} channel status:`, status);
202
+ if (status === "SUBSCRIBED") {
203
+ successCount++;
204
+ if (successCount === totalChannels) {
205
+ setConnectionState(ConnectionState.CONNECTED);
206
+ setRetryCount(0);
207
+ setLastError(null);
208
+ console.log("[DB Listener] โœ… All channels successfully subscribed");
209
+ }
210
+ }
211
+ else if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") {
212
+ console.error(`[DB Listener] ${tableName} channel failed to subscribe:`, status);
213
+ setLastError(`Channel subscription failed: ${status}`);
214
+ }
215
+ });
216
+ channels.current.push(channel);
133
217
  }
134
- catch (err) {
135
- console.warn("[DB Listener] โŒ Failed to set up realtime authentication:", err);
218
+ catch (error) {
219
+ console.error(`[DB Listener] Failed to set up ${tableName} channel:`, error);
136
220
  }
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);
221
+ }
222
+ return successCount > 0;
223
+ }, [cleanupChannels, createChannel, handlers]);
224
+ // Schedule reconnection with exponential backoff
225
+ const scheduleReconnection = useCallback(() => {
226
+ if (isDestroyedRef.current ||
227
+ retryCount >= RECONNECTION_CONFIG.MAX_RETRIES) {
228
+ console.log("[DB Listener] Max reconnection attempts reached or hook destroyed");
229
+ setConnectionState(ConnectionState.ERROR);
230
+ return;
231
+ }
232
+ const delay = calculateBackoffDelay(retryCount);
233
+ console.log(`[DB Listener] Scheduling reconnection attempt ${retryCount + 1} in ${delay}ms`);
234
+ reconnectTimeoutRef.current = setTimeout(() => {
235
+ if (!isDestroyedRef.current) {
236
+ initializeConnection();
237
+ }
238
+ }, delay);
239
+ }, [retryCount, calculateBackoffDelay]);
240
+ // Initialize connection
241
+ const initializeConnection = useCallback(async () => {
242
+ if (isDestroyedRef.current || isInitializingRef.current)
243
+ return;
244
+ isInitializingRef.current = true;
245
+ setConnectionState(ConnectionState.CONNECTING);
246
+ try {
247
+ console.log("[DB Listener] ๐Ÿ”„ Initializing connection...");
248
+ // Test database connection
249
+ const dbConnected = await testDbConnection();
250
+ if (!dbConnected) {
251
+ throw new Error("Database connection test failed");
266
252
  }
267
- catch (err) {
268
- console.error("[DB Listener] DB connection test failed:", err);
253
+ // Set up realtime authentication
254
+ const authSuccess = await setupRealtimeAuth();
255
+ if (!authSuccess) {
256
+ throw new Error("Realtime authentication failed");
269
257
  }
270
- };
271
- testDbConnection();
272
- console.log("=== END SCOUT DB LISTENER DEBUG ===");
258
+ // Set up channels
259
+ const channelsSuccess = await setupChannels();
260
+ if (!channelsSuccess) {
261
+ throw new Error("Channel setup failed");
262
+ }
263
+ console.log("[DB Listener] โœ… Connection initialized successfully");
264
+ }
265
+ catch (error) {
266
+ console.error("[DB Listener] โŒ Connection initialization failed:", error);
267
+ setLastError(error instanceof Error ? error.message : "Unknown error");
268
+ setConnectionState(ConnectionState.ERROR);
269
+ setRetryCount((prev) => prev + 1);
270
+ // Schedule reconnection
271
+ scheduleReconnection();
272
+ }
273
+ finally {
274
+ isInitializingRef.current = false;
275
+ }
276
+ }, [
277
+ testDbConnection,
278
+ setupRealtimeAuth,
279
+ setupChannels,
280
+ scheduleReconnection,
281
+ ]);
282
+ // Manual reconnection function
283
+ const reconnect = useCallback(() => {
284
+ if (isDestroyedRef.current)
285
+ return;
286
+ console.log("[DB Listener] ๐Ÿ”„ Manual reconnection requested");
287
+ cancelReconnection();
288
+ setRetryCount(0);
289
+ setLastError(null);
290
+ initializeConnection();
291
+ }, [cancelReconnection, initializeConnection]);
292
+ // Main effect
293
+ useEffect(() => {
294
+ console.log("=== SCOUT DB LISTENER INITIALIZATION ===");
295
+ if (!scoutSupabase) {
296
+ console.error("[DB Listener] No Supabase client available");
297
+ setConnectionState(ConnectionState.ERROR);
298
+ setLastError("No Supabase client available");
299
+ return;
300
+ }
301
+ supabase.current = scoutSupabase;
302
+ isDestroyedRef.current = false;
303
+ // Initialize connection
304
+ initializeConnection();
273
305
  // Cleanup function
274
306
  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 = [];
307
+ console.log("[DB Listener] ๐Ÿงน Cleaning up hook");
308
+ isDestroyedRef.current = true;
309
+ cancelReconnection();
310
+ cleanupChannels();
282
311
  };
283
- }, [scoutSupabase, dispatch]);
312
+ }, [
313
+ scoutSupabase,
314
+ initializeConnection,
315
+ cancelReconnection,
316
+ cleanupChannels,
317
+ ]);
318
+ // Return connection state and manual reconnect function
319
+ return {
320
+ connectionState,
321
+ lastError,
322
+ retryCount,
323
+ reconnect,
324
+ isConnected: connectionState === ConnectionState.CONNECTED,
325
+ isConnecting: connectionState === ConnectionState.CONNECTING,
326
+ };
284
327
  }
@@ -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,18 @@ export function ScoutRefreshProvider({ children }) {
23
32
  supabaseRef.current = createBrowserClient(url, anon_key);
24
33
  console.log("[ScoutRefreshProvider] Created Supabase client");
25
34
  }
35
+ // Use the enhanced DB listener with connection status
26
36
  useScoutDbListener(supabaseRef.current);
27
37
  useScoutRefresh();
38
+ // // Log connection status changes for debugging
39
+ // if (connectionStatus.lastError) {
40
+ // console.warn(
41
+ // "[ScoutRefreshProvider] DB Listener error:",
42
+ // connectionStatus.lastError
43
+ // );
44
+ // }
45
+ // if (connectionStatus.isConnected) {
46
+ // console.log("[ScoutRefreshProvider] โœ… DB Listener connected");
47
+ // }
28
48
  return (_jsx(SupabaseContext.Provider, { value: supabaseRef.current, children: children }));
29
49
  }
@@ -68,7 +68,10 @@ export const scoutSlice = createSlice({
68
68
  addTag(state, action) {
69
69
  for (const herd_module of state.herd_modules) {
70
70
  for (const event of herd_module.events) {
71
- if (event.id === action.payload.event_id && event.tags) {
71
+ if (event.id === action.payload.event_id) {
72
+ if (event.tags == undefined || event.tags == null) {
73
+ event.tags = [];
74
+ }
72
75
  event.tags.push(action.payload);
73
76
  return;
74
77
  }
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.38",
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",