@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
@@ -5,9 +5,12 @@ export * from "../helpers/bounding_boxes";
5
5
  export * from "../helpers/email";
6
6
  export * from "../helpers/eventMedia";
7
7
  export * from "../helpers/eventUtils";
8
+ export * from "../helpers/galleryMedia";
8
9
  export { default as get_gps_center } from "../helpers/gps";
9
10
  export * from "../helpers/herd_modules_equal";
10
11
  export * from "../helpers/location";
12
+ export * from "../helpers/mediaState";
13
+ export * from "../helpers/product_catalog";
11
14
  export * from "../helpers/realtime_topic_access";
12
15
  export * from "../helpers/storagePath";
13
16
  export * from "../helpers/time";
@@ -45,6 +48,7 @@ export { create_document_template_query as create_document_template, get_documen
45
48
  export { search_embeddings_vertex_multimodal_001_query as search_embeddings_vertex_multimodal_001 } from "../helpers/embeddings.queries";
46
49
  export { delete_event_query as delete_event, get_event_by_id_query as get_event_by_id, get_total_events_by_herd_query as get_total_events_by_herd, insert_event_query as insert_event, match_events_by_vertex_embedding_query as match_events_by_vertex_embedding, update_event_query as update_event, } from "../helpers/events.queries";
47
50
  export { create_battery_cycle_count_query as create_battery_cycle_count, delete_battery_cycle_count_query as delete_battery_cycle_count, get_battery_cycle_counts_by_battery_id_query as get_battery_cycle_counts_by_battery_id, update_battery_cycle_count_query as update_battery_cycle_count, } from "../helpers/battery_cycle_counts.queries";
51
+ export { get_gallery_items_by_ids_query as get_gallery_items_by_ids, type IGalleryItemsByIdsArgs, } from "../helpers/gallery.queries";
48
52
  export { get_health_metrics_query as get_health_metrics, get_health_metrics_summary_query as get_health_metrics_summary, } from "../helpers/health_metrics.queries";
49
53
  export { get_herd_media_usage_query as get_herd_media_usage, get_latest_herd_media_usage_query as get_latest_herd_media_usage, } from "../helpers/herd_media_usage.queries";
50
54
  export { check_device_online_status_query as check_device_online_status, get_heartbeats_by_device_query as get_heartbeats_by_device, get_last_heartbeat_by_device_query as get_last_heartbeat_by_device, } from "../helpers/heartbeats.queries";
@@ -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";
@@ -50,6 +53,7 @@ export { create_document_template_query as create_document_template, get_documen
50
53
  export { search_embeddings_vertex_multimodal_001_query as search_embeddings_vertex_multimodal_001 } from "../helpers/embeddings.queries";
51
54
  export { delete_event_query as delete_event, get_event_by_id_query as get_event_by_id, get_total_events_by_herd_query as get_total_events_by_herd, insert_event_query as insert_event, match_events_by_vertex_embedding_query as match_events_by_vertex_embedding, update_event_query as update_event, } from "../helpers/events.queries";
52
55
  export { create_battery_cycle_count_query as create_battery_cycle_count, delete_battery_cycle_count_query as delete_battery_cycle_count, get_battery_cycle_counts_by_battery_id_query as get_battery_cycle_counts_by_battery_id, update_battery_cycle_count_query as update_battery_cycle_count, } from "../helpers/battery_cycle_counts.queries";
56
+ export { get_gallery_items_by_ids_query as get_gallery_items_by_ids, } from "../helpers/gallery.queries";
53
57
  export { get_health_metrics_query as get_health_metrics, get_health_metrics_summary_query as get_health_metrics_summary, } from "../helpers/health_metrics.queries";
54
58
  export { get_herd_media_usage_query as get_herd_media_usage, get_latest_herd_media_usage_query as get_latest_herd_media_usage, } from "../helpers/herd_media_usage.queries";
55
59
  export { check_device_online_status_query as check_device_online_status, get_heartbeats_by_device_query as get_heartbeats_by_device, get_last_heartbeat_by_device_query as get_last_heartbeat_by_device, } from "../helpers/heartbeats.queries";
@@ -1,4 +1,4 @@
1
- import { IHerdModule } from "../types/herd_module";
1
+ import { IHerdModulesSnapshot } from "../types/herd_module";
2
2
  export type JwtMintCacheKey = "client_abilities" | "pubsub";
3
3
  type CachedJwtMint = {
4
4
  token: string;
@@ -53,8 +53,8 @@ export declare class ScoutCache {
53
53
  private getDatabase;
54
54
  private init;
55
55
  private validateDatabaseSchema;
56
- setHerdModules(herdModules: IHerdModule[], ttlMs?: number, etag?: string, userId?: string): Promise<void>;
57
- getHerdModules(userId?: string): Promise<CacheResult<IHerdModule[]>>;
56
+ setHerdModules(snapshot: IHerdModulesSnapshot, ttlMs?: number, etag?: string, userId?: string): Promise<void>;
57
+ getHerdModules(userId?: string): Promise<CacheResult<IHerdModulesSnapshot>>;
58
58
  setJwtMint<T extends CachedJwtMint>(key: JwtMintCacheKey, mint: T, userId?: string): Promise<void>;
59
59
  getJwtMint<T extends CachedJwtMint>(key: JwtMintCacheKey, userId?: string): Promise<CacheResult<T>>;
60
60
  clearJwtMint(key: JwtMintCacheKey, userId?: string): Promise<void>;
@@ -67,7 +67,7 @@ export declare class ScoutCache {
67
67
  shouldRefresh: boolean;
68
68
  reason: string;
69
69
  }>;
70
- preloadCache(loadFunction: () => Promise<IHerdModule[]>, ttlMs?: number, userId?: string): Promise<void>;
70
+ preloadCache(loadFunction: () => Promise<IHerdModulesSnapshot>, ttlMs?: number, userId?: string): Promise<void>;
71
71
  getDefaultTtl(): number;
72
72
  getCurrentDbVersion(): number;
73
73
  isCacheVersionCompatible(userId?: string): Promise<boolean>;
@@ -1,8 +1,11 @@
1
+ import { EMPTY_PRODUCT_CATALOG } from "./product_catalog";
1
2
  const LEGACY_DB_NAME = "ScoutCache";
2
- const DB_VERSION = 9;
3
+ const DB_VERSION = 10;
3
4
  const HERD_MODULES_STORE = "herd_modules";
5
+ const PRODUCT_CATALOG_STORE = "product_catalog";
4
6
  const JWT_MINTS_STORE = "jwt_mints";
5
7
  const CACHE_METADATA_STORE = "cache_metadata";
8
+ const PRODUCT_CATALOG_KEY = "product_catalog";
6
9
  const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
7
10
  let legacyDbDeleteStarted = false;
8
11
  function getSupabaseProjectRef() {
@@ -118,6 +121,9 @@ export class ScoutCache {
118
121
  herdModulesStore.createIndex("dbVersion", "dbVersion", {
119
122
  unique: false,
120
123
  });
124
+ db.createObjectStore(PRODUCT_CATALOG_STORE, {
125
+ keyPath: "key",
126
+ });
121
127
  const jwtMintsStore = db.createObjectStore(JWT_MINTS_STORE, {
122
128
  keyPath: "key",
123
129
  });
@@ -149,18 +155,20 @@ export class ScoutCache {
149
155
  }
150
156
  validateDatabaseSchema(db) {
151
157
  return (db.objectStoreNames.contains(HERD_MODULES_STORE) &&
158
+ db.objectStoreNames.contains(PRODUCT_CATALOG_STORE) &&
152
159
  db.objectStoreNames.contains(JWT_MINTS_STORE) &&
153
160
  db.objectStoreNames.contains(CACHE_METADATA_STORE));
154
161
  }
155
- async setHerdModules(herdModules, ttlMs = DEFAULT_TTL_MS, etag, userId) {
162
+ async setHerdModules(snapshot, ttlMs = DEFAULT_TTL_MS, etag, userId) {
156
163
  const db = await this.getDatabase(userId);
157
- const transaction = db.transaction([HERD_MODULES_STORE, CACHE_METADATA_STORE], "readwrite");
164
+ const transaction = db.transaction([HERD_MODULES_STORE, PRODUCT_CATALOG_STORE, CACHE_METADATA_STORE], "readwrite");
158
165
  const completion = transactionComplete(transaction);
159
166
  const herdModulesStore = transaction.objectStore(HERD_MODULES_STORE);
167
+ const productCatalogStore = transaction.objectStore(PRODUCT_CATALOG_STORE);
160
168
  const metadataStore = transaction.objectStore(CACHE_METADATA_STORE);
161
169
  const timestamp = Date.now();
162
170
  herdModulesStore.clear();
163
- herdModules.forEach((herdModule) => {
171
+ snapshot.herd_modules.forEach((herdModule) => {
164
172
  if (herdModule.herd.id == null)
165
173
  return;
166
174
  herdModulesStore.put({
@@ -170,6 +178,12 @@ export class ScoutCache {
170
178
  dbVersion: DB_VERSION,
171
179
  });
172
180
  });
181
+ productCatalogStore.put({
182
+ key: PRODUCT_CATALOG_KEY,
183
+ data: snapshot.product_catalog,
184
+ timestamp,
185
+ dbVersion: DB_VERSION,
186
+ });
173
187
  const metadata = {
174
188
  key: "herd_modules",
175
189
  timestamp,
@@ -185,10 +199,11 @@ export class ScoutCache {
185
199
  async getHerdModules(userId) {
186
200
  const scopedUserId = userId ?? this.userId ?? undefined;
187
201
  const db = await this.getDatabase(scopedUserId);
188
- const transaction = db.transaction([HERD_MODULES_STORE, CACHE_METADATA_STORE], "readonly");
202
+ const transaction = db.transaction([HERD_MODULES_STORE, PRODUCT_CATALOG_STORE, CACHE_METADATA_STORE], "readonly");
189
203
  return new Promise((resolve, reject) => {
190
204
  transaction.onerror = () => reject(transaction.error);
191
205
  const herdModulesStore = transaction.objectStore(HERD_MODULES_STORE);
206
+ const productCatalogStore = transaction.objectStore(PRODUCT_CATALOG_STORE);
192
207
  const metadataStore = transaction.objectStore(CACHE_METADATA_STORE);
193
208
  const metadataRequest = metadataStore.get("herd_modules");
194
209
  metadataRequest.onsuccess = () => {
@@ -210,10 +225,25 @@ export class ScoutCache {
210
225
  }
211
226
  const age = now - metadata.timestamp;
212
227
  const isStale = age > metadata.ttl;
228
+ let herdModules = null;
229
+ let productCatalog = null;
230
+ const resolveWhenLoaded = () => {
231
+ if (herdModules === null || productCatalog === null)
232
+ return;
233
+ resolve({
234
+ data: {
235
+ herd_modules: herdModules,
236
+ product_catalog: productCatalog,
237
+ },
238
+ isStale,
239
+ age,
240
+ metadata,
241
+ });
242
+ };
213
243
  const getAllRequest = herdModulesStore.getAll();
214
244
  getAllRequest.onsuccess = () => {
215
245
  const cacheEntries = getAllRequest.result;
216
- const herdModules = cacheEntries
246
+ herdModules = cacheEntries
217
247
  .filter((entry) => entry.data &&
218
248
  entry.data.herd &&
219
249
  entry.data.herd.id != null &&
@@ -226,12 +256,16 @@ export class ScoutCache {
226
256
  else {
227
257
  this.stats.misses++;
228
258
  }
229
- resolve({
230
- data: herdModules,
231
- isStale,
232
- age,
233
- metadata,
234
- });
259
+ resolveWhenLoaded();
260
+ };
261
+ const catalogRequest = productCatalogStore.get(PRODUCT_CATALOG_KEY);
262
+ catalogRequest.onsuccess = () => {
263
+ const entry = catalogRequest.result;
264
+ productCatalog =
265
+ entry?.dbVersion === DB_VERSION && entry.data
266
+ ? entry.data
267
+ : EMPTY_PRODUCT_CATALOG;
268
+ resolveWhenLoaded();
235
269
  };
236
270
  };
237
271
  });
@@ -295,9 +329,10 @@ export class ScoutCache {
295
329
  if (!userId && !this.dbName)
296
330
  return;
297
331
  const db = await this.getDatabase(userId);
298
- const transaction = db.transaction([HERD_MODULES_STORE, CACHE_METADATA_STORE], "readwrite");
332
+ const transaction = db.transaction([HERD_MODULES_STORE, PRODUCT_CATALOG_STORE, CACHE_METADATA_STORE], "readwrite");
299
333
  const completion = transactionComplete(transaction);
300
334
  transaction.objectStore(HERD_MODULES_STORE).clear();
335
+ transaction.objectStore(PRODUCT_CATALOG_STORE).clear();
301
336
  transaction.objectStore(CACHE_METADATA_STORE).delete("herd_modules");
302
337
  await completion;
303
338
  }
@@ -308,7 +343,7 @@ export class ScoutCache {
308
343
  const result = await this.getHerdModules(userId);
309
344
  const totalRequests = this.stats.hits + this.stats.misses;
310
345
  const hitRate = totalRequests > 0 ? this.stats.hits / totalRequests : 0;
311
- const size = result.data?.length || 0;
346
+ const size = result.data?.herd_modules.length || 0;
312
347
  return {
313
348
  size,
314
349
  lastUpdated: result.data ? Date.now() - result.age : 0,
@@ -334,7 +369,7 @@ export class ScoutCache {
334
369
  return { shouldRefresh: true, reason: "Force refresh requested" };
335
370
  }
336
371
  const result = await this.getHerdModules(userId);
337
- if (!result.data || result.data.length === 0) {
372
+ if (!result.data || result.data.herd_modules.length === 0) {
338
373
  return { shouldRefresh: true, reason: "No cached data" };
339
374
  }
340
375
  if (!result.metadata ||
@@ -360,8 +395,8 @@ export class ScoutCache {
360
395
  try {
361
396
  console.log("[ScoutCache] Starting background cache preload...");
362
397
  const startTime = Date.now();
363
- const herdModules = await loadFunction();
364
- await this.setHerdModules(herdModules, ttlMs, undefined, userId);
398
+ const snapshot = await loadFunction();
399
+ await this.setHerdModules(snapshot, ttlMs, undefined, userId);
365
400
  const duration = Date.now() - startTime;
366
401
  console.log(`[ScoutCache] Background preload completed in ${duration}ms`);
367
402
  }
@@ -1,12 +1,14 @@
1
- import { ISignedMediaUrls } from "../types/db";
1
+ import type { ISignedMediaUrls } from "../types/db";
2
2
  /** Events arrive as table rows and as the event_and_tags composite, so match on shape. */
3
- export type IEventMediaPaths = {
3
+ export type IMediaPaths = {
4
4
  file_path?: string | null;
5
5
  thumbnail_file_path?: string | null;
6
6
  proxy_file_path?: string | null;
7
7
  media_url?: string | null;
8
8
  };
9
- /** Collect unique non-empty storage paths used for event media signing. */
10
- export declare function collectEventStoragePaths(events: Iterable<IEventMediaPaths | null | undefined>): string[];
9
+ export type IEventMediaPaths = IMediaPaths;
10
+ /** Collect unique non-empty original and derivative storage paths. */
11
+ export declare function collectMediaStoragePaths(rows: Iterable<IMediaPaths | null | undefined>): string[];
12
+ export declare const collectEventStoragePaths: typeof collectMediaStoragePaths;
11
13
  /** Attach signed URLs for original, thumbnail, and proxy storage paths. */
12
14
  export declare function withEventMediaUrls<T extends IEventMediaPaths>(event: T, urlMap: Map<string, string | null | undefined>): T & ISignedMediaUrls;
@@ -1,13 +1,13 @@
1
1
  import { isNonEmptyStorageFilePath, signedUrlForPath } from "./storagePath";
2
- /** Collect unique non-empty storage paths used for event media signing. */
3
- export function collectEventStoragePaths(events) {
2
+ /** Collect unique non-empty original and derivative storage paths. */
3
+ export function collectMediaStoragePaths(rows) {
4
4
  const seen = new Set();
5
5
  const paths = [];
6
- for (const event of events) {
6
+ for (const row of rows) {
7
7
  for (const path of [
8
- event?.file_path,
9
- event?.thumbnail_file_path,
10
- event?.proxy_file_path,
8
+ row?.file_path,
9
+ row?.thumbnail_file_path,
10
+ row?.proxy_file_path,
11
11
  ]) {
12
12
  if (isNonEmptyStorageFilePath(path) && !seen.has(path)) {
13
13
  seen.add(path);
@@ -17,6 +17,7 @@ export function collectEventStoragePaths(events) {
17
17
  }
18
18
  return paths;
19
19
  }
20
+ export const collectEventStoragePaths = collectMediaStoragePaths;
20
21
  /** Attach signed URLs for original, thumbnail, and proxy storage paths. */
21
22
  export function withEventMediaUrls(event, urlMap) {
22
23
  return {
@@ -0,0 +1,4 @@
1
+ import type { IGalleryItem } from "../types/db";
2
+ import type { IWebResponseCompatible } from "../types/requests";
3
+ import { type IGalleryItemsByIdsArgs } from "./gallery.queries";
4
+ export declare function server_get_gallery_items_by_ids(args?: IGalleryItemsByIdsArgs): Promise<IWebResponseCompatible<IGalleryItem[]>>;
@@ -0,0 +1,5 @@
1
+ import { newServerClient } from "../supabase/server";
2
+ import { get_gallery_items_by_ids_query, } from "./gallery.queries";
3
+ export async function server_get_gallery_items_by_ids(args = {}) {
4
+ return get_gallery_items_by_ids_query(await newServerClient(), args);
5
+ }
@@ -0,0 +1,14 @@
1
+ import type { SupabaseClient } from "@supabase/supabase-js";
2
+ import type { IGalleryItem } from "../types/db";
3
+ import { type IWebResponseCompatible } from "../types/requests";
4
+ import type { Database } from "../types/supabase";
5
+ import { type GalleryItemKey } from "./galleryMedia";
6
+ /** Missing, inactive, log-only, and pathless rows are omitted from the successful result. */
7
+ export interface IGalleryItemsByIdsArgs {
8
+ eventIds?: readonly number[];
9
+ artifactIds?: readonly number[];
10
+ /** Optional mixed event/artifact confidence order; its ids are fetched automatically. */
11
+ rankedIds?: readonly GalleryItemKey[];
12
+ }
13
+ /** Hydrates ids into signed gallery items in one read, ordered as asked for. */
14
+ export declare function get_gallery_items_by_ids_query(client: SupabaseClient<Database>, args?: IGalleryItemsByIdsArgs): Promise<IWebResponseCompatible<IGalleryItem[]>>;
@@ -0,0 +1,49 @@
1
+ import { IWebResponse } from "../types/requests";
2
+ import { rankGalleryItems } from "./galleryMedia";
3
+ import { addGalleryMediaUrls } from "./media_urls";
4
+ const normalizedIds = (ids = []) => ids.every((id) => Number.isSafeInteger(id) && id > 0)
5
+ ? [...new Set(ids)]
6
+ : null;
7
+ const normalizedRanking = (items = []) => {
8
+ const unique = new Map();
9
+ for (const item of items) {
10
+ if ((item.feedType !== "event" && item.feedType !== "artifact") ||
11
+ !Number.isSafeInteger(item.id) ||
12
+ item.id <= 0) {
13
+ return null;
14
+ }
15
+ const key = `${item.feedType}:${item.id}`;
16
+ if (!unique.has(key))
17
+ unique.set(key, item);
18
+ }
19
+ return [...unique.values()];
20
+ };
21
+ /** Hydrates ids into signed gallery items in one read, ordered as asked for. */
22
+ export async function get_gallery_items_by_ids_query(client, args = {}) {
23
+ const rankedIds = normalizedRanking(args.rankedIds);
24
+ const rankedEventIds = [];
25
+ const rankedArtifactIds = [];
26
+ rankedIds?.forEach(({ feedType, id }) => {
27
+ (feedType === "event" ? rankedEventIds : rankedArtifactIds).push(id);
28
+ });
29
+ const eventIds = normalizedIds([...(args.eventIds ?? []), ...rankedEventIds]);
30
+ const artifactIds = normalizedIds([
31
+ ...(args.artifactIds ?? []),
32
+ ...rankedArtifactIds,
33
+ ]);
34
+ if (!rankedIds || !eventIds || !artifactIds) {
35
+ return IWebResponse.error("Gallery item IDs must be positive safe integers with valid feed types").to_compatible();
36
+ }
37
+ if (eventIds.length === 0 && artifactIds.length === 0) {
38
+ return IWebResponse.success([]).to_compatible();
39
+ }
40
+ const { data, error } = await client.rpc("get_gallery_items_by_ids", {
41
+ event_ids: eventIds,
42
+ artifact_ids: artifactIds,
43
+ });
44
+ if (error) {
45
+ return IWebResponse.error(error.message).to_compatible();
46
+ }
47
+ const items = await addGalleryMediaUrls(client, data ?? []);
48
+ return IWebResponse.success(rankGalleryItems(items, eventIds, artifactIds, rankedIds)).to_compatible();
49
+ }
@@ -0,0 +1,9 @@
1
+ import type { IGalleryItem, IGalleryItemRow } from "../types/db";
2
+ export interface GalleryItemKey {
3
+ feedType: "event" | "artifact";
4
+ id: number;
5
+ }
6
+ /** Signs media the way events do, then adds the derived kind and readiness a tile renders. */
7
+ export declare function withGalleryMediaUrls(item: IGalleryItemRow, urlMap: Map<string, string | null | undefined>): IGalleryItem;
8
+ /** Uses a mixed ranking when supplied, otherwise each type's list position and then recency. */
9
+ export declare function rankGalleryItems<T extends IGalleryItemRow>(items: T[], eventIds: readonly number[], artifactIds: readonly number[], rankedIds?: readonly GalleryItemKey[]): T[];
@@ -0,0 +1,41 @@
1
+ import { withEventMediaUrls } from "./eventMedia";
2
+ import { classifyMedia, mediaStatus } from "./mediaState";
3
+ /** Signs media the way events do, then adds the derived kind and readiness a tile renders. */
4
+ export function withGalleryMediaUrls(item, urlMap) {
5
+ const withUrls = withEventMediaUrls(item, urlMap);
6
+ return {
7
+ ...withUrls,
8
+ media_kind: classifyMedia(withUrls),
9
+ media_status: mediaStatus(withUrls),
10
+ };
11
+ }
12
+ /** Uses a mixed ranking when supplied, otherwise each type's list position and then recency. */
13
+ export function rankGalleryItems(items, eventIds, artifactIds, rankedIds) {
14
+ const mixedRanks = new Map();
15
+ rankedIds?.forEach(({ feedType, id }, index) => {
16
+ const key = `${feedType}:${id}`;
17
+ if (!mixedRanks.has(key))
18
+ mixedRanks.set(key, index);
19
+ });
20
+ const rankOf = (ids) => {
21
+ const ranks = new Map();
22
+ ids.forEach((id, index) => {
23
+ if (!ranks.has(id))
24
+ ranks.set(id, index);
25
+ });
26
+ return ranks;
27
+ };
28
+ const eventRanks = rankOf(eventIds);
29
+ const artifactRanks = rankOf(artifactIds);
30
+ const rank = (item) => {
31
+ if (mixedRanks.size > 0) {
32
+ return (mixedRanks.get(`${item.feed_type}:${item.id}`) ??
33
+ Number.MAX_SAFE_INTEGER);
34
+ }
35
+ const ranks = item.feed_type === "artifact" ? artifactRanks : eventRanks;
36
+ const position = item.id == null ? undefined : ranks.get(item.id);
37
+ return position ?? Number.MAX_SAFE_INTEGER;
38
+ };
39
+ return [...items].sort((left, right) => rank(left) - rank(right) ||
40
+ (right.sort_ts ?? "").localeCompare(left.sort_ts ?? ""));
41
+ }
@@ -5,13 +5,14 @@ import { get_devices_by_herd_query } from "./devices.queries";
5
5
  import { get_herds_with_location_query } from "./herds.queries";
6
6
  import { get_layers_by_herd_ids_query, get_plans_by_herd_ids_query, get_providers_by_herd_ids_query, } from "./herd_scoped.queries";
7
7
  import { get_parts_by_herd_ids } from "./parts";
8
+ import { EMPTY_PRODUCT_CATALOG } from "./product_catalog";
8
9
  import { get_session_summaries_query } from "./session_summaries.queries";
9
10
  import { get_session_usage_over_time_by_herd_query } from "./sessions.queries";
10
11
  import { get_users_with_herd_access_batch_query } from "./users.queries";
11
12
  import { get_more_zones_and_actions_for_herd_query } from "./zones.queries";
12
- async function hashHerdModules(modules) {
13
+ async function hashHerdModules(modules, productCatalog) {
13
14
  const stable = modules.map(({ timestamp_last_refreshed: _ts, ...rest }) => rest);
14
- const bytes = new TextEncoder().encode(JSON.stringify(stable));
15
+ const bytes = new TextEncoder().encode(JSON.stringify({ modules: stable, product_catalog: productCatalog }));
15
16
  const digest = await crypto.subtle.digest("SHA-1", bytes);
16
17
  return Array.from(new Uint8Array(digest))
17
18
  .map((byte) => byte.toString(16).padStart(2, "0"))
@@ -25,6 +26,7 @@ export async function load_herd_modules_query(client) {
25
26
  status: EnumWebResponse.ERROR,
26
27
  msg: herdsResult.msg ?? "Failed to load herds",
27
28
  data: null,
29
+ product_catalog: EMPTY_PRODUCT_CATALOG,
28
30
  time_finished: Date.now(),
29
31
  time_sent: Date.now(),
30
32
  server_processing_time_ms: Date.now() - startTime,
@@ -39,10 +41,11 @@ export async function load_herd_modules_query(client) {
39
41
  status: EnumWebResponse.SUCCESS,
40
42
  msg: "No herds accessible",
41
43
  data: [],
44
+ product_catalog: EMPTY_PRODUCT_CATALOG,
42
45
  time_finished: endTime,
43
46
  time_sent: endTime,
44
47
  server_processing_time_ms: endTime - startTime,
45
- content_hash: await hashHerdModules([]),
48
+ content_hash: await hashHerdModules([], EMPTY_PRODUCT_CATALOG),
46
49
  };
47
50
  }
48
51
  const herdIds = herds.map((herd) => herd.id);
@@ -53,19 +56,23 @@ export async function load_herd_modules_query(client) {
53
56
  }))));
54
57
  const grouped = (query) => query.then((response) => response.data ?? {}).catch(() => ({}));
55
58
  const unwrap = (response) => response.status === EnumWebResponse.SUCCESS ? response.data : null;
56
- const [plansByHerd, layersByHerd, providersByHerd, usersByHerd, partsByDevice, devicesResults, zonesResults, summariesResults, usageResults,] = await Promise.all([
59
+ const [plansByHerd, layersByHerd, providersByHerd, usersByHerd, partsWithCatalog, devicesResults, zonesResults, summariesResults, usageResults,] = await Promise.all([
57
60
  grouped(get_plans_by_herd_ids_query(client, herdIds)),
58
61
  grouped(get_layers_by_herd_ids_query(client, herdIds)),
59
62
  grouped(get_providers_by_herd_ids_query(client, herdIds)),
60
63
  get_users_with_herd_access_batch_query(client, herdIds)
61
64
  .then(unwrap)
62
65
  .catch(() => null),
63
- grouped(get_parts_by_herd_ids(client, herdIds)),
66
+ get_parts_by_herd_ids(client, herdIds)
67
+ .then(unwrap)
68
+ .catch(() => null),
64
69
  perHerd((id) => get_devices_by_herd_query(client, id)),
65
70
  perHerd((id) => get_more_zones_and_actions_for_herd_query(client, id, 0, 10)),
66
71
  perHerd((id) => get_session_summaries_query(client, { herd_id: id })),
67
72
  perHerd((id) => get_session_usage_over_time_by_herd_query(client, id)),
68
73
  ]);
74
+ const partsByDevice = partsWithCatalog?.parts_by_device ?? {};
75
+ const productCatalog = partsWithCatalog?.product_catalog ?? EMPTY_PRODUCT_CATALOG;
69
76
  const devicesByHerdIndex = devicesResults.map((response) => unwrap(response) ?? []);
70
77
  const allDevices = devicesByHerdIndex.flat();
71
78
  let apiKeysByDevice = {};
@@ -101,9 +108,10 @@ export async function load_herd_modules_query(client) {
101
108
  status: EnumWebResponse.SUCCESS,
102
109
  msg: "Herd modules loaded successfully",
103
110
  data: serialized_herd_modules,
111
+ product_catalog: productCatalog,
104
112
  time_finished: endTime,
105
113
  time_sent: endTime,
106
114
  server_processing_time_ms: endTime - startTime,
107
- content_hash: await hashHerdModules(serialized_herd_modules),
115
+ content_hash: await hashHerdModules(serialized_herd_modules, productCatalog),
108
116
  };
109
117
  }
@@ -0,0 +1,12 @@
1
+ export declare const normalizePageLimit: (limit: number | undefined) => number;
2
+ /** Splits a page fetched with `limit + 1` rows; the extra row is the only proof of a next page. */
3
+ export declare function splitOverFetchedPage<TRow>(rows: TRow[], limit: number): {
4
+ rows: TRow[];
5
+ hasMore: boolean;
6
+ };
7
+ /** Fields re-minted on every fetch, so they never indicate a changed row. */
8
+ export declare const SIGNED_URL_FIELDS: readonly ["media_url", "thumbnail_url", "proxy_url"];
9
+ /** Structural equality ignoring re-minted URLs and the readiness read off them. */
10
+ export declare function rowsEqualIgnoringSignedUrls(left: unknown, right: unknown): boolean;
11
+ /** Reuses `previous` — keeping identity for memoized consumers — with the URLs from `next`. */
12
+ export declare function withRefreshedSignedUrls<TRow>(previous: TRow, next: TRow): TRow;
@@ -0,0 +1,80 @@
1
+ import { dequal } from "dequal";
2
+ import { mediaStatus } from "./mediaState";
3
+ export const normalizePageLimit = (limit) => {
4
+ if (limit !== undefined && Number.isSafeInteger(limit) && limit > 0) {
5
+ return limit;
6
+ }
7
+ return 20;
8
+ };
9
+ /** Splits a page fetched with `limit + 1` rows; the extra row is the only proof of a next page. */
10
+ export function splitOverFetchedPage(rows, limit) {
11
+ const hasMore = rows.length > limit;
12
+ return { rows: hasMore ? rows.slice(0, limit) : rows, hasMore };
13
+ }
14
+ /** Fields re-minted on every fetch, so they never indicate a changed row. */
15
+ export const SIGNED_URL_FIELDS = [
16
+ "media_url",
17
+ "thumbnail_url",
18
+ "proxy_url",
19
+ ];
20
+ /** Feed rows nest their media under these keys. */
21
+ const NESTED_MEDIA_FIELDS = ["event_data", "artifact_data"];
22
+ /** Read off the signed URLs, so it is recomputed with them rather than compared. */
23
+ const DERIVED_MEDIA_FIELD = "media_status";
24
+ const REMINTED_FIELD_SET = new Set([
25
+ ...SIGNED_URL_FIELDS,
26
+ DERIVED_MEDIA_FIELD,
27
+ ]);
28
+ const NESTED_MEDIA_FIELD_SET = new Set(NESTED_MEDIA_FIELDS);
29
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
30
+ const comparedKeys = (row) => Object.keys(row).filter((key) => !REMINTED_FIELD_SET.has(key));
31
+ /** Structural equality ignoring re-minted URLs and the readiness read off them. */
32
+ export function rowsEqualIgnoringSignedUrls(left, right) {
33
+ if (left === right)
34
+ return true;
35
+ if (!isRecord(left) || !isRecord(right))
36
+ return dequal(left, right);
37
+ const keys = comparedKeys(left);
38
+ if (keys.length !== comparedKeys(right).length)
39
+ return false;
40
+ // Only the nested media rows hold re-minted fields of their own; the rest compares whole.
41
+ return keys.every((key) => NESTED_MEDIA_FIELD_SET.has(key)
42
+ ? rowsEqualIgnoringSignedUrls(left[key], right[key])
43
+ : dequal(left[key], right[key]));
44
+ }
45
+ /** A failed re-mint reports null, which never displaces a link that still works. */
46
+ const refreshedUrl = (previous, next) => next ?? previous;
47
+ function signedUrlsDiffer(previous, next) {
48
+ return (SIGNED_URL_FIELDS.some((field) => refreshedUrl(previous[field], next[field]) !== previous[field]) ||
49
+ NESTED_MEDIA_FIELDS.some((field) => {
50
+ const previousNested = previous[field];
51
+ const nextNested = next[field];
52
+ return (isRecord(previousNested) &&
53
+ isRecord(nextNested) &&
54
+ signedUrlsDiffer(previousNested, nextNested));
55
+ }));
56
+ }
57
+ /** Reuses `previous` — keeping identity for memoized consumers — with the URLs from `next`. */
58
+ export function withRefreshedSignedUrls(previous, next) {
59
+ if (!isRecord(previous) || !isRecord(next))
60
+ return previous;
61
+ if (!signedUrlsDiffer(previous, next))
62
+ return previous;
63
+ const merged = { ...previous };
64
+ for (const field of SIGNED_URL_FIELDS) {
65
+ if (field in previous || field in next) {
66
+ merged[field] = refreshedUrl(previous[field], next[field]);
67
+ }
68
+ }
69
+ for (const field of NESTED_MEDIA_FIELDS) {
70
+ const previousNested = previous[field];
71
+ const nextNested = next[field];
72
+ if (isRecord(previousNested) && isRecord(nextNested)) {
73
+ merged[field] = withRefreshedSignedUrls(previousNested, nextNested);
74
+ }
75
+ }
76
+ if (DERIVED_MEDIA_FIELD in merged) {
77
+ merged[DERIVED_MEDIA_FIELD] = mediaStatus(merged);
78
+ }
79
+ return merged;
80
+ }
@@ -0,0 +1,33 @@
1
+ import { MediaKind, MediaStatus, MediaType } from "../types/db";
2
+ /** Extensions classified by the client; image/video lists mirror the queue predicates. */
3
+ export declare const VIDEO_FILE_EXTENSIONS: readonly ["mp4", "m4v", "webm", "mov", "mkv", "avi"];
4
+ export declare const IMAGE_FILE_EXTENSIONS: readonly ["jpg", "jpeg", "png", "gif", "webp", "bmp"];
5
+ export declare const AUDIO_FILE_EXTENSIONS: readonly ["wav", "mp3", "m4a", "aac", "flac", "ogg"];
6
+ /** Lowercased extension of a storage path or URL, or null when there is none. */
7
+ export declare function mediaFileExtension(filePath: string | null | undefined): string | null;
8
+ export interface MediaClassificationInput {
9
+ /** Storage path, or a legacy absolute media URL. */
10
+ file_path?: string | null;
11
+ media_url?: string | null;
12
+ /** Events carry the media_type enum. */
13
+ media_type?: MediaType | null;
14
+ /** Artifacts carry a modality, which is `video` for video captures and `sys_logs` etc. for logs. */
15
+ modality?: string | null;
16
+ }
17
+ /** One classifier for events and artifacts; a recognized extension wins over the stored type. */
18
+ export declare function classifyMedia(row: MediaClassificationInput): MediaKind;
19
+ export declare const isVideoMedia: (row: MediaClassificationInput) => boolean;
20
+ export interface MediaStatusInput extends MediaClassificationInput {
21
+ thumbnail_file_path?: string | null;
22
+ thumbnail_generated_at?: string | null;
23
+ proxy_file_path?: string | null;
24
+ proxy_generated_at?: string | null;
25
+ processing_blocked_at?: string | null;
26
+ /** Signed URLs, as attached by the media helpers. */
27
+ thumbnail_url?: string | null;
28
+ proxy_url?: string | null;
29
+ }
30
+ /** Readiness of image/video derivatives or the original for media without derivatives. */
31
+ export declare function mediaStatus(row: MediaStatusInput): MediaStatus;
32
+ /** Readiness of the original media itself, ignoring derivatives. */
33
+ export declare function originalMediaStatus(row: Pick<MediaStatusInput, "file_path" | "media_url" | "processing_blocked_at">): MediaStatus;