@adventurelabs/scout-core 2.0.18 → 2.1.1

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 +78 -0
  48. package/package.json +4 -2
@@ -0,0 +1,86 @@
1
+ import { isNonEmptyStorageFilePath } from "./storagePath";
2
+ /** Extensions classified by the client; image/video lists mirror the queue predicates. */
3
+ export const VIDEO_FILE_EXTENSIONS = [
4
+ "mp4",
5
+ "m4v",
6
+ "webm",
7
+ "mov",
8
+ "mkv",
9
+ "avi",
10
+ ];
11
+ export const IMAGE_FILE_EXTENSIONS = [
12
+ "jpg",
13
+ "jpeg",
14
+ "png",
15
+ "gif",
16
+ "webp",
17
+ "bmp",
18
+ ];
19
+ export const AUDIO_FILE_EXTENSIONS = [
20
+ "wav",
21
+ "mp3",
22
+ "m4a",
23
+ "aac",
24
+ "flac",
25
+ "ogg",
26
+ ];
27
+ const KIND_BY_EXTENSION = new Map([
28
+ ...VIDEO_FILE_EXTENSIONS.map((ext) => [ext, "video"]),
29
+ ...IMAGE_FILE_EXTENSIONS.map((ext) => [ext, "image"]),
30
+ ...AUDIO_FILE_EXTENSIONS.map((ext) => [ext, "audio"]),
31
+ ]);
32
+ /** Lowercased extension of a storage path or URL, or null when there is none. */
33
+ export function mediaFileExtension(filePath) {
34
+ if (!isNonEmptyStorageFilePath(filePath))
35
+ return null;
36
+ const withoutQuery = filePath.split(/[?#]/)[0];
37
+ const name = withoutQuery.slice(withoutQuery.lastIndexOf("/") + 1);
38
+ const dot = name.lastIndexOf(".");
39
+ if (dot <= 0 || dot === name.length - 1)
40
+ return null;
41
+ return name.slice(dot + 1).toLowerCase();
42
+ }
43
+ /** One classifier for events and artifacts; a recognized extension wins over the stored type. */
44
+ export function classifyMedia(row) {
45
+ const extension = mediaFileExtension(row.file_path) ?? mediaFileExtension(row.media_url);
46
+ const byExtension = extension ? KIND_BY_EXTENSION.get(extension) : undefined;
47
+ if (byExtension)
48
+ return byExtension;
49
+ if (row.media_type)
50
+ return row.media_type;
51
+ if (row.modality === "video")
52
+ return "video";
53
+ return "unknown";
54
+ }
55
+ export const isVideoMedia = (row) => classifyMedia(row) === "video";
56
+ /** Readiness of image/video derivatives or the original for media without derivatives. */
57
+ export function mediaStatus(row) {
58
+ const kind = classifyMedia(row);
59
+ if (kind !== "image" && kind !== "video")
60
+ return originalMediaStatus(row);
61
+ const derivatives = kind === "video"
62
+ ? [
63
+ { path: row.proxy_file_path, url: row.proxy_url },
64
+ { path: row.thumbnail_file_path, url: row.thumbnail_url },
65
+ ]
66
+ : [{ path: row.thumbnail_file_path, url: row.thumbnail_url }];
67
+ const present = derivatives.filter(({ path }) => isNonEmptyStorageFilePath(path));
68
+ if (present.some(({ url }) => url))
69
+ return "ready";
70
+ // An unsigned derivative is recoverable by re-signing, unlike work that never ran.
71
+ if (present.length > 0)
72
+ return "url_unavailable";
73
+ if (row.processing_blocked_at)
74
+ return "blocked";
75
+ return "processing";
76
+ }
77
+ /** Readiness of the original media itself, ignoring derivatives. */
78
+ export function originalMediaStatus(row) {
79
+ if (row.media_url)
80
+ return "ready";
81
+ if (isNonEmptyStorageFilePath(row.file_path))
82
+ return "url_unavailable";
83
+ if (row.processing_blocked_at)
84
+ return "blocked";
85
+ return "processing";
86
+ }
@@ -1,7 +1,8 @@
1
1
  import type { SupabaseClient } from "@supabase/supabase-js";
2
- import type { IArtifact, IArtifactWithMediaUrl, ISignedMediaUrls } from "../types/db";
2
+ import type { IArtifact, IArtifactWithMediaUrl, IGalleryItem, IGalleryItemRow, ISignedMediaUrls } from "../types/db";
3
3
  import type { Database } from "../types/supabase";
4
4
  import { type IEventMediaPaths } from "./eventMedia";
5
5
  export declare function createSignedUrlMap(client: SupabaseClient<Database>, paths: Iterable<string | null | undefined>): Promise<Map<string, string | null>>;
6
6
  export declare function addEventMediaUrls<T extends IEventMediaPaths>(client: SupabaseClient<Database>, events: T[]): Promise<Array<T & ISignedMediaUrls>>;
7
7
  export declare function addArtifactMediaUrls(client: SupabaseClient<Database>, artifacts: IArtifact[]): Promise<IArtifactWithMediaUrl[]>;
8
+ export declare function addGalleryMediaUrls(client: SupabaseClient<Database>, items: IGalleryItemRow[]): Promise<IGalleryItem[]>;
@@ -1,5 +1,6 @@
1
1
  import { collectArtifactStoragePaths, withArtifactMediaUrls, } from "./artifactMedia";
2
- import { collectEventStoragePaths, withEventMediaUrls, } from "./eventMedia";
2
+ import { withGalleryMediaUrls } from "./galleryMedia";
3
+ import { collectMediaStoragePaths, withEventMediaUrls, } from "./eventMedia";
3
4
  import { generateSignedUrlsBatchWithClient } from "./storage_internal";
4
5
  import { isNonEmptyStorageFilePath } from "./storagePath";
5
6
  export async function createSignedUrlMap(client, paths) {
@@ -8,10 +9,20 @@ export async function createSignedUrlMap(client, paths) {
8
9
  return new Map(uniquePaths.map((path, index) => [path, urls[index]]));
9
10
  }
10
11
  export async function addEventMediaUrls(client, events) {
11
- const urlMap = await createSignedUrlMap(client, collectEventStoragePaths(events));
12
+ const urlMap = await createSignedUrlMap(client, collectMediaStoragePaths(events));
12
13
  return events.map((event) => withEventMediaUrls(event, urlMap));
13
14
  }
14
15
  export async function addArtifactMediaUrls(client, artifacts) {
15
16
  const urlMap = await createSignedUrlMap(client, collectArtifactStoragePaths(artifacts));
16
17
  return artifacts.map((artifact) => withArtifactMediaUrls(artifact, urlMap));
17
18
  }
19
+ export async function addGalleryMediaUrls(client, items) {
20
+ let urlMap = new Map();
21
+ try {
22
+ urlMap = await createSignedUrlMap(client, collectMediaStoragePaths(items));
23
+ }
24
+ catch (error) {
25
+ console.warn("Failed to generate signed URLs for gallery:", error);
26
+ }
27
+ return items.map((item) => withGalleryMediaUrls(item, urlMap));
28
+ }
@@ -1,8 +1,13 @@
1
1
  import { Database } from "../types/supabase";
2
- import { EntityLifecycle, IPart, IPartWithProduct, PartInsert } from "../types/db";
2
+ import { EntityLifecycle, IPart, IProductCatalog, PartInsert } from "../types/db";
3
3
  import { IWebResponseCompatible } from "../types/requests";
4
4
  import { SupabaseClient } from "@supabase/supabase-js";
5
5
  import { LifecycleUpdateOptions } from "./lifecycle";
6
+ /** Parts grouped by device, plus one shared copy of each referenced product. */
7
+ export interface IPartsByDeviceWithCatalog {
8
+ parts_by_device: Record<number, IPart[]>;
9
+ product_catalog: IProductCatalog;
10
+ }
6
11
  export declare function get_parts_by_device_id(client: SupabaseClient<Database>, device_id: number): Promise<IWebResponseCompatible<IPart[]>>;
7
12
  export declare function get_part_by_id(client: SupabaseClient<Database>, part_id: number): Promise<IWebResponseCompatible<IPart | null>>;
8
13
  export declare function get_parts_by_serial_number(client: SupabaseClient<Database>, serial_number: string): Promise<IWebResponseCompatible<IPart[]>>;
@@ -16,7 +21,7 @@ export declare function delete_part(client: SupabaseClient<Database>, part_id: n
16
21
  export declare function update_part_status(client: SupabaseClient<Database>, part_id: number, lifecycle: EntityLifecycle, options?: LifecycleUpdateOptions): Promise<IWebResponseCompatible<IPart | null>>;
17
22
  export declare function get_parts_by_certificate_id(client: SupabaseClient<Database>, certificate_id: number): Promise<IWebResponseCompatible<IPart[]>>;
18
23
  export declare function get_parts_by_herd_id(client: SupabaseClient<Database>, herd_id: number): Promise<IWebResponseCompatible<IPart[]>>;
19
- export declare function get_parts_by_herd_ids(client: SupabaseClient<Database>, herd_ids: number[]): Promise<IWebResponseCompatible<Record<number, IPartWithProduct[]>>>;
24
+ export declare function get_parts_by_herd_ids(client: SupabaseClient<Database>, herd_ids: number[]): Promise<IWebResponseCompatible<IPartsByDeviceWithCatalog>>;
20
25
  export declare function restore_part(client: SupabaseClient<Database>, part_id: number, options?: LifecycleUpdateOptions): Promise<IWebResponseCompatible<IPart | null>>;
21
26
  export declare function hard_delete_part(client: SupabaseClient<Database>, part_id: number): Promise<IWebResponseCompatible<IPart | null>>;
22
27
  export declare function get_deleted_parts_by_device_id(client: SupabaseClient<Database>, device_id: number): Promise<IWebResponseCompatible<IPart[]>>;
@@ -1,5 +1,6 @@
1
1
  import { IWebResponse } from "../types/requests";
2
2
  import { update_part_lifecycle } from "./lifecycle";
3
+ import { build_product_catalog, EMPTY_PRODUCT_CATALOG, } from "./product_catalog";
3
4
  export async function get_parts_by_device_id(client, device_id) {
4
5
  const { data, error } = await client
5
6
  .from("parts")
@@ -173,7 +174,10 @@ export async function get_parts_by_herd_id(client, herd_id) {
173
174
  export async function get_parts_by_herd_ids(client, herd_ids) {
174
175
  var _a;
175
176
  if (herd_ids.length === 0) {
176
- return IWebResponse.success({}).to_compatible();
177
+ return IWebResponse.success({
178
+ parts_by_device: {},
179
+ product_catalog: EMPTY_PRODUCT_CATALOG,
180
+ }).to_compatible();
177
181
  }
178
182
  const { data, error } = await client
179
183
  .from("parts")
@@ -191,18 +195,23 @@ export async function get_parts_by_herd_ids(client, herd_ids) {
191
195
  if (error) {
192
196
  return IWebResponse.error(error.message).to_compatible();
193
197
  }
194
- const grouped = {};
198
+ const parts_by_device = {};
199
+ const product_entries = [];
195
200
  for (const row of data ?? []) {
196
201
  if (row.device_id == null)
197
202
  continue;
198
203
  const { product_numbers, devices: _devices, ...partRow } = row;
199
- const part = {
200
- ...partRow,
204
+ const part = partRow;
205
+ product_entries.push({
206
+ product_number: part.product_number,
201
207
  product: product_numbers?.products ?? null,
202
- };
203
- (grouped[_a = row.device_id] ?? (grouped[_a] = [])).push(part);
208
+ });
209
+ (parts_by_device[_a = row.device_id] ?? (parts_by_device[_a] = [])).push(part);
204
210
  }
205
- return IWebResponse.success(grouped).to_compatible();
211
+ return IWebResponse.success({
212
+ parts_by_device,
213
+ product_catalog: build_product_catalog(product_entries),
214
+ }).to_compatible();
206
215
  }
207
216
  export async function restore_part(client, part_id, options) {
208
217
  const { data, error } = await client
@@ -193,7 +193,7 @@ export declare function server_get_parts_by_herd_id(herd_id: number): Promise<im
193
193
  serial_number: string;
194
194
  updated_at: string | null;
195
195
  }[]>>;
196
- export declare function server_get_parts_by_herd_ids(herd_ids: number[]): Promise<import("../types").IWebResponseCompatible<Record<number, import("../types").IPartWithProduct[]>>>;
196
+ export declare function server_get_parts_by_herd_ids(herd_ids: number[]): Promise<import("../types").IWebResponseCompatible<import("./parts").IPartsByDeviceWithCatalog>>;
197
197
  export declare function server_restore_part(part_id: number, options?: LifecycleUpdateOptions): Promise<import("../types").IWebResponseCompatible<{
198
198
  certificate_id: number | null;
199
199
  created_at: string;
@@ -0,0 +1,9 @@
1
+ import { IDevice, IPart, IProduct, IProductCatalog } from "../types/db";
2
+ export declare const EMPTY_PRODUCT_CATALOG: IProductCatalog;
3
+ export declare function build_product_catalog(entries: Iterable<{
4
+ product_number: string;
5
+ product: IProduct | null | undefined;
6
+ }>): IProductCatalog;
7
+ export declare function get_product_for_part(catalog: IProductCatalog | null | undefined, part: Pick<IPart, "product_number"> | null | undefined): IProduct | null;
8
+ /** Distinct products across a device's parts, in part order. */
9
+ export declare function get_products_for_device(catalog: IProductCatalog | null | undefined, device: Pick<IDevice, "parts"> | null | undefined): IProduct[];
@@ -0,0 +1,36 @@
1
+ export const EMPTY_PRODUCT_CATALOG = {
2
+ products: {},
3
+ product_id_by_number: {},
4
+ };
5
+ export function build_product_catalog(entries) {
6
+ var _a, _b;
7
+ const catalog = {
8
+ products: {},
9
+ product_id_by_number: {},
10
+ };
11
+ for (const { product_number, product } of entries) {
12
+ if (!product)
13
+ continue;
14
+ catalog.product_id_by_number[product_number] = product.id;
15
+ (_a = catalog.products)[_b = product.id] ?? (_a[_b] = product);
16
+ }
17
+ return catalog;
18
+ }
19
+ export function get_product_for_part(catalog, part) {
20
+ if (!catalog || !part?.product_number)
21
+ return null;
22
+ const product_id = catalog.product_id_by_number[part.product_number];
23
+ if (product_id == null)
24
+ return null;
25
+ return catalog.products[product_id] ?? null;
26
+ }
27
+ /** Distinct products across a device's parts, in part order. */
28
+ export function get_products_for_device(catalog, device) {
29
+ const products = new Map();
30
+ for (const part of device?.parts ?? []) {
31
+ const product = get_product_for_part(catalog, part);
32
+ if (product)
33
+ products.set(product.id, product);
34
+ }
35
+ return Array.from(products.values());
36
+ }
@@ -17,6 +17,7 @@ export declare function server_get_session_incidents_by_session_id(session_id: n
17
17
  operator: string | null;
18
18
  photo: string | null;
19
19
  session_id: number;
20
+ timestamp_observation: string | null;
20
21
  updated_at: string;
21
22
  updated_by: string | null;
22
23
  }[]>>;
@@ -38,6 +39,7 @@ export declare function server_get_session_incident_by_id(incident_id: number):
38
39
  operator: string | null;
39
40
  photo: string | null;
40
41
  session_id: number;
42
+ timestamp_observation: string | null;
41
43
  updated_at: string;
42
44
  updated_by: string | null;
43
45
  } | null>>;
@@ -59,6 +61,7 @@ export declare function server_create_session_incident(row: SessionIncidentInser
59
61
  operator: string | null;
60
62
  photo: string | null;
61
63
  session_id: number;
64
+ timestamp_observation: string | null;
62
65
  updated_at: string;
63
66
  updated_by: string | null;
64
67
  } | null>>;
@@ -80,6 +83,7 @@ export declare function server_update_session_incident(incident_id: number, patc
80
83
  operator: string | null;
81
84
  photo: string | null;
82
85
  session_id: number;
86
+ timestamp_observation: string | null;
83
87
  updated_at: string;
84
88
  updated_by: string | null;
85
89
  } | null>>;
@@ -101,6 +105,7 @@ export declare function server_delete_session_incident(incident_id: number): Pro
101
105
  operator: string | null;
102
106
  photo: string | null;
103
107
  session_id: number;
108
+ timestamp_observation: string | null;
104
109
  updated_at: string;
105
110
  updated_by: string | null;
106
111
  } | null>>;
@@ -1,4 +1,5 @@
1
- import { SupabaseClient } from "@supabase/supabase-js";
2
- import { Database } from "../types/supabase";
3
- export declare function generateSignedUrlsBatchWithClient(supabase: SupabaseClient<Database>, filePaths: string[], expiresIn?: number): Promise<(string | null)[]>;
1
+ import type { SupabaseClient } from "@supabase/supabase-js";
2
+ import type { Database } from "../types/supabase";
3
+ /** Signs every path with one request per bucket rather than one per path. */
4
+ export declare function generateSignedUrlsBatchWithClient(supabase: SupabaseClient<Database>, filePaths: readonly string[], expiresIn?: number): Promise<(string | null)[]>;
4
5
  export declare function generateSignedUrlWithClient(supabase: SupabaseClient<Database>, filePath: string, expiresIn?: number): Promise<string | null>;
@@ -1,27 +1,59 @@
1
1
  import { SIGNED_URL_EXPIRATION_SECONDS } from "../constants/db";
2
2
  import { parseStorageFilePath } from "./storagePath";
3
+ const normalizeExpiration = (expiresIn) => Number.isSafeInteger(expiresIn) && expiresIn > 0
4
+ ? expiresIn
5
+ : SIGNED_URL_EXPIRATION_SECONDS;
6
+ /** Signs every path with one request per bucket rather than one per path. */
3
7
  export async function generateSignedUrlsBatchWithClient(supabase, filePaths, expiresIn = SIGNED_URL_EXPIRATION_SECONDS) {
4
- return Promise.all(filePaths.map(async (filePath) => {
8
+ const signedUrls = filePaths.map(() => null);
9
+ const expirationSeconds = normalizeExpiration(expiresIn);
10
+ const requestsByBucket = new Map();
11
+ let unparsedPathCount = 0;
12
+ filePaths.forEach((filePath, index) => {
13
+ const parsed = parseStorageFilePath(filePath);
14
+ if (!parsed) {
15
+ unparsedPathCount += 1;
16
+ return;
17
+ }
18
+ const requests = requestsByBucket.get(parsed.bucket) ?? new Map();
19
+ const indices = requests.get(parsed.objectPath);
20
+ if (indices) {
21
+ indices.push(index);
22
+ }
23
+ else {
24
+ requests.set(parsed.objectPath, [index]);
25
+ }
26
+ requestsByBucket.set(parsed.bucket, requests);
27
+ });
28
+ // Legacy device-id-prefixed paths never parse, so warn once per page instead of per path.
29
+ if (unparsedPathCount > 0) {
30
+ console.warn(`Skipped ${unparsedPathCount} unparseable storage path(s)`);
31
+ }
32
+ await Promise.all(Array.from(requestsByBucket, async ([bucket, requestsByPath]) => {
33
+ const objectPaths = Array.from(requestsByPath.keys());
5
34
  try {
6
- const parsed = parseStorageFilePath(filePath);
7
- if (!parsed) {
8
- console.error("Invalid file path:", filePath);
9
- return null;
10
- }
11
35
  const { data, error } = await supabase.storage
12
- .from(parsed.bucket)
13
- .createSignedUrl(parsed.objectPath, expiresIn);
14
- if (error) {
15
- console.warn(`Error generating signed URL for ${filePath}:`, error.message);
16
- return null;
36
+ .from(bucket)
37
+ .createSignedUrls(objectPaths, expirationSeconds);
38
+ if (error || !data) {
39
+ console.warn(`Error generating signed URLs for bucket ${bucket}:`, error?.message);
40
+ return;
17
41
  }
18
- return data.signedUrl;
42
+ // Signing fails per path, so an unsigned path stays null without losing the others.
43
+ data.forEach((entry, position) => {
44
+ const objectPath = entry.path != null && requestsByPath.has(entry.path)
45
+ ? entry.path
46
+ : objectPaths[position];
47
+ requestsByPath.get(objectPath)?.forEach((index) => {
48
+ signedUrls[index] = entry.signedUrl || null;
49
+ });
50
+ });
19
51
  }
20
52
  catch (error) {
21
- console.warn(`Exception generating signed URL for ${filePath}:`, error);
22
- return null;
53
+ console.warn(`Exception generating signed URLs for bucket ${bucket}:`, error);
23
54
  }
24
55
  }));
56
+ return signedUrls;
25
57
  }
26
58
  export async function generateSignedUrlWithClient(supabase, filePath, expiresIn = SIGNED_URL_EXPIRATION_SECONDS) {
27
59
  const [signedUrl] = await generateSignedUrlsBatchWithClient(supabase, [filePath], expiresIn);
@@ -344,6 +344,8 @@ export async function get_event_and_tags_by_event_id_query(client, event_id) {
344
344
  thumbnail_generated_at: data[0].thumbnail_generated_at ?? null,
345
345
  proxy_file_path: data[0].proxy_file_path ?? null,
346
346
  proxy_generated_at: data[0].proxy_generated_at ?? null,
347
+ processing_blocked_at: data[0].processing_blocked_at ?? null,
348
+ processing_blocked_reason: data[0].processing_blocked_reason ?? null,
347
349
  };
348
350
  const [eventWithUrl] = await addEventMediaUrls(client, [transformedData]);
349
351
  return IWebResponse.success(eventWithUrl).to_compatible();
@@ -18,5 +18,5 @@ export { useScoutRealtimeMaintenanceRequests } from "./useScoutRealtimeMaintenan
18
18
  export { useScoutRealtimeContacts } from "./useScoutRealtimeContacts";
19
19
  export { useScoutRealtimeSessionIncidents } from "./useScoutRealtimeSessionIncidents";
20
20
  export { useScoutRealtimeOperatingContexts, type ComplianceRealtimeData, type ComplianceRealtimeRow, type OperatingContextRealtimeData, type OperatingContextRealtimeRow, } from "./useScoutRealtimeOperatingContexts";
21
- export { useInfiniteSessionsByHerd, useInfiniteSessionsByDevice, useInfiniteEventsByHerd, useInfiniteEventsByDevice, useInfiniteArtifactsByHerd, useInfiniteArtifactsByDevice, useInfiniteFeedByHerd, useInfiniteFeedByDevice, useInfiniteAnalysisJobs, useInfiniteAnalysisTasks, useIntersectionObserver, type InfiniteScrollData, type InfiniteRefetchOptions, type UseInfiniteScrollOptions, type UseAnalysisJobsInfiniteOptions, type UseAnalysisTasksInfiniteOptions, } from "./useInfiniteQuery";
21
+ export { useInfiniteSessionsByHerd, useInfiniteSessionsByDevice, useInfiniteEventsByHerd, useInfiniteEventsByDevice, useInfiniteArtifactsByHerd, useInfiniteArtifactsByDevice, useInfiniteFeedByHerd, useInfiniteFeedByDevice, useInfiniteGalleryByHerd, useInfiniteGalleryByDevice, useInfiniteAnalysisJobs, useInfiniteAnalysisTasks, useIntersectionObserver, type InfiniteScrollData, type InfiniteRefetchOptions, type UseInfiniteScrollOptions, type UseAnalysisJobsInfiniteOptions, type UseAnalysisTasksInfiniteOptions, } from "./useInfiniteQuery";
22
22
  export { useLoadingPerformance, useSessionSummariesByHerd, useHasSessionSummaries, } from "../store/hooks";
@@ -19,6 +19,6 @@ export { useScoutRealtimeContacts } from "./useScoutRealtimeContacts";
19
19
  export { useScoutRealtimeSessionIncidents } from "./useScoutRealtimeSessionIncidents";
20
20
  export { useScoutRealtimeOperatingContexts, } from "./useScoutRealtimeOperatingContexts";
21
21
  // RTK Query infinite scroll hooks
22
- export { useInfiniteSessionsByHerd, useInfiniteSessionsByDevice, useInfiniteEventsByHerd, useInfiniteEventsByDevice, useInfiniteArtifactsByHerd, useInfiniteArtifactsByDevice, useInfiniteFeedByHerd, useInfiniteFeedByDevice, useInfiniteAnalysisJobs, useInfiniteAnalysisTasks, useIntersectionObserver, } from "./useInfiniteQuery";
22
+ export { useInfiniteSessionsByHerd, useInfiniteSessionsByDevice, useInfiniteEventsByHerd, useInfiniteEventsByDevice, useInfiniteArtifactsByHerd, useInfiniteArtifactsByDevice, useInfiniteFeedByHerd, useInfiniteFeedByDevice, useInfiniteGalleryByHerd, useInfiniteGalleryByDevice, useInfiniteAnalysisJobs, useInfiniteAnalysisTasks, useIntersectionObserver, } from "./useInfiniteQuery";
23
23
  // Session summaries and performance hooks
24
24
  export { useLoadingPerformance, useSessionSummariesByHerd, useHasSessionSummaries, } from "../store/hooks";
@@ -1,5 +1,5 @@
1
1
  import { SupabaseClient } from "@supabase/supabase-js";
2
- import { IArtifactWithMediaUrl, ISessionWithCoordinates, IEventAndTagsPrettyLocation, IFeedItem, IAnalysisJob, IAnalysisTask, AnalysisWorkStatus } from "../types/db";
2
+ import { IArtifactWithMediaUrl, ISessionWithCoordinates, IEventAndTagsPrettyLocation, IFeedItem, IGalleryItem, IAnalysisJob, IAnalysisTask, AnalysisWorkStatus } from "../types/db";
3
3
  export interface UseInfiniteScrollOptions {
4
4
  limit?: number;
5
5
  enabled?: boolean;
@@ -37,6 +37,8 @@ export declare const useInfiniteArtifactsByHerd: (herdId: number, options: UseIn
37
37
  export declare const useInfiniteArtifactsByDevice: (deviceId: number, options: UseInfiniteScrollOptions) => InfiniteScrollData<IArtifactWithMediaUrl>;
38
38
  export declare const useInfiniteFeedByHerd: (herdId: number, options: UseInfiniteScrollOptions) => InfiniteScrollData<IFeedItem>;
39
39
  export declare const useInfiniteFeedByDevice: (deviceId: number, options: UseInfiniteScrollOptions) => InfiniteScrollData<IFeedItem>;
40
+ export declare const useInfiniteGalleryByHerd: (herdId: number, options: UseInfiniteScrollOptions) => InfiniteScrollData<IGalleryItem>;
41
+ export declare const useInfiniteGalleryByDevice: (deviceId: number, options: UseInfiniteScrollOptions) => InfiniteScrollData<IGalleryItem>;
40
42
  export interface UseAnalysisJobsInfiniteOptions {
41
43
  limit?: number;
42
44
  enabled?: boolean;
@@ -1,34 +1,8 @@
1
1
  "use client";
2
2
  import { useState, useCallback, useMemo, useEffect, useRef } from "react";
3
- import { useGetSessionsInfiniteByHerdQuery, useGetSessionsInfiniteByDeviceQuery, useGetEventsInfiniteByHerdQuery, useGetEventsInfiniteByDeviceQuery, useGetArtifactsInfiniteByHerdQuery, useGetArtifactsInfiniteByDeviceQuery, useGetFeedInfiniteByHerdQuery, useGetFeedInfiniteByDeviceQuery, useGetAnalysisJobsInfiniteQuery, useGetAnalysisTasksInfiniteQuery, } from "../store/api";
4
- /** Signed media URLs are re-minted on every fetch, so they never indicate a changed row. */
5
- const REMINTED_URL_KEYS = new Set(["media_url", "thumbnail_url", "proxy_url"]);
6
- function rowsEqual(left, right) {
7
- if (left === right)
8
- return true;
9
- if (typeof left !== "object" ||
10
- typeof right !== "object" ||
11
- left === null ||
12
- right === null) {
13
- return false;
14
- }
15
- if (Array.isArray(left) || Array.isArray(right)) {
16
- return (Array.isArray(left) &&
17
- Array.isArray(right) &&
18
- left.length === right.length &&
19
- left.every((item, index) => rowsEqual(item, right[index])));
20
- }
21
- const keys = Object.keys(left).filter((key) => !REMINTED_URL_KEYS.has(key));
22
- const rightKeys = Object.keys(right).filter((key) => !REMINTED_URL_KEYS.has(key));
23
- return (keys.length === rightKeys.length &&
24
- keys.every((key) => Object.prototype.hasOwnProperty.call(right, key) &&
25
- rowsEqual(Reflect.get(left, key), Reflect.get(right, key))));
26
- }
27
- /**
28
- * Reuse the previous row object when a refetch returned the same content, so consumers
29
- * can memoize on row identity. Rows that only differ by a re-minted signed URL keep the
30
- * earlier object, and therefore the earlier URL, until the row itself changes.
31
- */
3
+ import { useGetSessionsInfiniteByHerdQuery, useGetSessionsInfiniteByDeviceQuery, useGetEventsInfiniteByHerdQuery, useGetEventsInfiniteByDeviceQuery, useGetArtifactsInfiniteByHerdQuery, useGetArtifactsInfiniteByDeviceQuery, useGetFeedInfiniteByHerdQuery, useGetFeedInfiniteByDeviceQuery, useGetGalleryInfiniteByHerdQuery, useGetGalleryInfiniteByDeviceQuery, useGetAnalysisJobsInfiniteQuery, useGetAnalysisTasksInfiniteQuery, } from "../store/api";
4
+ import { normalizePageLimit, rowsEqualIgnoringSignedUrls, withRefreshedSignedUrls, } from "../helpers/mediaRows";
5
+ /** Reuses the previous row object, with fresh signed URLs, so consumers can memoize on identity. */
32
6
  function preserveRowReferences(previous, next, rowKey) {
33
7
  const previousByKey = new Map(previous
34
8
  .map((row) => [rowKey(row), row])
@@ -36,7 +10,9 @@ function preserveRowReferences(previous, next, rowKey) {
36
10
  return next.map((row) => {
37
11
  const key = rowKey(row);
38
12
  const previousRow = key == null ? undefined : previousByKey.get(key);
39
- return previousRow && rowsEqual(previousRow, row) ? previousRow : row;
13
+ return previousRow && rowsEqualIgnoringSignedUrls(previousRow, row)
14
+ ? withRefreshedSignedUrls(previousRow, row)
15
+ : row;
40
16
  });
41
17
  }
42
18
  /** Cursors are flat records of scalars, so a shallow comparison identifies a page. */
@@ -51,6 +27,7 @@ function sameCursor(left, right) {
51
27
  }
52
28
  const rowId = (row) => row.id;
53
29
  const feedRowKey = (row) => `${row.sort_ts ?? ""}_${row.sort_id ?? ""}_${row.feed_type ?? ""}`;
30
+ const galleryRowKey = (row) => `${row.feed_type ?? ""}_${row.id ?? ""}`;
54
31
  const emptyPaging = (key) => ({
55
32
  key,
56
33
  cursor: null,
@@ -234,7 +211,7 @@ function useFiltersKey(options) {
234
211
  ]);
235
212
  }
236
213
  const rangeArgs = (options) => ({
237
- limit: options.limit || 20,
214
+ limit: normalizePageLimit(options.limit),
238
215
  supabase: options.supabase,
239
216
  rangeStart: options.rangeStart ?? null,
240
217
  rangeEnd: options.rangeEnd ?? null,
@@ -259,16 +236,12 @@ const artifactsPage = (response) => ({
259
236
  nextCursor: response.nextCursor,
260
237
  hasMore: response.hasMore,
261
238
  });
262
- const feedPage = (response) => {
263
- const rows = Array.isArray(response.items) ? response.items : [];
264
- return {
265
- rows,
266
- nextCursor: response.nextCursor,
267
- // The feed RPC can return fewer rows than the limit while more still exist, so any
268
- // non-empty page with a next cursor means there is more to load.
269
- hasMore: rows.length > 0 && response.nextCursor !== null,
270
- };
271
- };
239
+ /** The RPCs over-fetch by one to answer `hasMore`, so an exactly-full page is terminal. */
240
+ const itemsPage = (response) => ({
241
+ rows: Array.isArray(response.items) ? response.items : [],
242
+ nextCursor: response.nextCursor,
243
+ hasMore: response.hasMore,
244
+ });
272
245
  const analysisJobsPage = (response) => ({
273
246
  rows: response.jobs,
274
247
  nextCursor: response.nextCursor,
@@ -350,7 +323,7 @@ export const useInfiniteFeedByHerd = (herdId, options) => {
350
323
  const paging = usePaging(`herd:${herdId}|${filtersKey}`);
351
324
  const query = useGetFeedInfiniteByHerdQuery({ herdId, cursor: paging.cursor, ...rangeArgs(options) }, { skip: !options.enabled || !herdId });
352
325
  return useInfinitePages(paging, query, {
353
- toPage: feedPage,
326
+ toPage: itemsPage,
354
327
  rowKey: feedRowKey,
355
328
  });
356
329
  };
@@ -359,10 +332,31 @@ export const useInfiniteFeedByDevice = (deviceId, options) => {
359
332
  const paging = usePaging(`device:${deviceId}|${filtersKey}`);
360
333
  const query = useGetFeedInfiniteByDeviceQuery({ deviceId, cursor: paging.cursor, ...rangeArgs(options) }, { skip: !options.enabled || !deviceId });
361
334
  return useInfinitePages(paging, query, {
362
- toPage: feedPage,
335
+ toPage: itemsPage,
363
336
  rowKey: feedRowKey,
364
337
  });
365
338
  };
339
+ // =====================================================
340
+ // GALLERY (lean merged events + artifacts)
341
+ // =====================================================
342
+ export const useInfiniteGalleryByHerd = (herdId, options) => {
343
+ const filtersKey = useFiltersKey(options);
344
+ const paging = usePaging(`herd:${herdId}|${filtersKey}`);
345
+ const query = useGetGalleryInfiniteByHerdQuery({ herdId, cursor: paging.cursor, ...rangeArgs(options) }, { skip: !options.enabled || !herdId });
346
+ return useInfinitePages(paging, query, {
347
+ toPage: itemsPage,
348
+ rowKey: galleryRowKey,
349
+ });
350
+ };
351
+ export const useInfiniteGalleryByDevice = (deviceId, options) => {
352
+ const filtersKey = useFiltersKey(options);
353
+ const paging = usePaging(`device:${deviceId}|${filtersKey}`);
354
+ const query = useGetGalleryInfiniteByDeviceQuery({ deviceId, cursor: paging.cursor, ...rangeArgs(options) }, { skip: !options.enabled || !deviceId });
355
+ return useInfinitePages(paging, query, {
356
+ toPage: itemsPage,
357
+ rowKey: galleryRowKey,
358
+ });
359
+ };
366
360
  export const useInfiniteAnalysisJobs = (options) => {
367
361
  const status = options.status ?? null;
368
362
  const paging = usePaging(`status:${status}`);
@@ -8,5 +8,9 @@ export type ScoutRealtimeBroadcastOptions = {
8
8
  backoffInitialMs?: number;
9
9
  backoffMaxMs?: number;
10
10
  backoffMaxAttempts?: number;
11
+ /** Called once a topic is back after a drop, since broadcasts sent meanwhile are gone. */
12
+ onMissedUpdates?: () => void;
13
+ /** Rejoin topics no longer joined when the tab regains focus or the browser reconnects. */
14
+ recoverOnFocus?: boolean;
11
15
  };
12
16
  export declare function useScoutRealtimeBroadcast<T>(scoutSupabase: SupabaseClient<Database>, topics: string[], options?: ScoutRealtimeBroadcastOptions, rowFilter?: (row: T) => boolean): [RealtimeData<T> | null, () => void];