@adventurelabs/scout-core 2.0.18 → 2.1.0

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.
Files changed (48) hide show
  1. package/dist/client/index.d.ts +4 -0
  2. package/dist/client/index.js +4 -0
  3. package/dist/helpers/cache.d.ts +4 -4
  4. package/dist/helpers/cache.js +52 -17
  5. package/dist/helpers/eventMedia.d.ts +6 -4
  6. package/dist/helpers/eventMedia.js +7 -6
  7. package/dist/helpers/gallery.d.ts +4 -0
  8. package/dist/helpers/gallery.js +5 -0
  9. package/dist/helpers/gallery.queries.d.ts +14 -0
  10. package/dist/helpers/gallery.queries.js +49 -0
  11. package/dist/helpers/galleryMedia.d.ts +9 -0
  12. package/dist/helpers/galleryMedia.js +41 -0
  13. package/dist/helpers/herd_modules.queries.js +14 -6
  14. package/dist/helpers/mediaRows.d.ts +12 -0
  15. package/dist/helpers/mediaRows.js +80 -0
  16. package/dist/helpers/mediaState.d.ts +33 -0
  17. package/dist/helpers/mediaState.js +86 -0
  18. package/dist/helpers/media_urls.d.ts +2 -1
  19. package/dist/helpers/media_urls.js +13 -2
  20. package/dist/helpers/parts.d.ts +7 -2
  21. package/dist/helpers/parts.js +16 -7
  22. package/dist/helpers/parts_server.d.ts +1 -1
  23. package/dist/helpers/product_catalog.d.ts +9 -0
  24. package/dist/helpers/product_catalog.js +36 -0
  25. package/dist/helpers/session_incidents_server.d.ts +5 -0
  26. package/dist/helpers/storage_internal.d.ts +4 -3
  27. package/dist/helpers/storage_internal.js +46 -14
  28. package/dist/helpers/tags.queries.js +2 -0
  29. package/dist/hooks/index.d.ts +1 -1
  30. package/dist/hooks/index.js +1 -1
  31. package/dist/hooks/useInfiniteQuery.d.ts +3 -1
  32. package/dist/hooks/useInfiniteQuery.js +37 -43
  33. package/dist/hooks/useScoutRealtimeBroadcast.d.ts +4 -0
  34. package/dist/hooks/useScoutRealtimeBroadcast.js +76 -11
  35. package/dist/hooks/useScoutRefresh.js +18 -11
  36. package/dist/server/index.d.ts +5 -0
  37. package/dist/server/index.js +4 -0
  38. package/dist/store/api.d.ts +389 -9
  39. package/dist/store/api.js +163 -75
  40. package/dist/store/configureStore.d.ts +4 -0
  41. package/dist/store/hooks.d.ts +4 -1
  42. package/dist/store/hooks.js +16 -0
  43. package/dist/store/scout.d.ts +4 -2
  44. package/dist/store/scout.js +21 -2
  45. package/dist/types/db.d.ts +19 -5
  46. package/dist/types/herd_module.d.ts +12 -1
  47. package/dist/types/supabase.d.ts +77 -0
  48. package/package.json +4 -2
@@ -10,18 +10,27 @@ const OPERATIONS = {
10
10
  };
11
11
  const DEFAULT_BACKOFF_INITIAL_MS = 1000;
12
12
  const DEFAULT_BACKOFF_MAX_MS = 60000;
13
+ /** A channel still joining reports its own outcome, so only a dead one needs a rejoin. */
14
+ const HEALTHY_CHANNEL_STATES = new Set([
15
+ "joined",
16
+ "joining",
17
+ ]);
13
18
  function isRealtimeAuthError(error) {
14
19
  const message = error?.message ?? "";
15
20
  return /unauthorized|permission/i.test(message);
16
21
  }
22
+ /** Jittered, so a server blip does not bring every topic and every client back in lockstep. */
17
23
  function backoffDelayMs(attempt, initialMs, maxMs) {
18
- return Math.min(maxMs, initialMs * 2 ** Math.max(0, attempt));
24
+ const ceiling = Math.min(maxMs, initialMs * 2 ** Math.max(0, attempt));
25
+ return ceiling * (0.5 + Math.random() * 0.5);
19
26
  }
20
27
  export function useScoutRealtimeBroadcast(scoutSupabase, topics, options = {}, rowFilter) {
21
- const { authErrorRetry = "stop", backoffInitialMs = DEFAULT_BACKOFF_INITIAL_MS, backoffMaxMs = DEFAULT_BACKOFF_MAX_MS, backoffMaxAttempts = Number.POSITIVE_INFINITY, } = options;
28
+ const { authErrorRetry = "stop", backoffInitialMs = DEFAULT_BACKOFF_INITIAL_MS, backoffMaxMs = DEFAULT_BACKOFF_MAX_MS, backoffMaxAttempts = Number.POSITIVE_INFINITY, onMissedUpdates, recoverOnFocus = true, } = options;
22
29
  const [latestUpdate, setLatestUpdate] = useState(null);
23
30
  const rowFilterRef = useRef(rowFilter);
24
31
  rowFilterRef.current = rowFilter;
32
+ const onMissedUpdatesRef = useRef(onMissedUpdates);
33
+ onMissedUpdatesRef.current = onMissedUpdates;
25
34
  const subscribedTopics = useAuthorizedRealtimeTopics(topics);
26
35
  const clearLatestUpdate = useCallback(() => setLatestUpdate(null), []);
27
36
  useEffect(() => {
@@ -31,7 +40,12 @@ export function useScoutRealtimeBroadcast(scoutSupabase, topics, options = {}, r
31
40
  let cancelled = false;
32
41
  const channels = new Map();
33
42
  const authAttempts = new Map();
43
+ const dropAttempts = new Map();
34
44
  const blockedTopics = new Set();
45
+ /** Topics being rejoined after a drop, so their next join reports missed updates. */
46
+ const droppedTopics = new Set();
47
+ /** Topics a channel already exists for, which is what tells a drop from a first join. */
48
+ const startedTopics = new Set();
35
49
  const retryTimers = new Map();
36
50
  const removeTopicChannel = (topic) => {
37
51
  const channel = channels.get(topic);
@@ -40,13 +54,13 @@ export function useScoutRealtimeBroadcast(scoutSupabase, topics, options = {}, r
40
54
  channels.delete(topic);
41
55
  void scoutSupabase.removeChannel(channel);
42
56
  };
43
- const scheduleRetry = (topic) => {
44
- if (authErrorRetry !== "backoff" || cancelled)
57
+ const scheduleRejoin = (topic, attempts) => {
58
+ if (cancelled)
45
59
  return;
46
- const attempt = authAttempts.get(topic) ?? 0;
60
+ const attempt = attempts.get(topic) ?? 0;
47
61
  if (attempt >= backoffMaxAttempts)
48
62
  return;
49
- authAttempts.set(topic, attempt + 1);
63
+ attempts.set(topic, attempt + 1);
50
64
  const existing = retryTimers.get(topic);
51
65
  if (existing)
52
66
  clearTimeout(existing);
@@ -71,20 +85,39 @@ export function useScoutRealtimeBroadcast(scoutSupabase, topics, options = {}, r
71
85
  setLatestUpdate({ data: row, operation, table: change.table });
72
86
  });
73
87
  channels.set(topic, channel);
88
+ startedTopics.add(topic);
74
89
  channel.subscribe((status, error) => {
75
90
  if (status === "SUBSCRIBED") {
76
91
  authAttempts.delete(topic);
92
+ dropAttempts.delete(topic);
77
93
  blockedTopics.delete(topic);
94
+ if (droppedTopics.delete(topic))
95
+ onMissedUpdatesRef.current?.();
78
96
  return;
79
97
  }
80
- if (status !== "CHANNEL_ERROR")
98
+ // A status for a channel we replaced or tore down says nothing about the topic now.
99
+ if (cancelled || channels.get(topic) !== channel)
81
100
  return;
82
- console.warn(`[scout-core realtime] 🟡 ${topic} unavailable`, error?.message ?? "");
83
- if (!isRealtimeAuthError(error) || cancelled)
101
+ if (status === "CHANNEL_ERROR" && isRealtimeAuthError(error)) {
102
+ console.warn(`[scout-core realtime] 🟡 ${topic} unavailable`, error?.message ?? "");
103
+ blockedTopics.add(topic);
104
+ removeTopicChannel(topic);
105
+ // Rows published while unauthorized are gone, so a rejoin still owes a refetch.
106
+ droppedTopics.add(topic);
107
+ if (authErrorRetry === "backoff")
108
+ scheduleRejoin(topic, authAttempts);
84
109
  return;
85
- blockedTopics.add(topic);
110
+ }
111
+ if (status !== "CHANNEL_ERROR" &&
112
+ status !== "CLOSED" &&
113
+ status !== "TIMED_OUT") {
114
+ return;
115
+ }
116
+ // Rejoining is the only way to see rows published during a transport drop.
117
+ console.warn(`[scout-core realtime] 🟡 ${topic} dropped (${status})`, error?.message ?? "");
86
118
  removeTopicChannel(topic);
87
- scheduleRetry(topic);
119
+ droppedTopics.add(topic);
120
+ scheduleRejoin(topic, dropAttempts);
88
121
  });
89
122
  };
90
123
  async function authenticateAndJoin(topicsToJoin) {
@@ -116,10 +149,41 @@ export function useScoutRealtimeBroadcast(scoutSupabase, topics, options = {}, r
116
149
  retryBlockedTopics();
117
150
  }
118
151
  });
152
+ /** A socket that died while hidden reports nothing, so returning rejoins dead topics. */
153
+ const recoverDroppedTopics = () => {
154
+ const offline = typeof navigator !== "undefined" && !navigator.onLine;
155
+ if (cancelled || offline)
156
+ return;
157
+ const toRejoin = subscribedTopics.filter((topic) => startedTopics.has(topic) &&
158
+ !blockedTopics.has(topic) &&
159
+ !HEALTHY_CHANNEL_STATES.has(channels.get(topic)?.state ?? "") &&
160
+ !retryTimers.has(topic));
161
+ if (toRejoin.length === 0)
162
+ return;
163
+ toRejoin.forEach((topic) => {
164
+ removeTopicChannel(topic);
165
+ droppedTopics.add(topic);
166
+ dropAttempts.delete(topic);
167
+ });
168
+ void authenticateAndJoin(toRejoin);
169
+ };
170
+ const handleVisibilityChange = () => {
171
+ if (document.visibilityState === "visible")
172
+ recoverDroppedTopics();
173
+ };
174
+ const listeningForFocus = recoverOnFocus && typeof window !== "undefined";
175
+ if (listeningForFocus) {
176
+ window.addEventListener("online", recoverDroppedTopics);
177
+ document.addEventListener("visibilitychange", handleVisibilityChange);
178
+ }
119
179
  void authenticateAndJoin(subscribedTopics);
120
180
  return () => {
121
181
  cancelled = true;
122
182
  unsubscribeAuth();
183
+ if (listeningForFocus) {
184
+ window.removeEventListener("online", recoverDroppedTopics);
185
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
186
+ }
123
187
  retryTimers.forEach((timer) => clearTimeout(timer));
124
188
  retryTimers.clear();
125
189
  channels.forEach((channel) => {
@@ -134,6 +198,7 @@ export function useScoutRealtimeBroadcast(scoutSupabase, topics, options = {}, r
134
198
  backoffInitialMs,
135
199
  backoffMaxMs,
136
200
  backoffMaxAttempts,
201
+ recoverOnFocus,
137
202
  ]);
138
203
  return [latestUpdate, clearLatestUpdate];
139
204
  }
@@ -100,7 +100,7 @@ export function useScoutRefresh(options = {}, identityOptions = {}) {
100
100
  const isCurrent = () => refreshIdRef.current === refreshId;
101
101
  const startTime = Date.now();
102
102
  const isOffline = typeof navigator === "undefined" || !navigator.onLine;
103
- let cachedHerdModules = null;
103
+ let cachedSnapshot = null;
104
104
  let cacheReady = false;
105
105
  const complete = (loadingState) => {
106
106
  if (!isCurrent())
@@ -114,7 +114,7 @@ export function useScoutRefresh(options = {}, identityOptions = {}) {
114
114
  if (activeUserIdRef.current &&
115
115
  activeUserIdRef.current !== userId) {
116
116
  resetClientState();
117
- cachedHerdModules = null;
117
+ cachedSnapshot = null;
118
118
  }
119
119
  activeUserIdRef.current = userId;
120
120
  };
@@ -141,7 +141,7 @@ export function useScoutRefresh(options = {}, identityOptions = {}) {
141
141
  if (cacheResult.data === null) {
142
142
  return;
143
143
  }
144
- cachedHerdModules = cacheResult.data;
144
+ cachedSnapshot = cacheResult.data;
145
145
  dispatch(setDataSource(EnumDataSource.CACHE));
146
146
  dispatch(setDataSourceInfo({
147
147
  source: EnumDataSource.CACHE,
@@ -149,7 +149,10 @@ export function useScoutRefresh(options = {}, identityOptions = {}) {
149
149
  cacheAge: cacheResult.age,
150
150
  isStale: cacheResult.isStale,
151
151
  }));
152
- dispatchInTransition(dispatch, setHerdModules({ modules: cachedHerdModules }));
152
+ dispatchInTransition(dispatch, setHerdModules({
153
+ modules: cachedSnapshot.herd_modules,
154
+ productCatalog: cachedSnapshot.product_catalog,
155
+ }));
153
156
  dispatch(setHerdModulesLoadingState(EnumHerdModulesLoadingState.SUCCESSFULLY_LOADED));
154
157
  }
155
158
  catch (cacheError) {
@@ -188,14 +191,14 @@ export function useScoutRefresh(options = {}, identityOptions = {}) {
188
191
  dispatch(setUserApiDuration(sessionDuration));
189
192
  dispatchInTransition(dispatch, setUser(sessionUser));
190
193
  }
191
- if (cachedHerdModules === null) {
194
+ if (cachedSnapshot === null) {
192
195
  dispatch(setDataSource(EnumDataSource.UNKNOWN));
193
196
  dispatch(setDataSourceInfo({
194
197
  source: EnumDataSource.UNKNOWN,
195
198
  timestamp: Date.now(),
196
199
  }));
197
200
  }
198
- complete(cachedHerdModules !== null
201
+ complete(cachedSnapshot !== null
199
202
  ? EnumHerdModulesLoadingState.SUCCESSFULLY_LOADED
200
203
  : EnumHerdModulesLoadingState.UNSUCCESSFULLY_LOADED);
201
204
  return;
@@ -226,16 +229,19 @@ export function useScoutRefresh(options = {}, identityOptions = {}) {
226
229
  !Array.isArray(herdModulesResponse.data)) {
227
230
  throw new Error("Invalid herd modules response");
228
231
  }
229
- const freshHerdModules = herdModulesResponse.data;
232
+ const freshSnapshot = {
233
+ herd_modules: herdModulesResponse.data,
234
+ product_catalog: herdModulesResponse.product_catalog,
235
+ };
230
236
  const freshContentHash = herdModulesResponse.content_hash;
231
237
  if (cacheReady) {
232
238
  try {
233
- await scoutCache.setHerdModules(freshHerdModules, cacheTtlMs, freshContentHash, userId);
239
+ await scoutCache.setHerdModules(freshSnapshot, cacheTtlMs, freshContentHash, userId);
234
240
  }
235
241
  catch (cacheError) {
236
242
  console.warn("[useScoutRefresh] Cache save failed:", cacheError);
237
243
  await recoverScoutCache(cacheError, "cache save", userId, async () => {
238
- await scoutCache.setHerdModules(freshHerdModules, cacheTtlMs, freshContentHash, userId);
244
+ await scoutCache.setHerdModules(freshSnapshot, cacheTtlMs, freshContentHash, userId);
239
245
  });
240
246
  }
241
247
  }
@@ -248,7 +254,8 @@ export function useScoutRefresh(options = {}, identityOptions = {}) {
248
254
  timestamp: Date.now(),
249
255
  }));
250
256
  dispatchInTransition(dispatch, setHerdModules({
251
- modules: freshHerdModules,
257
+ modules: freshSnapshot.herd_modules,
258
+ productCatalog: freshSnapshot.product_catalog,
252
259
  contentHash: freshContentHash,
253
260
  }));
254
261
  const dataProcessingDuration = Date.now() - dataProcessingStartTime;
@@ -259,7 +266,7 @@ export function useScoutRefresh(options = {}, identityOptions = {}) {
259
266
  if (!isCurrent())
260
267
  return;
261
268
  console.error("Error refreshing scout data:", error);
262
- if (cachedHerdModules !== null) {
269
+ if (cachedSnapshot !== null) {
263
270
  dispatch(setDataSource(EnumDataSource.CACHE));
264
271
  complete(EnumHerdModulesLoadingState.SUCCESSFULLY_LOADED);
265
272
  }
@@ -6,9 +6,12 @@ export * from "../helpers/bounding_boxes";
6
6
  export * from "../helpers/email";
7
7
  export * from "../helpers/eventMedia";
8
8
  export * from "../helpers/eventUtils";
9
+ export * from "../helpers/galleryMedia";
9
10
  export { default as get_gps_center } from "../helpers/gps";
10
11
  export * from "../helpers/herd_modules_equal";
11
12
  export * from "../helpers/location";
13
+ export * from "../helpers/mediaState";
14
+ export * from "../helpers/product_catalog";
12
15
  export * from "../helpers/realtime_topic_access";
13
16
  export * from "../helpers/storagePath";
14
17
  export * from "../helpers/time";
@@ -28,6 +31,8 @@ export { server_create_document_template as create_document_template, server_get
28
31
  export { server_search_embeddings_vertex_multimodal_001 as search_embeddings_vertex_multimodal_001 } from "../helpers/embeddings";
29
32
  export { server_delete_event as delete_event, server_get_event_by_id as get_event_by_id, server_get_total_events_by_herd as get_total_events_by_herd, server_insert_event as insert_event, server_match_events_by_vertex_embedding as match_events_by_vertex_embedding, server_update_event as update_event, } from "../helpers/events";
30
33
  export { server_create_battery_cycle_count as create_battery_cycle_count, server_delete_battery_cycle_count as delete_battery_cycle_count, server_get_battery_cycle_counts_by_battery_id as get_battery_cycle_counts_by_battery_id, server_update_battery_cycle_count as update_battery_cycle_count, } from "../helpers/battery_cycle_counts";
34
+ export { server_get_gallery_items_by_ids as get_gallery_items_by_ids } from "../helpers/gallery";
35
+ export type { IGalleryItemsByIdsArgs } from "../helpers/gallery.queries";
31
36
  export { server_get_health_metrics as get_health_metrics, server_get_health_metrics_summary as get_health_metrics_summary, } from "../helpers/health_metrics";
32
37
  export { server_get_herd_media_usage as get_herd_media_usage, server_get_latest_herd_media_usage as get_latest_herd_media_usage, } from "../helpers/herd_media_usage";
33
38
  export { server_check_device_online_status as check_device_online_status, server_get_heartbeats_by_device as get_heartbeats_by_device, server_get_last_heartbeat_by_device as get_last_heartbeat_by_device, } from "../helpers/heartbeats";
@@ -8,9 +8,12 @@ export * from "../helpers/bounding_boxes";
8
8
  export * from "../helpers/email";
9
9
  export * from "../helpers/eventMedia";
10
10
  export * from "../helpers/eventUtils";
11
+ export * from "../helpers/galleryMedia";
11
12
  export { default as get_gps_center } from "../helpers/gps";
12
13
  export * from "../helpers/herd_modules_equal";
13
14
  export * from "../helpers/location";
15
+ export * from "../helpers/mediaState";
16
+ export * from "../helpers/product_catalog";
14
17
  export * from "../helpers/realtime_topic_access";
15
18
  export * from "../helpers/storagePath";
16
19
  export * from "../helpers/time";
@@ -28,6 +31,7 @@ export { server_create_document_template as create_document_template, server_get
28
31
  export { server_search_embeddings_vertex_multimodal_001 as search_embeddings_vertex_multimodal_001 } from "../helpers/embeddings";
29
32
  export { server_delete_event as delete_event, server_get_event_by_id as get_event_by_id, server_get_total_events_by_herd as get_total_events_by_herd, server_insert_event as insert_event, server_match_events_by_vertex_embedding as match_events_by_vertex_embedding, server_update_event as update_event, } from "../helpers/events";
30
33
  export { server_create_battery_cycle_count as create_battery_cycle_count, server_delete_battery_cycle_count as delete_battery_cycle_count, server_get_battery_cycle_counts_by_battery_id as get_battery_cycle_counts_by_battery_id, server_update_battery_cycle_count as update_battery_cycle_count, } from "../helpers/battery_cycle_counts";
34
+ export { server_get_gallery_items_by_ids as get_gallery_items_by_ids } from "../helpers/gallery";
31
35
  export { server_get_health_metrics as get_health_metrics, server_get_health_metrics_summary as get_health_metrics_summary, } from "../helpers/health_metrics";
32
36
  export { server_get_herd_media_usage as get_herd_media_usage, server_get_latest_herd_media_usage as get_latest_herd_media_usage, } from "../helpers/herd_media_usage";
33
37
  export { server_check_device_online_status as check_device_online_status, server_get_heartbeats_by_device as get_heartbeats_by_device, server_get_last_heartbeat_by_device as get_last_heartbeat_by_device, } from "../helpers/heartbeats";