@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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 (
|
|
124
|
-
console.
|
|
333
|
+
catch (error) {
|
|
334
|
+
console.error(`[DB Listener] Failed to set up ${config.name} channel:`, error);
|
|
125
335
|
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
268
|
-
|
|
391
|
+
// Set up channels
|
|
392
|
+
const channelsSuccess = await setupChannels();
|
|
393
|
+
if (!channelsSuccess) {
|
|
394
|
+
throw new Error("Channel setup failed");
|
|
269
395
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
}
|
|
280
|
-
});
|
|
281
|
-
channels.current = [];
|
|
440
|
+
console.log("[DB Listener] 🧹 Cleaning up hook");
|
|
441
|
+
isDestroyedRef.current = true;
|
|
442
|
+
cancelReconnection();
|
|
443
|
+
cleanupChannels();
|
|
282
444
|
};
|
|
283
|
-
}, [
|
|
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
|
-
|
|
35
|
+
// Use the enhanced DB listener with connection status
|
|
36
|
+
const connectionStatus = useScoutDbListener(supabaseRef.current);
|
|
27
37
|
useScoutRefresh();
|
|
28
|
-
|
|
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
|
}
|