@adventurelabs/scout-core 1.0.39 → 1.0.41

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.
@@ -1,2 +1,5 @@
1
1
  import { IApiKeyScout } from "../types/db";
2
2
  export declare function server_list_api_keys(device_id: string): Promise<IApiKeyScout[]>;
3
+ export declare function server_list_api_keys_batch(device_ids: number[]): Promise<{
4
+ [device_id: number]: IApiKeyScout[];
5
+ }>;
@@ -12,9 +12,36 @@ export async function server_list_api_keys(device_id) {
12
12
  return [];
13
13
  const data_to_return = [];
14
14
  for (let i = 0; i < data.length; i++) {
15
- // convert data to IApiKeyScout
16
15
  const converted_data = JSON.parse(data[i]);
17
16
  data_to_return.push(converted_data);
18
17
  }
19
18
  return data_to_return;
20
19
  }
20
+ export async function server_list_api_keys_batch(device_ids) {
21
+ console.log(`[API Keys Batch] Starting batch load for ${device_ids.length} devices`);
22
+ const supabase = await newServerClient();
23
+ const { data, error } = await supabase.rpc("load_api_keys_batch", {
24
+ device_ids: device_ids,
25
+ });
26
+ if (error) {
27
+ console.error("Error listing API keys in batch:", error.message);
28
+ return {};
29
+ }
30
+ if (!data)
31
+ return {};
32
+ console.log(`[API Keys Batch] Received ${data.length} API key records`);
33
+ const result = {};
34
+ // Group API keys by device_id
35
+ data.forEach((item) => {
36
+ const device_id = item.device_id;
37
+ if (!result[device_id]) {
38
+ result[device_id] = [];
39
+ }
40
+ result[device_id].push({
41
+ id: item.api_key_id.toString(),
42
+ key: item.api_key_key,
43
+ });
44
+ });
45
+ console.log(`[API Keys Batch] Returning API keys for ${Object.keys(result).length} devices`);
46
+ return result;
47
+ }
@@ -8,12 +8,8 @@ import { Database } from "../types/supabase";
8
8
  * @returns Promise<string | null> - The signed URL or null if error
9
9
  */
10
10
  export declare function generateSignedUrl(filePath: string, expiresIn?: number, supabaseClient?: SupabaseClient<Database>): Promise<string | null>;
11
- /**
12
- * Generates signed URLs for multiple events and sets them as media_url
13
- * @param events - Array of events that may have file_path
14
- * @param supabaseClient - Optional Supabase client (will create new one if not provided)
15
- * @returns Promise<Array> - Events with signed URLs set as media_url
16
- */
11
+ export declare function generateSignedUrlsBatch(filePaths: string[], expiresIn?: number, supabaseClient?: SupabaseClient<Database>): Promise<Map<string, string | null>>;
12
+ export declare function addSignedUrlsToEventsBatch(events: any[], supabaseClient?: SupabaseClient<Database>): Promise<any[]>;
17
13
  export declare function addSignedUrlsToEvents(events: any[], supabaseClient?: SupabaseClient<Database>): Promise<any[]>;
18
14
  /**
19
15
  * Generates a signed URL for a single event and sets it as media_url
@@ -25,26 +25,59 @@ export async function generateSignedUrl(filePath, expiresIn = 3600, supabaseClie
25
25
  return null;
26
26
  }
27
27
  }
28
- /**
29
- * Generates signed URLs for multiple events and sets them as media_url
30
- * @param events - Array of events that may have file_path
31
- * @param supabaseClient - Optional Supabase client (will create new one if not provided)
32
- * @returns Promise<Array> - Events with signed URLs set as media_url
33
- */
34
- export async function addSignedUrlsToEvents(events, supabaseClient) {
35
- const eventsWithSignedUrls = await Promise.all(events.map(async (event) => {
36
- // If event has a file_path, generate a signed URL and set it as media_url
37
- if (event.file_path) {
38
- const signedUrl = await generateSignedUrl(event.file_path, 3600, supabaseClient);
28
+ export async function generateSignedUrlsBatch(filePaths, expiresIn = 3600, supabaseClient) {
29
+ try {
30
+ const supabase = supabaseClient || (await newServerClient());
31
+ const urlMap = new Map();
32
+ const signedUrlPromises = filePaths.map(async (filePath) => {
33
+ try {
34
+ const { data, error } = await supabase.storage
35
+ .from(BUCKET_NAME_SCOUT)
36
+ .createSignedUrl(filePath, expiresIn);
37
+ if (error) {
38
+ console.error(`Error generating signed URL for ${filePath}:`, error.message);
39
+ return { filePath, signedUrl: null };
40
+ }
41
+ return { filePath, signedUrl: data.signedUrl };
42
+ }
43
+ catch (error) {
44
+ console.error(`Error in generateSignedUrl for ${filePath}:`, error);
45
+ return { filePath, signedUrl: null };
46
+ }
47
+ });
48
+ const results = await Promise.all(signedUrlPromises);
49
+ results.forEach(({ filePath, signedUrl }) => {
50
+ urlMap.set(filePath, signedUrl);
51
+ });
52
+ return urlMap;
53
+ }
54
+ catch (error) {
55
+ console.error("Error in generateSignedUrlsBatch:", error);
56
+ return new Map();
57
+ }
58
+ }
59
+ export async function addSignedUrlsToEventsBatch(events, supabaseClient) {
60
+ const filePaths = events
61
+ .map((event) => event.file_path)
62
+ .filter((path) => path)
63
+ .filter((path, index, array) => array.indexOf(path) === index);
64
+ if (filePaths.length === 0) {
65
+ return events;
66
+ }
67
+ const urlMap = await generateSignedUrlsBatch(filePaths, 3600, supabaseClient);
68
+ return events.map((event) => {
69
+ if (event.file_path && urlMap.has(event.file_path)) {
70
+ const signedUrl = urlMap.get(event.file_path);
39
71
  return {
40
72
  ...event,
41
- media_url: signedUrl || event.media_url, // Fall back to existing media_url if signed URL fails
73
+ media_url: signedUrl || event.media_url,
42
74
  };
43
75
  }
44
- // If no file_path, keep existing media_url
45
76
  return event;
46
- }));
47
- return eventsWithSignedUrls;
77
+ });
78
+ }
79
+ export async function addSignedUrlsToEvents(events, supabaseClient) {
80
+ return addSignedUrlsToEventsBatch(events, supabaseClient);
48
81
  }
49
82
  /**
50
83
  * Generates a signed URL for a single event and sets it as media_url
@@ -61,6 +94,5 @@ export async function addSignedUrlToEvent(event, supabaseClient) {
61
94
  media_url: signedUrl || event.media_url, // Fall back to existing media_url if signed URL fails
62
95
  };
63
96
  }
64
- // If no file_path, keep existing media_url
65
97
  return event;
66
98
  }
@@ -5,4 +5,7 @@ export declare function server_delete_tags_by_ids(tag_ids: number[]): Promise<IW
5
5
  export declare function server_update_tags(tags: ITag[]): Promise<IWebResponseCompatible<ITag[]>>;
6
6
  export declare function server_get_more_events_with_tags_by_herd(herd_id: number, offset: number, page_count?: number): Promise<IWebResponseCompatible<IEventWithTags[]>>;
7
7
  export declare function server_get_events_and_tags_for_device(device_id: number, limit?: number): Promise<IWebResponseCompatible<IEventWithTags[]>>;
8
+ export declare function server_get_events_and_tags_for_devices_batch(device_ids: number[], limit?: number): Promise<IWebResponseCompatible<{
9
+ [device_id: number]: IEventWithTags[];
10
+ }>>;
8
11
  export declare function get_event_and_tags_by_event_id(event_id: number): Promise<IWebResponseCompatible<IEventWithTags>>;
@@ -146,6 +146,62 @@ export async function server_get_events_and_tags_for_device(device_id, limit = 3
146
146
  const eventsWithSignedUrls = await addSignedUrlsToEvents(data || [], supabase);
147
147
  return IWebResponse.success(eventsWithSignedUrls).to_compatible();
148
148
  }
149
+ export async function server_get_events_and_tags_for_devices_batch(device_ids, limit = 1) {
150
+ console.log(`[Events Batch] Starting batch load for ${device_ids.length} devices (limit: ${limit})`);
151
+ const supabase = await newServerClient();
152
+ // Use single RPC call for all devices
153
+ const { data, error } = await supabase.rpc("get_events_and_tags_for_devices_batch", {
154
+ device_ids: device_ids,
155
+ limit_per_device: limit,
156
+ });
157
+ if (error) {
158
+ console.error("Error fetching events for devices in batch:", error.message);
159
+ return {
160
+ status: EnumWebResponse.ERROR,
161
+ msg: error.message,
162
+ data: {},
163
+ };
164
+ }
165
+ if (!data)
166
+ return IWebResponse.success({}).to_compatible();
167
+ console.log(`[Events Batch] Received ${data.length} event records`);
168
+ // Group events by device_id
169
+ const eventsByDevice = {};
170
+ data.forEach((row) => {
171
+ const device_id = row.device_id;
172
+ if (!eventsByDevice[device_id]) {
173
+ eventsByDevice[device_id] = [];
174
+ }
175
+ // Create event object from the new structure
176
+ const event = {
177
+ id: row.event_id,
178
+ inserted_at: row.inserted_at,
179
+ message: row.message,
180
+ media_url: row.media_url,
181
+ file_path: row.file_path,
182
+ latitude: row.latitude,
183
+ longitude: row.longitude,
184
+ altitude: row.altitude,
185
+ heading: row.heading,
186
+ media_type: row.media_type,
187
+ device_id: device_id,
188
+ timestamp_observation: row.timestamp_observation,
189
+ is_public: row.is_public,
190
+ earthranger_url: row.earthranger_url,
191
+ tags: Array.isArray(row.tags) ? row.tags : [],
192
+ };
193
+ eventsByDevice[device_id].push(event);
194
+ });
195
+ // Add signed URLs to all events
196
+ const result = {};
197
+ for (const device_id in eventsByDevice) {
198
+ const events = eventsByDevice[device_id];
199
+ const eventsWithSignedUrls = await addSignedUrlsToEvents(events, supabase);
200
+ result[parseInt(device_id)] = eventsWithSignedUrls;
201
+ }
202
+ console.log(`[Events Batch] Returning events for ${Object.keys(result).length} devices`);
203
+ return IWebResponse.success(result).to_compatible();
204
+ }
149
205
  export async function get_event_and_tags_by_event_id(event_id) {
150
206
  const supabase = await newServerClient();
151
207
  // use actual sql query to get event and tags instead of rpc
@@ -7,59 +7,12 @@ declare enum ConnectionState {
7
7
  ERROR = "error"
8
8
  }
9
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
10
+ * Hook for listening to real-time database changes
20
11
  */
21
12
  export declare function useScoutDbListener(scoutSupabase: SupabaseClient<Database>): {
22
13
  connectionState: ConnectionState;
23
14
  lastError: string | null;
24
- retryCount: number;
25
- reconnect: () => void;
26
15
  isConnected: boolean;
27
16
  isConnecting: boolean;
28
17
  };
29
18
  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,7 +1,7 @@
1
1
  "use client";
2
2
  import { useAppDispatch } from "../store/hooks";
3
- import { useEffect, useRef, useState, useCallback } from "react";
4
- import { addDevice, addPlan, addTag, addSessionToStore, deleteDevice, deletePlan, deleteSessionFromStore, deleteTag, updateDevice, updatePlan, updateSessionInStore, updateTag, } from "../store/scout";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { addDevice, addPlan, addTag, deleteDevice, deletePlan, deleteTag, updateDevice, updatePlan, updateTag, } from "../store/scout";
5
5
  // Connection state enum
6
6
  var ConnectionState;
7
7
  (function (ConnectionState) {
@@ -10,52 +10,20 @@ var ConnectionState;
10
10
  ConnectionState["CONNECTED"] = "connected";
11
11
  ConnectionState["ERROR"] = "error";
12
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
13
  /**
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
14
+ * Hook for listening to real-time database changes
32
15
  */
33
16
  export function useScoutDbListener(scoutSupabase) {
34
- const supabase = useRef(null);
35
17
  const channels = useRef([]);
36
18
  const dispatch = useAppDispatch();
37
- // Connection state management
38
19
  const [connectionState, setConnectionState] = useState(ConnectionState.DISCONNECTED);
39
20
  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
21
  // Clean up all channels
53
- const cleanupChannels = useCallback(() => {
54
- console.log("[DB Listener] 🧹 Cleaning up channels");
22
+ const cleanupChannels = () => {
55
23
  channels.current.forEach((channel) => {
56
- if (channel && supabase.current) {
24
+ if (channel && scoutSupabase) {
57
25
  try {
58
- supabase.current.removeChannel(channel);
26
+ scoutSupabase.removeChannel(channel);
59
27
  }
60
28
  catch (error) {
61
29
  console.warn("[DB Listener] Error removing channel:", error);
@@ -63,153 +31,91 @@ export function useScoutDbListener(scoutSupabase) {
63
31
  }
64
32
  });
65
33
  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;
72
- }
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;
86
- }
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;
117
- }
118
- action(data);
119
- };
120
- }, []);
121
- // Create event handlers using the factory
122
- const handlers = useCallback(() => ({
34
+ };
35
+ // Create event handlers
36
+ const handlers = {
123
37
  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"),
38
+ INSERT: (payload) => {
39
+ if (payload.new)
40
+ dispatch(addTag(payload.new));
41
+ },
42
+ UPDATE: (payload) => {
43
+ if (payload.new)
44
+ dispatch(updateTag(payload.new));
45
+ },
46
+ DELETE: (payload) => {
47
+ if (payload.old)
48
+ dispatch(deleteTag(payload.old));
49
+ },
127
50
  },
128
51
  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"),
52
+ INSERT: (payload) => {
53
+ if (payload.new)
54
+ dispatch(addDevice(payload.new));
55
+ },
56
+ UPDATE: (payload) => {
57
+ if (payload.new)
58
+ dispatch(updateDevice(payload.new));
59
+ },
60
+ DELETE: (payload) => {
61
+ if (payload.old)
62
+ dispatch(deleteDevice(payload.old));
63
+ },
132
64
  },
133
65
  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"),
66
+ INSERT: (payload) => {
67
+ if (payload.new)
68
+ dispatch(addPlan(payload.new));
69
+ },
70
+ UPDATE: (payload) => {
71
+ if (payload.new)
72
+ dispatch(updatePlan(payload.new));
73
+ },
74
+ DELETE: (payload) => {
75
+ if (payload.old)
76
+ dispatch(deletePlan(payload.old));
77
+ },
137
78
  },
138
79
  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"),
80
+ INSERT: (payload) => console.log("[DB Listener] Session INSERT:", payload),
81
+ UPDATE: (payload) => console.log("[DB Listener] Session UPDATE:", payload),
82
+ DELETE: (payload) => console.log("[DB Listener] Session DELETE:", payload),
142
83
  },
143
84
  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),
85
+ INSERT: (payload) => console.log("[DB Listener] Connectivity INSERT:", payload),
86
+ UPDATE: (payload) => console.log("[DB Listener] Connectivity UPDATE:", payload),
87
+ DELETE: (payload) => console.log("[DB Listener] Connectivity DELETE:", payload),
147
88
  },
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)
89
+ };
90
+ // Set up channels
91
+ const setupChannels = async () => {
92
+ if (!scoutSupabase)
183
93
  return false;
184
94
  cleanupChannels();
185
- const tableHandlers = handlers();
186
- const tables = Object.keys(tableHandlers);
95
+ const tables = Object.keys(handlers);
187
96
  let successCount = 0;
188
97
  const totalChannels = tables.length;
189
98
  for (const tableName of tables) {
190
- const channel = createChannel(tableName);
191
- if (!channel)
192
- continue;
193
99
  try {
100
+ const channelName = `scout_broadcast_${tableName}_${Date.now()}`;
101
+ const channel = scoutSupabase.channel(channelName, {
102
+ config: { private: false },
103
+ });
194
104
  // Set up event handlers
195
- const tableHandler = tableHandlers[tableName];
105
+ const tableHandler = handlers[tableName];
196
106
  Object.entries(tableHandler).forEach(([event, handler]) => {
197
107
  channel.on("broadcast", { event }, handler);
198
108
  });
199
109
  // Subscribe to the channel
200
110
  channel.subscribe((status) => {
201
- console.log(`[DB Listener] ${tableName} channel status:`, status);
202
111
  if (status === "SUBSCRIBED") {
203
112
  successCount++;
204
113
  if (successCount === totalChannels) {
205
114
  setConnectionState(ConnectionState.CONNECTED);
206
- setRetryCount(0);
207
115
  setLastError(null);
208
- console.log("[DB Listener] ✅ All channels successfully subscribed");
209
116
  }
210
117
  }
211
118
  else if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") {
212
- console.error(`[DB Listener] ${tableName} channel failed to subscribe:`, status);
213
119
  setLastError(`Channel subscription failed: ${status}`);
214
120
  }
215
121
  });
@@ -220,107 +126,44 @@ export function useScoutDbListener(scoutSupabase) {
220
126
  }
221
127
  }
222
128
  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]);
129
+ };
240
130
  // Initialize connection
241
- const initializeConnection = useCallback(async () => {
242
- if (isDestroyedRef.current || isInitializingRef.current)
131
+ const initializeConnection = async () => {
132
+ if (!scoutSupabase)
243
133
  return;
244
- isInitializingRef.current = true;
245
134
  setConnectionState(ConnectionState.CONNECTING);
246
135
  try {
247
- console.log("[DB Listener] 🔄 Initializing connection...");
248
136
  // Test database connection
249
- const dbConnected = await testDbConnection();
250
- if (!dbConnected) {
137
+ const { error } = await scoutSupabase.from("tags").select("id").limit(1);
138
+ if (error) {
251
139
  throw new Error("Database connection test failed");
252
140
  }
253
- // Set up realtime authentication
254
- const authSuccess = await setupRealtimeAuth();
255
- if (!authSuccess) {
256
- throw new Error("Realtime authentication failed");
257
- }
258
141
  // Set up channels
259
- const channelsSuccess = await setupChannels();
260
- if (!channelsSuccess) {
142
+ const success = await setupChannels();
143
+ if (!success) {
261
144
  throw new Error("Channel setup failed");
262
145
  }
263
- console.log("[DB Listener] ✅ Connection initialized successfully");
264
146
  }
265
147
  catch (error) {
266
- console.error("[DB Listener] ❌ Connection initialization failed:", error);
267
148
  setLastError(error instanceof Error ? error.message : "Unknown error");
268
149
  setConnectionState(ConnectionState.ERROR);
269
- setRetryCount((prev) => prev + 1);
270
- // Schedule reconnection
271
- scheduleReconnection();
272
150
  }
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]);
151
+ };
292
152
  // Main effect
293
153
  useEffect(() => {
294
- console.log("=== SCOUT DB LISTENER INITIALIZATION ===");
295
154
  if (!scoutSupabase) {
296
- console.error("[DB Listener] No Supabase client available");
297
155
  setConnectionState(ConnectionState.ERROR);
298
156
  setLastError("No Supabase client available");
299
157
  return;
300
158
  }
301
- supabase.current = scoutSupabase;
302
- isDestroyedRef.current = false;
303
- // Initialize connection
304
159
  initializeConnection();
305
- // Cleanup function
306
160
  return () => {
307
- console.log("[DB Listener] 🧹 Cleaning up hook");
308
- isDestroyedRef.current = true;
309
- cancelReconnection();
310
161
  cleanupChannels();
311
162
  };
312
- }, [
313
- scoutSupabase,
314
- initializeConnection,
315
- cancelReconnection,
316
- cleanupChannels,
317
- ]);
318
- // Return connection state and manual reconnect function
163
+ }, [scoutSupabase]);
319
164
  return {
320
165
  connectionState,
321
166
  lastError,
322
- retryCount,
323
- reconnect,
324
167
  isConnected: connectionState === ConnectionState.CONNECTED,
325
168
  isConnecting: connectionState === ConnectionState.CONNECTING,
326
169
  };
package/dist/index.d.ts CHANGED
@@ -33,7 +33,6 @@ export * from "./hooks/useScoutRefresh";
33
33
  export * from "./providers";
34
34
  export * from "./store/scout";
35
35
  export * from "./store/hooks";
36
- export * from "./supabase/client";
37
36
  export * from "./supabase/middleware";
38
37
  export * from "./supabase/server";
39
38
  export * from "./api_keys/actions";
package/dist/index.js CHANGED
@@ -40,7 +40,6 @@ export * from "./providers";
40
40
  export * from "./store/scout";
41
41
  export * from "./store/hooks";
42
42
  // Supabase
43
- export * from "./supabase/client";
44
43
  export * from "./supabase/middleware";
45
44
  export * from "./supabase/server";
46
45
  // API Keys
@@ -744,6 +744,40 @@ export declare function useSupabase(): SupabaseClient<Database, "public", {
744
744
  };
745
745
  Returns: string[];
746
746
  };
747
+ load_api_keys_batch: {
748
+ Args: {
749
+ device_ids: number[];
750
+ };
751
+ Returns: {
752
+ device_id: number;
753
+ api_key_id: number;
754
+ api_key_key: string;
755
+ }[];
756
+ };
757
+ get_events_and_tags_for_devices_batch: {
758
+ Args: {
759
+ device_ids: number[];
760
+ limit_per_device: number;
761
+ };
762
+ Returns: {
763
+ event_id: number;
764
+ inserted_at: string;
765
+ message: string | null;
766
+ media_url: string | null;
767
+ file_path: string | null;
768
+ latitude: number | null;
769
+ longitude: number | null;
770
+ earthranger_url: string | null;
771
+ altitude: number;
772
+ heading: number;
773
+ media_type: string;
774
+ device_id: number;
775
+ timestamp_observation: string;
776
+ is_public: boolean;
777
+ tags: import("../types/supabase").Json;
778
+ herd_id: number | null;
779
+ }[];
780
+ };
747
781
  };
748
782
  Enums: {
749
783
  app_permission: "herds.delete" | "events.delete";
@@ -1,3 +1,2 @@
1
- export * from "./client";
2
1
  export * from "./middleware";
3
2
  export * from "./server";
@@ -1,3 +1,2 @@
1
- export * from "./client";
2
1
  export * from "./middleware";
3
2
  export * from "./server";
@@ -735,6 +735,40 @@ export declare function newServerClient(): Promise<import("@supabase/supabase-js
735
735
  };
736
736
  Returns: string[];
737
737
  };
738
+ load_api_keys_batch: {
739
+ Args: {
740
+ device_ids: number[];
741
+ };
742
+ Returns: {
743
+ device_id: number;
744
+ api_key_id: number;
745
+ api_key_key: string;
746
+ }[];
747
+ };
748
+ get_events_and_tags_for_devices_batch: {
749
+ Args: {
750
+ device_ids: number[];
751
+ limit_per_device: number;
752
+ };
753
+ Returns: {
754
+ event_id: number;
755
+ inserted_at: string;
756
+ message: string | null;
757
+ media_url: string | null;
758
+ file_path: string | null;
759
+ latitude: number | null;
760
+ longitude: number | null;
761
+ earthranger_url: string | null;
762
+ altitude: number;
763
+ heading: number;
764
+ media_type: string;
765
+ device_id: number;
766
+ timestamp_observation: string;
767
+ is_public: boolean;
768
+ tags: import("../types/supabase").Json;
769
+ herd_id: number | null;
770
+ }[];
771
+ };
738
772
  };
739
773
  Enums: {
740
774
  app_permission: "herds.delete" | "events.delete";
@@ -55,7 +55,6 @@ export type IUserAndRole = {
55
55
  };
56
56
  export interface IApiKeyScout {
57
57
  id: string;
58
- description: string;
59
58
  key: string;
60
59
  }
61
60
  export type Tag = ITag;
@@ -3,11 +3,11 @@ import { get_devices_by_herd } from "../helpers/devices";
3
3
  import { server_get_total_events_by_herd } from "../helpers/events";
4
4
  import { EnumSessionsVisibility } from "./events";
5
5
  import { server_get_plans_by_herd } from "../helpers/plans";
6
- import { server_get_events_and_tags_for_device } from "../helpers/tags";
6
+ import { server_get_events_and_tags_for_devices_batch, } from "../helpers/tags";
7
7
  import { server_get_users_with_herd_access } from "../helpers/users";
8
8
  import { EnumWebResponse } from "./requests";
9
9
  import { server_get_more_zones_and_actions_for_herd } from "../helpers/zones";
10
- import { server_list_api_keys } from "../api_keys/actions";
10
+ import { server_list_api_keys_batch } from "../api_keys/actions";
11
11
  import { getSessionsByHerdId } from "../helpers/sessions";
12
12
  export class HerdModule {
13
13
  constructor(herd, devices, events, timestamp_last_refreshed, user_roles = null, events_page_index = 0, total_events = 0, total_events_with_filters = 0, labels = [], plans = [], zones = [], sessions = []) {
@@ -56,31 +56,37 @@ export class HerdModule {
56
56
  return new HerdModule(herd, [], [], Date.now());
57
57
  }
58
58
  const new_devices = response_new_devices.data;
59
- // get api keys for each device... run requests in parallel
59
+ // get api keys and events for all devices in batch
60
+ let recent_events_batch = {};
60
61
  if (new_devices.length > 0) {
61
62
  try {
62
- let api_keys_promises = new_devices.map((device) => server_list_api_keys(device.id?.toString() ?? "").catch((error) => {
63
- console.warn(`Failed to get API keys for device ${device.id}:`, error);
64
- return undefined;
65
- }));
66
- let api_keys = await Promise.all(api_keys_promises);
63
+ const device_ids = new_devices.map((device) => device.id ?? 0);
64
+ console.log(`[HerdModule] Loading batch data for ${device_ids.length} devices:`, device_ids);
65
+ // Load API keys and events in parallel
66
+ const [api_keys_batch, events_response] = await Promise.all([
67
+ server_list_api_keys_batch(device_ids),
68
+ server_get_events_and_tags_for_devices_batch(device_ids, 1),
69
+ ]);
70
+ // Assign API keys to devices
67
71
  for (let i = 0; i < new_devices.length; i++) {
68
- new_devices[i].api_keys_scout = api_keys[i];
72
+ const device_id = new_devices[i].id ?? 0;
73
+ new_devices[i].api_keys_scout = api_keys_batch[device_id] || [];
74
+ }
75
+ console.log(`[HerdModule] API keys loaded for ${Object.keys(api_keys_batch).length} devices`);
76
+ // Process events response
77
+ if (events_response.status === EnumWebResponse.SUCCESS &&
78
+ events_response.data) {
79
+ recent_events_batch = events_response.data;
80
+ console.log(`[HerdModule] Events loaded for ${Object.keys(recent_events_batch).length} devices`);
69
81
  }
70
82
  }
71
83
  catch (error) {
72
- console.warn("Failed to load API keys for devices:", error);
73
- // Continue without API keys
84
+ console.warn("Failed to load API keys or events for devices:", error);
85
+ // Continue without API keys and events
74
86
  }
75
87
  }
76
- // get recent events for each device... run requests in parallel
77
- let recent_events_promises = new_devices.map((device) => server_get_events_and_tags_for_device(device.id ?? 0).catch((error) => {
78
- console.warn(`Failed to get events for device ${device.id}:`, error);
79
- return { status: EnumWebResponse.ERROR, data: null };
80
- }));
81
- // Run all requests in parallel with individual error handling
82
- const [recent_events, res_zones, res_user_roles, total_event_count, res_plans, res_sessions,] = await Promise.allSettled([
83
- Promise.all(recent_events_promises),
88
+ // Run all remaining requests in parallel with individual error handling
89
+ const [res_zones, res_user_roles, total_event_count, res_plans, res_sessions,] = await Promise.allSettled([
84
90
  server_get_more_zones_and_actions_for_herd(herd.id, 0, 10).catch((error) => {
85
91
  console.warn("Failed to get zones and actions:", error);
86
92
  return { status: EnumWebResponse.ERROR, data: null };
@@ -102,20 +108,18 @@ export class HerdModule {
102
108
  return [];
103
109
  }),
104
110
  ]);
105
- // Process recent events with error handling
106
- if (recent_events.status === "fulfilled") {
107
- for (let i = 0; i < new_devices.length; i++) {
108
- try {
109
- let x = recent_events.value[i]?.data;
110
- if (recent_events.value[i]?.status == EnumWebResponse.SUCCESS &&
111
- x) {
112
- new_devices[i].recent_events = x;
113
- }
114
- }
115
- catch (error) {
116
- console.warn(`Failed to process events for device ${new_devices[i].id}:`, error);
111
+ // Assign recent events to devices from batch results
112
+ for (let i = 0; i < new_devices.length; i++) {
113
+ try {
114
+ const device_id = new_devices[i].id ?? 0;
115
+ const events = recent_events_batch[device_id];
116
+ if (events) {
117
+ new_devices[i].recent_events = events;
117
118
  }
118
119
  }
120
+ catch (error) {
121
+ console.warn(`Failed to process events for device ${new_devices[i].id}:`, error);
122
+ }
119
123
  }
120
124
  // Extract data with safe fallbacks
121
125
  const zones = res_zones.status === "fulfilled" && res_zones.value?.data
@@ -802,6 +802,40 @@ export type Database = {
802
802
  };
803
803
  Returns: string[];
804
804
  };
805
+ load_api_keys_batch: {
806
+ Args: {
807
+ device_ids: number[];
808
+ };
809
+ Returns: {
810
+ device_id: number;
811
+ api_key_id: number;
812
+ api_key_key: string;
813
+ }[];
814
+ };
815
+ get_events_and_tags_for_devices_batch: {
816
+ Args: {
817
+ device_ids: number[];
818
+ limit_per_device: number;
819
+ };
820
+ Returns: {
821
+ event_id: number;
822
+ inserted_at: string;
823
+ message: string | null;
824
+ media_url: string | null;
825
+ file_path: string | null;
826
+ latitude: number | null;
827
+ longitude: number | null;
828
+ earthranger_url: string | null;
829
+ altitude: number;
830
+ heading: number;
831
+ media_type: string;
832
+ device_id: number;
833
+ timestamp_observation: string;
834
+ is_public: boolean;
835
+ tags: Json;
836
+ herd_id: number | null;
837
+ }[];
838
+ };
805
839
  };
806
840
  Enums: {
807
841
  app_permission: "herds.delete" | "events.delete";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adventurelabs/scout-core",
3
- "version": "1.0.39",
3
+ "version": "1.0.41",
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",
@@ -1 +0,0 @@
1
- export declare function newClient(): Promise<import("@supabase/supabase-js").SupabaseClient<any, "public", any>>;
@@ -1,5 +0,0 @@
1
- import { createClient } from "@supabase/supabase-js";
2
- export async function newClient() {
3
- const client = await createClient(process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY);
4
- return client;
5
- }