diff --git a/core/src/ops/locations/update/action.rs b/core/src/ops/locations/update/action.rs index ee497c3e5..7fcbd2931 100644 --- a/core/src/ops/locations/update/action.rs +++ b/core/src/ops/locations/update/action.rs @@ -66,7 +66,7 @@ impl LibraryAction for LocationUpdateAction { .ok_or_else(|| ActionError::LocationNotFound(self.input.id))?; // Build the update - let mut active: entities::location::ActiveModel = location.into(); + let mut active: entities::location::ActiveModel = location.clone().into(); if let Some(name) = &self.input.name { active.name = Set(Some(name.clone())); @@ -82,7 +82,76 @@ impl LibraryAction for LocationUpdateAction { active.updated_at = Set(chrono::Utc::now()); // Execute update - active.update(db).await.map_err(ActionError::SeaOrm)?; + let updated_location = active.update(db).await.map_err(ActionError::SeaOrm)?; + + // Emit ResourceChanged event for UI reactivity + // Note: job_policies is local-only config (not synced), so we emit regular event not sync event + // Build LocationInfo for the event + let entry = entities::entry::Entity::find_by_id( + updated_location + .entry_id + .ok_or_else(|| ActionError::Internal("Location has no entry_id".to_string()))?, + ) + .one(db) + .await + .map_err(ActionError::SeaOrm)? + .ok_or_else(|| ActionError::Internal("Location entry not found".to_string()))?; + + let directory_path = entities::directory_paths::Entity::find_by_id(entry.id) + .one(db) + .await + .map_err(ActionError::SeaOrm)? + .ok_or_else(|| { + ActionError::Internal(format!( + "No directory path found for location {} entry {}", + updated_location.uuid, entry.id + )) + })?; + + let device = entities::device::Entity::find_by_id(updated_location.device_id) + .one(db) + .await + .map_err(ActionError::SeaOrm)? + .ok_or_else(|| { + ActionError::Internal(format!( + "Device not found for location {}", + updated_location.uuid + )) + })?; + + let sd_path = crate::domain::SdPath::Physical { + device_slug: device.slug.clone(), + path: directory_path.path.clone().into(), + }; + + let job_policies = updated_location + .job_policies + .as_ref() + .and_then(|json| serde_json::from_str(json).ok()) + .unwrap_or_default(); + + let location_info = crate::ops::locations::list::LocationInfo { + id: updated_location.uuid, + path: directory_path.path.clone().into(), + name: updated_location.name.clone(), + sd_path, + job_policies, + index_mode: updated_location.index_mode.clone(), + scan_state: updated_location.scan_state.clone(), + last_scan_at: updated_location.last_scan_at, + error_message: updated_location.error_message.clone(), + total_file_count: updated_location.total_file_count, + total_byte_size: updated_location.total_byte_size, + created_at: updated_location.created_at, + updated_at: updated_location.updated_at, + }; + + context.events.emit(crate::infra::event::Event::ResourceChanged { + resource_type: "location".to_string(), + resource: serde_json::to_value(&location_info) + .map_err(|e| ActionError::Internal(format!("Failed to serialize location: {}", e)))?, + metadata: None, + }); Ok(LocationUpdateOutput { id: self.input.id }) } diff --git a/packages/interface/src/Explorer.tsx b/packages/interface/src/Explorer.tsx index 2165c87f3..128f80534 100644 --- a/packages/interface/src/Explorer.tsx +++ b/packages/interface/src/Explorer.tsx @@ -42,7 +42,6 @@ export function ExplorerLayout() { wireMethod: "query:locations.list", input: null, resourceType: "location", - isGlobalList: true, }); // Get current location if we're on a location route diff --git a/packages/interface/src/components/Explorer/components/LocationsSection.tsx b/packages/interface/src/components/Explorer/components/LocationsSection.tsx index a10327003..7f9e0bf0a 100644 --- a/packages/interface/src/components/Explorer/components/LocationsSection.tsx +++ b/packages/interface/src/components/Explorer/components/LocationsSection.tsx @@ -18,7 +18,6 @@ export function LocationsSection() { wireMethod: "query:locations.list", input: null, resourceType: "location", - isGlobalList: true, }); const locations = locationsQuery.data?.locations || []; diff --git a/packages/interface/src/components/SpacesSidebar/LocationsGroup.tsx b/packages/interface/src/components/SpacesSidebar/LocationsGroup.tsx index f2852947c..38bc98d54 100644 --- a/packages/interface/src/components/SpacesSidebar/LocationsGroup.tsx +++ b/packages/interface/src/components/SpacesSidebar/LocationsGroup.tsx @@ -16,7 +16,6 @@ export function LocationsGroup({ isCollapsed, onToggle }: LocationsGroupProps) { wireMethod: "query:locations.list", input: null, // Unit struct serializes as null, not {} resourceType: "location", - isGlobalList: true, }); const locations = locationsData?.locations ?? []; diff --git a/packages/interface/src/components/SpacesSidebar/VolumesGroup.tsx b/packages/interface/src/components/SpacesSidebar/VolumesGroup.tsx index 5e70574b2..95fa604fb 100644 --- a/packages/interface/src/components/SpacesSidebar/VolumesGroup.tsx +++ b/packages/interface/src/components/SpacesSidebar/VolumesGroup.tsx @@ -20,7 +20,6 @@ export function VolumesGroup({ wireMethod: "query:volumes.list", input: { filter }, resourceType: "volume", - isGlobalList: true, }); const volumes = volumesData?.volumes || []; diff --git a/packages/interface/src/components/SpacesSidebar/hooks/useSpaces.ts b/packages/interface/src/components/SpacesSidebar/hooks/useSpaces.ts index e520faa61..c9280db4d 100644 --- a/packages/interface/src/components/SpacesSidebar/hooks/useSpaces.ts +++ b/packages/interface/src/components/SpacesSidebar/hooks/useSpaces.ts @@ -5,7 +5,6 @@ export function useSpaces() { wireMethod: 'query:spaces.list', input: null, // Unit struct serializes as null, not {} resourceType: 'space', - isGlobalList: true, }); } diff --git a/packages/interface/src/inspectors/LocationInspector.tsx b/packages/interface/src/inspectors/LocationInspector.tsx index 940435366..afd95b520 100644 --- a/packages/interface/src/inspectors/LocationInspector.tsx +++ b/packages/interface/src/inspectors/LocationInspector.tsx @@ -1,30 +1,30 @@ import { - Info, - Gear, - Briefcase, - ClockCounterClockwise, - HardDrive, - DotsThree, - Hash, - Sparkle, - Image, - MagnifyingGlass, - Trash, - FunnelX, - ToggleLeft, - ToggleRight, - X, - Play, - FilmStrip, - VideoCamera, + Info, + Gear, + Briefcase, + ClockCounterClockwise, + HardDrive, + DotsThree, + Hash, + Sparkle, + Image, + MagnifyingGlass, + Trash, + FunnelX, + ToggleLeft, + ToggleRight, + X, + Play, + FilmStrip, + VideoCamera, } from "@phosphor-icons/react"; import { useState } from "react"; import { - InfoRow, - Section, - Divider, - Tabs, - TabContent, + InfoRow, + Section, + Divider, + Tabs, + TabContent, } from "../components/Inspector"; import clsx from "clsx"; import type { LocationInfo } from "@sd/ts-client/generated/types"; @@ -32,607 +32,691 @@ import { useLibraryMutation } from "../context"; import LocationIcon from "@sd/assets/icons/Location.png"; interface LocationInspectorProps { - location: LocationInfo; + location: LocationInfo; } export function LocationInspector({ location }: LocationInspectorProps) { - const [activeTab, setActiveTab] = useState("overview"); + const [activeTab, setActiveTab] = useState("overview"); - const tabs = [ - { id: "overview", label: "Overview", icon: Info }, - { id: "indexing", label: "Indexing", icon: Gear }, - { id: "jobs", label: "Jobs", icon: Briefcase }, - { id: "activity", label: "Activity", icon: ClockCounterClockwise }, - { id: "devices", label: "Devices", icon: HardDrive }, - { id: "more", label: "More", icon: DotsThree }, - ]; + const tabs = [ + { id: "overview", label: "Overview", icon: Info }, + { id: "indexing", label: "Indexing", icon: Gear }, + { id: "jobs", label: "Jobs", icon: Briefcase }, + { id: "activity", label: "Activity", icon: ClockCounterClockwise }, + { id: "devices", label: "Devices", icon: HardDrive }, + { id: "more", label: "More", icon: DotsThree }, + ]; - return ( - <> - {/* Tabs */} - + return ( + <> + {/* Tabs */} + - {/* Tab Content */} -
- - - + {/* Tab Content */} +
+ + + - - - + + + - - - + + + - - - + + + - - - + + + - - - -
- - ); + + + +
+ + ); } function OverviewTab({ location }: { location: LocationInfo }) { - const formatBytes = (bytes: number) => { - if (bytes === 0) return "0 B"; - const k = 1024; - const sizes = ["B", "KB", "MB", "GB", "TB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; - }; + const rescanLocation = useLibraryMutation("locations.rescan"); - const formatDate = (dateStr: string) => { - const date = new Date(dateStr); - return date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }); - }; + const formatBytes = (bytes: number) => { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; + }; - return ( -
- {/* Location icon */} -
-
- Location -
-
+ const formatDate = (dateStr: string) => { + const date = new Date(dateStr); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + }; - {/* Location name */} -
-

- {location.name || "Unnamed Location"} -

-

Local Storage

-
+ return ( +
+ {/* Location icon */} +
+ Location +
- + {/* Location name */} +
+

+ {location.name || "Unnamed Location"} +

+

+ Local Storage +

+
- {/* Details */} -
- - - - - {location.last_scan_at && ( - - )} -
+ - {/* Index Mode */} -
- -
+ {/* Details */} +
+ + + + + {location.last_scan_at && ( + + )} +
- {/* Quick Actions */} -
-
- - -
-
-
- ); + {/* Index Mode */} +
+ +
+ + {/* Quick Actions */} +
+
+ + +
+
+
+ ); } function IndexingTab({ location }: { location: LocationInfo }) { - const [indexMode, setIndexMode] = useState<"shallow" | "content" | "deep">( - location.index_mode as "shallow" | "content" | "deep", - ); - const [ignoreRules, setIgnoreRules] = useState([ - ".git", - "node_modules", - "*.tmp", - ".DS_Store", - ]); + const [indexMode, setIndexMode] = useState<"shallow" | "content" | "deep">( + location.index_mode as "shallow" | "content" | "deep", + ); + const [ignoreRules, setIgnoreRules] = useState([ + ".git", + "node_modules", + "*.tmp", + ".DS_Store", + ]); - return ( -
-
-

- Controls how deeply this location is indexed -

+ return ( +
+
+

+ Controls how deeply this location is indexed +

-
- setIndexMode("shallow")} - /> - setIndexMode("content")} - /> - setIndexMode("deep")} - /> -
-
+
+ setIndexMode("shallow")} + /> + setIndexMode("content")} + /> + setIndexMode("deep")} + /> +
+
-
-

- Files and folders matching these patterns will be ignored -

+
+

+ Files and folders matching these patterns will be ignored +

-
- {ignoreRules.map((pattern, i) => ( - { - setIgnoreRules(ignoreRules.filter((_, idx) => idx !== i)); - }} - /> - ))} -
+
+ {ignoreRules.map((pattern, i) => ( + { + setIgnoreRules( + ignoreRules.filter((_, idx) => idx !== i), + ); + }} + /> + ))} +
- -
-
- ); + + + + ); } function JobsTab({ location }: { location: LocationInfo }) { - const updateLocation = useLibraryMutation("locations.update"); - const triggerJob = useLibraryMutation("locations.triggerJob"); + const updateLocation = useLibraryMutation("locations.update"); + const triggerJob = useLibraryMutation("locations.triggerJob"); - const updatePolicy = async ( - updates: Partial, - ) => { - await updateLocation.mutateAsync({ - id: location.id, - job_policies: { - ...location.job_policies, - ...updates, - }, - }); - }; + const updatePolicy = async ( + updates: Partial, + ) => { + await updateLocation.mutateAsync({ + id: location.id, + job_policies: { + ...location.job_policies, + ...updates, + }, + }); + }; - const thumbnails = location.job_policies?.thumbnail?.enabled ?? true; - const thumbstrips = location.job_policies?.thumbstrip?.enabled ?? true; - const proxies = location.job_policies?.proxy?.enabled ?? false; - const ocr = location.job_policies?.ocr?.enabled ?? false; - const speech = location.job_policies?.speech_to_text?.enabled ?? false; + const thumbnails = location.job_policies?.thumbnail?.enabled ?? true; + const thumbstrips = location.job_policies?.thumbstrip?.enabled ?? true; + const proxies = location.job_policies?.proxy?.enabled ?? false; + const ocr = location.job_policies?.ocr?.enabled ?? false; + const speech = location.job_policies?.speech_to_text?.enabled ?? false; - return ( -
-

- Configure which processing jobs run automatically for this location -

+ return ( +
+

+ Configure which processing jobs run automatically for this + location +

-
-
- - updatePolicy({ - thumbnail: { ...location.job_policies.thumbnail, enabled }, - }) - } - onTrigger={() => - triggerJob.mutate({ - location_id: location.id, - job_type: "thumbnail", - force: false, - }) - } - isTriggering={triggerJob.isPending} - /> - - updatePolicy({ - thumbstrip: { ...location.job_policies.thumbstrip, enabled }, - }) - } - onTrigger={() => - triggerJob.mutate({ - location_id: location.id, - job_type: "thumbstrip", - force: false, - }) - } - isTriggering={triggerJob.isPending} - icon={FilmStrip} - /> - - updatePolicy({ - proxy: { ...location.job_policies.proxy, enabled }, - }) - } - onTrigger={() => - triggerJob.mutate({ - location_id: location.id, - job_type: "proxy", - force: false, - }) - } - isTriggering={triggerJob.isPending} - icon={VideoCamera} - /> -
-
+
+
+ + updatePolicy({ + thumbnail: { + ...location.job_policies.thumbnail, + enabled, + }, + }) + } + onTrigger={() => + triggerJob.mutate({ + location_id: location.id, + job_type: "thumbnail", + force: false, + }) + } + isTriggering={triggerJob.isPending} + /> + + updatePolicy({ + thumbstrip: { + ...location.job_policies.thumbstrip, + enabled, + }, + }) + } + onTrigger={() => + triggerJob.mutate({ + location_id: location.id, + job_type: "thumbstrip", + force: false, + }) + } + isTriggering={triggerJob.isPending} + icon={FilmStrip} + /> + + updatePolicy({ + proxy: { + ...location.job_policies.proxy, + enabled, + }, + }) + } + onTrigger={() => + triggerJob.mutate({ + location_id: location.id, + job_type: "proxy", + force: false, + }) + } + isTriggering={triggerJob.isPending} + icon={VideoCamera} + /> +
+
-
-
- - updatePolicy({ ocr: { ...location.job_policies.ocr, enabled } }) - } - onTrigger={() => - triggerJob.mutate({ - location_id: location.id, - job_type: "ocr", - force: false, - }) - } - isTriggering={triggerJob.isPending} - /> - - updatePolicy({ - speech_to_text: { - ...location.job_policies.speech_to_text, - enabled, - }, - }) - } - onTrigger={() => - triggerJob.mutate({ - location_id: location.id, - job_type: "speech_to_text", - force: false, - }) - } - isTriggering={triggerJob.isPending} - /> -
-
-
- ); +
+
+ + updatePolicy({ + ocr: { ...location.job_policies.ocr, enabled }, + }) + } + onTrigger={() => + triggerJob.mutate({ + location_id: location.id, + job_type: "ocr", + force: false, + }) + } + isTriggering={triggerJob.isPending} + /> + + updatePolicy({ + speech_to_text: { + ...location.job_policies.speech_to_text, + enabled, + }, + }) + } + onTrigger={() => + triggerJob.mutate({ + location_id: location.id, + job_type: "speech_to_text", + force: false, + }) + } + isTriggering={triggerJob.isPending} + /> +
+
+
+ ); } function ActivityTab({ location }: { location: LocationInfo }) { - const activity = [ - { action: "Full Scan Completed", time: "10 min ago", files: 12456 }, - { action: "Thumbnails Generated", time: "1 hour ago", files: 234 }, - { action: "Content Hashes Updated", time: "3 hours ago", files: 5678 }, - { action: "Metadata Extracted", time: "5 hours ago", files: 890 }, - { action: "Location Added", time: "Jan 15, 2025", files: 0 }, - ]; + const activity = [ + { action: "Full Scan Completed", time: "10 min ago", files: 12456 }, + { action: "Thumbnails Generated", time: "1 hour ago", files: 234 }, + { action: "Content Hashes Updated", time: "3 hours ago", files: 5678 }, + { action: "Metadata Extracted", time: "5 hours ago", files: 890 }, + { action: "Location Added", time: "Jan 15, 2025", files: 0 }, + ]; - return ( -
-

- Recent indexing activity and job history -

+ return ( +
+

+ Recent indexing activity and job history +

-
- {activity.map((item, i) => ( -
- -
-
{item.action}
-
- {item.time} - {item.files > 0 && ` · ${item.files.toLocaleString()} files`} -
-
-
- ))} -
-
- ); +
+ {activity.map((item, i) => ( +
+ +
+
+ {item.action} +
+
+ {item.time} + {item.files > 0 && + ` · ${item.files.toLocaleString()} files`} +
+
+
+ ))} +
+
+ ); } function DevicesTab({ location }: { location: LocationInfo }) { - const devices = [ - { name: "MacBook Pro", status: "online" as const, lastSeen: "2 min ago" }, - { name: "Desktop PC", status: "offline" as const, lastSeen: "2 days ago" }, - { name: "Home Server", status: "online" as const, lastSeen: "5 min ago" }, - ]; + const devices = [ + { + name: "MacBook Pro", + status: "online" as const, + lastSeen: "2 min ago", + }, + { + name: "Desktop PC", + status: "offline" as const, + lastSeen: "2 days ago", + }, + { + name: "Home Server", + status: "online" as const, + lastSeen: "5 min ago", + }, + ]; - return ( -
-

- Devices that have access to this location -

+ return ( +
+

+ Devices that have access to this location +

-
- {devices.map((device, i) => ( -
-
- -
-
- {device.name} -
-
-
- - {device.status === "online" ? "Online" : "Offline"} ·{" "} - {device.lastSeen} - -
-
-
-
- ))} -
-
- ); +
+ {devices.map((device, i) => ( +
+
+ +
+
+ {device.name} +
+
+
+ + {device.status === "online" + ? "Online" + : "Offline"}{" "} + · {device.lastSeen} + +
+
+
+
+ ))} +
+
+ ); } function MoreTab({ location }: { location: LocationInfo }) { - const removeLocation = useLibraryMutation("locations.remove"); + const removeLocation = useLibraryMutation("locations.remove"); - const formatDate = (dateStr: string) => { - const date = new Date(dateStr); - return date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }); - }; + const formatDate = (dateStr: string) => { + const date = new Date(dateStr); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + }; - return ( -
-
- - - {location.last_scan_at && ( - - )} -
+ return ( +
+
+ + + {location.last_scan_at && ( + + )} +
-
-

- Removing this location will not delete your files -

- -
-
- ); +
+

+ Removing this location will not delete your files +

+ +
+
+ ); } // Helper Components interface RadioOptionProps { - value: string; - label: string; - description: string; - checked: boolean; - onChange: () => void; + value: string; + label: string; + description: string; + checked: boolean; + onChange: () => void; } function RadioOption({ - value, - label, - description, - checked, - onChange, + value, + label, + description, + checked, + onChange, }: RadioOptionProps) { - return ( - - ); + return ( + + ); } interface IgnoreRuleProps { - pattern: string; - onRemove: () => void; + pattern: string; + onRemove: () => void; } function IgnoreRule({ pattern, onRemove }: IgnoreRuleProps) { - return ( -
- - {pattern} - - -
- ); + return ( +
+ + {pattern} + + +
+ ); } interface JobConfigRowProps { - label: string; - description: string; - enabled: boolean; - onToggle: (enabled: boolean) => void; - onTrigger: () => void; - isTriggering: boolean; - icon?: React.ComponentType; + label: string; + description: string; + enabled: boolean; + onToggle: (enabled: boolean) => void; + onTrigger: () => void; + isTriggering: boolean; + icon?: React.ComponentType; } function JobConfigRow({ - label, - description, - enabled, - onToggle, - onTrigger, - isTriggering, - icon: Icon, + label, + description, + enabled, + onToggle, + onTrigger, + isTriggering, + icon: Icon, }: JobConfigRowProps) { - return ( -
- - -
- ); + return ( +
+ + +
+ ); } diff --git a/packages/interface/src/routes/overview/StorageOverview.tsx b/packages/interface/src/routes/overview/StorageOverview.tsx index 5fcda99e4..9dd3dd610 100644 --- a/packages/interface/src/routes/overview/StorageOverview.tsx +++ b/packages/interface/src/routes/overview/StorageOverview.tsx @@ -1,5 +1,5 @@ import { motion } from "framer-motion"; -import { HardDrive } from "@phosphor-icons/react"; +import { HardDrive, Plus } from "@phosphor-icons/react"; import DriveIcon from "@sd/assets/icons/Drive.png"; import HDDIcon from "@sd/assets/icons/HDD.png"; import ServerIcon from "@sd/assets/icons/Server.png"; @@ -7,7 +7,7 @@ import DatabaseIcon from "@sd/assets/icons/Database.png"; import DriveAmazonS3Icon from "@sd/assets/icons/Drive-AmazonS3.png"; import DriveGoogleDriveIcon from "@sd/assets/icons/Drive-GoogleDrive.png"; import DriveDropboxIcon from "@sd/assets/icons/Drive-Dropbox.png"; -import { useNormalizedCache } from "../../context"; +import { useNormalizedCache, useLibraryMutation } from "../../context"; import type { VolumeListOutput, VolumeListQueryInput, @@ -65,7 +65,6 @@ export function StorageOverview() { wireMethod: "query:volumes.list", input: { filter: "All" }, resourceType: "volume", - isGlobalList: true, }); // Fetch all devices using normalized cache @@ -76,7 +75,6 @@ export function StorageOverview() { wireMethod: "query:devices.list", input: { include_offline: true, include_details: false }, resourceType: "device", - isGlobalList: true, }); if (volumesLoading || devicesLoading) { @@ -189,6 +187,18 @@ function getDummyVolumeStats(volumeName: string) { } function VolumeBar({ volume, index }: VolumeBarProps) { + const trackVolume = useLibraryMutation("volumes.track"); + + const handleTrack = async () => { + try { + await trackVolume.mutateAsync({ + fingerprint: volume.fingerprint, + }); + } catch (error) { + console.error("Failed to track volume:", error); + } + }; + // Use real data from backend, fallback to dummy data if not available const useDummyData = !volume.total_capacity; const dummy = useDummyData ? getDummyVolumeStats(volume.name) : null; @@ -244,9 +254,15 @@ function VolumeBar({ volume, index }: VolumeBarProps) { )} {!volume.is_tracked && ( - - Untracked - + )} {useDummyData && ( diff --git a/packages/ts-client/src/hooks/useNormalizedCache.ts b/packages/ts-client/src/hooks/useNormalizedCache.ts index f61123db3..263a4ccf2 100644 --- a/packages/ts-client/src/hooks/useNormalizedCache.ts +++ b/packages/ts-client/src/hooks/useNormalizedCache.ts @@ -11,55 +11,61 @@ import { useSpacedriveClient } from "./useClient"; * @param noMergeFields - Fields to replace (from Identifiable.no_merge_fields) */ function deepMerge( - existing: any, - incoming: any, - noMergeFields: string[] = [], + existing: any, + incoming: any, + noMergeFields: string[] = [], ): any { - // If incoming is null/undefined, keep existing - if (incoming === null || incoming === undefined) { - return existing !== null && existing !== undefined ? existing : incoming; - } + // If incoming is null/undefined, keep existing + if (incoming === null || incoming === undefined) { + return existing !== null && existing !== undefined + ? existing + : incoming; + } - // If types don't match or not objects, incoming wins - if ( - typeof existing !== "object" || - typeof incoming !== "object" || - Array.isArray(existing) || - Array.isArray(incoming) - ) { - return incoming; - } + // If types don't match or not objects, incoming wins + if ( + typeof existing !== "object" || + typeof incoming !== "object" || + Array.isArray(existing) || + Array.isArray(incoming) + ) { + return incoming; + } - // Both are objects - deep merge - const merged: any = { ...incoming }; + // Both are objects - deep merge + const merged: any = { ...incoming }; - for (const key in existing) { - // Check if this field should not be merged (from backend Identifiable trait) - if (noMergeFields.includes(key)) { - continue; // Use incoming value as-is - } + for (const key in existing) { + // Check if this field should not be merged (from backend Identifiable trait) + if (noMergeFields.includes(key)) { + continue; // Use incoming value as-is + } - if (!(key in incoming)) { - // Key exists in old but not new - preserve it - merged[key] = existing[key]; - } else if (incoming[key] === null || incoming[key] === undefined) { - // Key exists in both but new is null - preserve old - if (existing[key] !== null && existing[key] !== undefined) { - merged[key] = existing[key]; - } - } else if ( - typeof existing[key] === "object" && - typeof incoming[key] === "object" && - !Array.isArray(existing[key]) && - !Array.isArray(incoming[key]) - ) { - // Both are objects - recurse - merged[key] = deepMerge(existing[key], incoming[key], noMergeFields); - } - // else: incoming wins (has non-null value) - } + if (!(key in incoming)) { + // Key exists in old but not new - preserve it + merged[key] = existing[key]; + } else if (incoming[key] === null || incoming[key] === undefined) { + // Key exists in both but new is null - preserve old + if (existing[key] !== null && existing[key] !== undefined) { + merged[key] = existing[key]; + } + } else if ( + typeof existing[key] === "object" && + typeof incoming[key] === "object" && + !Array.isArray(existing[key]) && + !Array.isArray(incoming[key]) + ) { + // Both are objects - recurse + merged[key] = deepMerge( + existing[key], + incoming[key], + noMergeFields, + ); + } + // else: incoming wins (has non-null value) + } - return merged; + return merged; } /** @@ -67,49 +73,51 @@ function deepMerge( * Uses metadata from Identifiable trait for matching */ function resourceMatches( - existing: any, - incoming: any, - alternateIds: string[] = [], + existing: any, + incoming: any, + alternateIds: string[] = [], ): boolean { - // Match by primary ID - if (existing.id === incoming.id) { - return true; - } + // Match by primary ID + if (existing.id === incoming.id) { + return true; + } - // Match by any alternate ID (e.g., content UUID for Files) - for (const altId of alternateIds) { - if (existing.id === altId || incoming.id === altId) { - return true; - } - } + // Match by any alternate ID (e.g., content UUID for Files) + for (const altId of alternateIds) { + if (existing.id === altId || incoming.id === altId) { + return true; + } + } - return false; + return false; } interface UseNormalizedCacheOptions { - /** Wire method to call (e.g., "query:locations.list") */ - wireMethod: string; - /** Input for the query */ - input: I; - /** Resource type for cache indexing (e.g., "location") */ - resourceType: string; - /** Whether the query is enabled (default: true) */ - enabled?: boolean; - /** Whether this is a global list query that should accept new items (default: false) */ - isGlobalList?: boolean; - /** Optional filter function to check if a resource belongs in this query */ - resourceFilter?: (resource: any) => boolean; - /** Resource ID for single-resource queries (filters events to matching ID only) */ - resourceId?: string; - /** - * Optional path scope for filtering events to a specific directory/path. - * When provided, the backend includes affected_paths in event metadata for efficient filtering. - * - * Note: Full server-side filtering is available via EventFilter.path_scope in the daemon, - * but current client architecture uses a single global subscription. Future enhancement - * could create separate filtered subscriptions per hook. - */ - pathScope?: import("../types").SdPath; + /** Wire method to call (e.g., "query:locations.list") */ + wireMethod: string; + /** Input for the query */ + input: I; + /** Resource type for cache indexing (e.g., "location") */ + resourceType: string; + /** Whether the query is enabled (default: true) */ + enabled?: boolean; + /** + * Optional filter function to check if a resource belongs in this query. + * If not provided, all resources that pass the pathScope filter will be added (global list behavior). + * Use this for additional filtering beyond path scope (e.g., file type, tags, etc.) + */ + resourceFilter?: (resource: any) => boolean; + /** Resource ID for single-resource queries (filters events to matching ID only) */ + resourceId?: string; + /** + * Optional path scope for filtering events to a specific directory/path. + * When provided, the backend includes affected_paths in event metadata for efficient filtering. + * + * Note: Full server-side filtering is available via EventFilter.path_scope in the daemon, + * but current client architecture uses a single global subscription. Future enhancement + * could create separate filtered subscriptions per hook. + */ + pathScope?: import("../types").SdPath; } /** @@ -141,48 +149,52 @@ interface UseNormalizedCacheOptions { * ``` */ export function useNormalizedCache({ - wireMethod, - input, - resourceType, - enabled = true, - isGlobalList = false, - resourceFilter, - resourceId, - pathScope, + wireMethod, + input, + resourceType, + enabled = true, + resourceFilter, + resourceId, + pathScope, }: UseNormalizedCacheOptions) { - const client = useSpacedriveClient(); - const queryClient = useQueryClient(); + const client = useSpacedriveClient(); + const queryClient = useQueryClient(); - // Track library ID reactively so queryKey updates when it changes - const [libraryId, setLibraryId] = useState(client.getCurrentLibraryId()); + // Track library ID reactively so queryKey updates when it changes + const [libraryId, setLibraryId] = useState( + client.getCurrentLibraryId(), + ); - // Listen for library ID changes and update our state (causes re-render) - useEffect(() => { - const handleLibraryChange = (newLibraryId: string) => { - setLibraryId(newLibraryId); - }; + // Listen for library ID changes and update our state (causes re-render) + useEffect(() => { + const handleLibraryChange = (newLibraryId: string) => { + setLibraryId(newLibraryId); + }; - client.on("library-changed", handleLibraryChange); - return () => { - client.off("library-changed", handleLibraryChange); - }; - }, [client, wireMethod]); + client.on("library-changed", handleLibraryChange); + return () => { + client.off("library-changed", handleLibraryChange); + }; + }, [client, wireMethod]); - // Include library ID in key so switching libraries triggers refetch - // useMemo to prevent array recreation on every render - const queryKey = useMemo(() => [wireMethod, libraryId, input], [wireMethod, libraryId, JSON.stringify(input)]); + // Include library ID in key so switching libraries triggers refetch + // useMemo to prevent array recreation on every render + const queryKey = useMemo( + () => [wireMethod, libraryId, input], + [wireMethod, libraryId, JSON.stringify(input)], + ); - // Use TanStack Query normally - // When libraryId changes, queryKey changes, and TanStack Query automatically fetches new data - const query = useQuery({ - queryKey, - queryFn: async () => { - // Client.execute() automatically adds library_id to the request - // as a sibling field to payload - return await client.execute(wireMethod, input); - }, - enabled: enabled && !!libraryId, - }); + // Use TanStack Query normally + // When libraryId changes, queryKey changes, and TanStack Query automatically fetches new data + const query = useQuery({ + queryKey, + queryFn: async () => { + // Client.execute() automatically adds library_id to the request + // as a sibling field to payload + return await client.execute(wireMethod, input); + }, + enabled: enabled && !!libraryId, + }); // Listen for ResourceChanged events and update cache atomically useEffect(() => { @@ -195,16 +207,98 @@ export function useNormalizedCache({ // Check if any affected path matches our pathScope return affectedPaths.some((affectedPath: any) => { - // For now, do a simple JSON comparison - // In the future, could use more sophisticated path matching - return JSON.stringify(affectedPath) === JSON.stringify(pathScope); + // Handle Physical paths with hierarchy + if ("Physical" in pathScope && "Physical" in affectedPath) { + // Handle both device_id (manual types) and device_slug (generated types) + const scopeDevice = + (pathScope.Physical as any).device_slug || + (pathScope.Physical as any).device_id; + const scopePath = (pathScope.Physical as any).path; + const fileDevice = + (affectedPath.Physical as any).device_slug || + (affectedPath.Physical as any).device_id; + const filePath = (affectedPath.Physical as any).path; + + // Must be same device AND file must be under scope directory + return ( + scopeDevice === fileDevice && + filePath.startsWith(scopePath) + ); + } + + // Handle Content ID paths + if ("Content" in pathScope && "Content" in affectedPath) { + const scope = pathScope as { + Content: { content_id: string }; + }; + const affected = affectedPath as { + Content: { content_id: string }; + }; + return ( + scope.Content.content_id === affected.Content.content_id + ); + } + + // Handle Sidecar paths (match by content ID) + if ("Content" in pathScope && "Sidecar" in affectedPath) { + const scope = pathScope as { + Content: { content_id: string }; + }; + const affected = affectedPath as { + Sidecar: { content_id: string }; + }; + return ( + scope.Content.content_id === affected.Sidecar.content_id + ); + } + if ("Sidecar" in pathScope && "Content" in affectedPath) { + const scope = pathScope as { + Sidecar: { content_id: string }; + }; + const affected = affectedPath as { + Content: { content_id: string }; + }; + return ( + scope.Sidecar.content_id === affected.Content.content_id + ); + } + + // Handle Cloud paths + if ("Cloud" in pathScope && "Cloud" in affectedPath) { + const scope = pathScope as { + Cloud: { + service: string; + identifier: string; + path: string; + }; + }; + const affected = affectedPath as { + Cloud: { + service: string; + identifier: string; + path: string; + }; + }; + return ( + scope.Cloud.service === affected.Cloud.service && + scope.Cloud.identifier === affected.Cloud.identifier && + affected.Cloud.path.startsWith(scope.Cloud.path) + ); + } + + // Fallback to exact match for unknown types + return ( + JSON.stringify(affectedPath) === JSON.stringify(pathScope) + ); }); }; const handleEvent = (event: any) => { // Handle Refresh event - invalidate all queries if ("Refresh" in event) { - console.log("[useNormalizedCache] Refresh event received, invalidating all queries"); + console.log( + "[useNormalizedCache] Refresh event received, invalidating all queries", + ); queryClient.invalidateQueries(); return; } @@ -214,450 +308,536 @@ export function useNormalizedCache({ return; } - // Check if this is a ResourceChanged event for our resource type - if ("ResourceChanged" in event) { - const { resource_type, resource, metadata } = event.ResourceChanged; + // Check if this is a ResourceChanged event for our resource type + if ("ResourceChanged" in event) { + const { resource_type, resource, metadata } = + event.ResourceChanged; - const noMergeFields = metadata?.no_merge_fields || []; + const noMergeFields = metadata?.no_merge_fields || []; - if (resource_type === resourceType && eventAffectsPath(metadata)) { - // Atomic update: merge this resource into the query data - queryClient.setQueryData(queryKey, (oldData) => { - if (!oldData) { - return oldData; - } + // Log all events that match our resource type + if (resource_type === resourceType) + console.log( + "targeted ResourceChanged event", + resource_type, + resourceType, + event, + ); - // Handle both array responses and wrapped responses - // e.g., LocationsListOutput = { locations: LocationInfo[] } - if (Array.isArray(oldData)) { - // Direct array response - const resourceId = resource.id; - const existingIndex = oldData.findIndex( - (item: any) => item.id === resourceId, - ); + if ( + resource_type === resourceType && + eventAffectsPath(metadata) + ) { + console.log("ResourceChanged event affects path", metadata); + // Atomic update: merge this resource into the query data + queryClient.setQueryData(queryKey, (oldData) => { + if (!oldData) { + return oldData; + } - if (existingIndex >= 0) { - const newData = [...oldData]; - newData[existingIndex] = deepMerge( - oldData[existingIndex], - resource, - noMergeFields, - ); - return newData as O; - } + // Handle both array responses and wrapped responses + // e.g., LocationsListOutput = { locations: LocationInfo[] } + if (Array.isArray(oldData)) { + // Direct array response + const resourceId = resource.id; + const existingIndex = oldData.findIndex( + (item: any) => item.id === resourceId, + ); - // Append if this is a global list OR resource passes filter - if ( - isGlobalList || - (resourceFilter && resourceFilter(resource)) - ) { - console.log( - "[Cache] Appending new item to array (isGlobalList:", - isGlobalList, - ")", - ); - return [...oldData, resource] as O; - } + if (existingIndex >= 0) { + const newData = [...oldData]; + newData[existingIndex] = deepMerge( + oldData[existingIndex], + resource, + noMergeFields, + ); + return newData as O; + } - console.log("[Cache] ️ Skipping - not in list scope"); + // Append if no filter OR resource passes filter + if (!resourceFilter || resourceFilter(resource)) { + console.log( + "[Cache] Appending new item to array", + ); + return [...oldData, resource] as O; + } - return oldData; - } else if (oldData && typeof oldData === "object") { - // Wrapped response - look for array field - // Try common wrapper field names - const arrayField = Object.keys(oldData).find((key) => - Array.isArray((oldData as any)[key]), - ); + console.log( + "[Cache] Skipping - filtered out by resourceFilter", + ); - if (arrayField) { - const array = (oldData as any)[arrayField]; - const resourceId = resource.id; - const existingIndex = array.findIndex( - (item: any) => item.id === resourceId, - ); + return oldData; + } else if (oldData && typeof oldData === "object") { + // Wrapped response - look for array field + // Try common wrapper field names + const arrayField = Object.keys(oldData).find( + (key) => Array.isArray((oldData as any)[key]), + ); - if (existingIndex >= 0) { - const newArray = [...array]; - newArray[existingIndex] = deepMerge( - array[existingIndex], - resource, - noMergeFields, - ); - console.log(`[${resource_type}] Updated existing item in wrapped array`, { wireMethod, field: arrayField, id: resource.id }); - return { ...oldData, [arrayField]: newArray }; - } + if (arrayField) { + const array = (oldData as any)[arrayField]; + const resourceId = resource.id; + const existingIndex = array.findIndex( + (item: any) => item.id === resourceId, + ); - // Append if this is a global list OR resource passes filter - if ( - isGlobalList || - (resourceFilter && resourceFilter(resource)) - ) { - console.log(`[${resource_type}] Appended to wrapped array`, { wireMethod, field: arrayField, id: resource.id }); - return { ...oldData, [arrayField]: [...array, resource] }; - } + if (existingIndex >= 0) { + const newArray = [...array]; + newArray[existingIndex] = deepMerge( + array[existingIndex], + resource, + noMergeFields, + ); + console.log( + `[${resource_type}] Updated existing item in wrapped array`, + { + wireMethod, + field: arrayField, + id: resource.id, + }, + ); + return { + ...oldData, + [arrayField]: newArray, + }; + } - return oldData; - } + // Append if no filter OR resource passes filter + if ( + !resourceFilter || + resourceFilter(resource) + ) { + console.log( + `[${resource_type}] Appended to wrapped array`, + { + wireMethod, + field: arrayField, + id: resource.id, + }, + ); + return { + ...oldData, + [arrayField]: [...array, resource], + }; + } - // Check for wrapped single-object field (e.g., { layout: SpaceLayout }) - for (const key of Object.keys(oldData)) { - const wrappedValue = (oldData as any)[key]; - if ( - wrappedValue && - typeof wrappedValue === "object" && - !Array.isArray(wrappedValue) && - wrappedValue.id === resource.id - ) { - console.log(`[${resource_type}] Updated wrapped object`, { wireMethod, field: key, id: resource.id }); - return { - ...oldData, - [key]: deepMerge(wrappedValue, resource, noMergeFields), - } as O; - } - } + return oldData; + } - // Handle single object response (e.g., files.by_id returns a single File) - // Check if oldData is a single resource object - if ((oldData as any).id === resource.id) { - // This is the file we're displaying - merge the update - // console.log('[Cache] Updating single resource:', { - // oldId: (oldData as any).id, - // newId: resource.id, - // name: resource.name, - // }); - return deepMerge(oldData, resource, noMergeFields) as O; - } + // Check for wrapped single-object field (e.g., { layout: SpaceLayout }) + for (const key of Object.keys(oldData)) { + const wrappedValue = (oldData as any)[key]; + if ( + wrappedValue && + typeof wrappedValue === "object" && + !Array.isArray(wrappedValue) && + wrappedValue.id === resource.id + ) { + console.log( + `[${resource_type}] Updated wrapped object`, + { + wireMethod, + field: key, + id: resource.id, + }, + ); + return { + ...oldData, + [key]: deepMerge( + wrappedValue, + resource, + noMergeFields, + ), + } as O; + } + } - // Also check by content UUID for single object - if ( - (oldData as any).content_identity?.uuid && - (oldData as any).content_identity.uuid === - resource.content_identity?.uuid - ) { - // console.log('[Cache] Updating single resource by content UUID:', { - // contentId: resource.content_identity.uuid, - // name: resource.name, - // }); - return deepMerge(oldData, resource, noMergeFields) as O; - } - } + // Handle single object response (e.g., files.by_id returns a single File) + // Check if oldData is a single resource object + if ((oldData as any).id === resource.id) { + // This is the file we're displaying - merge the update + // console.log('[Cache] Updating single resource:', { + // oldId: (oldData as any).id, + // newId: resource.id, + // name: resource.name, + // }); + return deepMerge( + oldData, + resource, + noMergeFields, + ) as O; + } - return oldData; - }); - } - } else if ("ResourceChangedBatch" in event) { - const { resource_type, resources, metadata } = - event.ResourceChangedBatch; + // Also check by content UUID for single object + if ( + (oldData as any).content_identity?.uuid && + (oldData as any).content_identity.uuid === + resource.content_identity?.uuid + ) { + // console.log('[Cache] Updating single resource by content UUID:', { + // contentId: resource.content_identity.uuid, + // name: resource.name, + // }); + return deepMerge( + oldData, + resource, + noMergeFields, + ) as O; + } + } - // console.log("[ResourceEvent] ResourceChangedBatch:", { - // resourceType: resource_type, - // ourType: resourceType, - // wireMethod, - // isArray: Array.isArray(resources), - // count: Array.isArray(resources) ? resources.length : "not array", - // firstItem: Array.isArray(resources) ? resources[0] : resources, - // }); + return oldData; + }); + } + } else if ("ResourceChangedBatch" in event) { + const { resource_type, resources, metadata } = + event.ResourceChangedBatch; - if (resource_type === resourceType && Array.isArray(resources) && eventAffectsPath(metadata)) { - // Filter to matching resourceId if specified (for single-resource queries) - const filteredResources = resourceId - ? resources.filter((r: any) => r.id === resourceId) - : resources; + // Log all batch events that match our resource type + if (resource_type === resourceType) { + console.log( + "targeted ResourceChangedBatch event", + resource_type, + resourceType, + "passes path filter:", + eventAffectsPath(metadata), + metadata, + ); + } - if (filteredResources.length === 0) { - return; // No matching resources for this query - } - // Extract merge config from Identifiable metadata - const noMergeFields = metadata?.no_merge_fields || []; - const alternateIds = metadata?.alternate_ids || []; + if ( + resource_type === resourceType && + Array.isArray(resources) && + eventAffectsPath(metadata) + ) { + // Filter to matching resourceId if specified (for single-resource queries) + const filteredResources = resourceId + ? resources.filter((r: any) => r.id === resourceId) + : resources; - // Atomic update: merge filtered resources into the query data - queryClient.setQueryData(queryKey, (oldData) => { - if (!oldData) return oldData; + if (filteredResources.length === 0) { + return; // No matching resources for this query + } + // Extract merge config from Identifiable metadata + const noMergeFields = metadata?.no_merge_fields || []; + const alternateIds = metadata?.alternate_ids || []; - // Helper: check if resource matches by ID or alternate IDs - const matches = (existing: any, incoming: any) => { - if (existing.id === incoming.id) return true; - // Check alternate IDs (e.g., content UUID for Files) - return alternateIds.some( - (altId) => - existing.id === altId || - existing.content_identity?.uuid === altId || - incoming.id === altId || - incoming.content_identity?.uuid === altId, - ); - }; + // Atomic update: merge filtered resources into the query data + queryClient.setQueryData(queryKey, (oldData) => { + if (!oldData) return oldData; - // Create a map of filtered incoming resources - const resourceMap = new Map(filteredResources.map((r: any) => [r.id, r])); + // Helper: check if resource matches by ID or alternate IDs + const matches = (existing: any, incoming: any) => { + if (existing.id === incoming.id) return true; + // Check alternate IDs (e.g., content UUID for Files) + return alternateIds.some( + (altId: any) => + existing.id === altId || + existing.content_identity?.uuid === altId || + incoming.id === altId || + incoming.content_identity?.uuid === altId, + ); + }; - if (Array.isArray(oldData)) { - // Direct array response - const newData = [...oldData]; - const seenIds = new Set(); + // Create a map of filtered incoming resources + const resourceMap = new Map( + filteredResources.map((r: any) => [r.id, r]), + ); - // Update existing items with deep merge - for (let i = 0; i < newData.length; i++) { - const item: any = newData[i]; - if (resourceMap.has(item.id)) { - const incomingResource = resourceMap.get(item.id); - newData[i] = deepMerge(item, incomingResource); - seenIds.add(item.id); - } - } + if (Array.isArray(oldData)) { + // Direct array response + const newData = [...oldData]; + const seenIds = new Set(); - // Append new items if: - // - This is a global list query, OR - // - The resource passes the filter (belongs in this query scope) - if (isGlobalList) { - for (const resource of resources) { - if (!seenIds.has(resource.id)) { - newData.push(resource); - } - } - } else if (resourceFilter) { - for (const resource of resources) { - if (seenIds.has(resource.id)) { - continue; // Already updated by ID - } + // Update existing items with deep merge + for (let i = 0; i < newData.length; i++) { + const item: any = newData[i]; + if (resourceMap.has(item.id)) { + const incomingResource = resourceMap.get( + item.id, + ); + newData[i] = deepMerge( + item, + incomingResource, + ); + seenIds.add(item.id); + } + } - // Check if we should process this resource - const shouldAppend = resourceFilter(resource); - if (!shouldAppend) { - continue; - } + // Append new items if no filter OR resource passes filter + for (const resource of resources) { + if (seenIds.has(resource.id)) { + continue; // Already updated by ID + } - // For Content-based paths with multiple entries, update by content UUID - // (sidecar events can create multiple File resources for the same content) - if ( - resource.sd_path?.Content && - resource.content_identity?.uuid - ) { - const contentId = resource.content_identity.uuid; + // Check if we should process this resource + if ( + resourceFilter && + !resourceFilter(resource) + ) { + continue; // Filtered out + } - // Find existing item with same content - const existingIndex = newData.findIndex( - (item: any) => item.content_identity?.uuid === contentId, - ); + // For Content-based paths with multiple entries, update by content UUID + // (sidecar events can create multiple File resources for the same content) + if ( + resource.sd_path?.Content && + resource.content_identity?.uuid + ) { + const contentId = + resource.content_identity.uuid; - if (existingIndex >= 0) { - // Update existing item (merge sidecars, etc.) - newData[existingIndex] = deepMerge( - newData[existingIndex], - resource, - noMergeFields, - ); - console.log( - "[Cache] Updated existing file by content UUID:", - { - name: resource.name, - contentId, - }, - ); - continue; - } - } + // Find existing item with same content + const existingIndex = newData.findIndex( + (item: any) => + item.content_identity?.uuid === + contentId, + ); - // New item - append it - newData.push(resource); - } - } + if (existingIndex >= 0) { + // Update existing item (merge sidecars, etc.) + newData[existingIndex] = deepMerge( + newData[existingIndex], + resource, + noMergeFields, + ); + console.log( + "[Cache] Updated existing file by content UUID:", + { + name: resource.name, + contentId, + }, + ); + continue; + } + } - return newData as O; - } else if (oldData && typeof oldData === "object") { - // Check if this is a single resource object vs wrapper - // Single resource has: id field - // Wrapper has: array field (files, locations, etc.) + pagination fields - const isSingleResource = !!(oldData as any).id; + // New item - append it + newData.push(resource); + } - // console.log("[Cache] Batch - response type check:", { - // isSingleResource, - // hasId: !!(oldData as any).id, - // hasSdPath: !!(oldData as any).sd_path, - // firstKey: Object.keys(oldData)[0], - // }); + return newData as O; + } else if (oldData && typeof oldData === "object") { + // Check if this is a single resource object vs wrapper + // Single resource has: id field + // Wrapper has: array field (files, locations, etc.) + pagination fields + const isSingleResource = !!(oldData as any).id; - if (isSingleResource) { - // For File resources with sd_path, validate path matches (prevent cross-path pollution) - const oldPath = (oldData as any).sd_path; + // console.log("[Cache] Batch - response type check:", { + // isSingleResource, + // hasId: !!(oldData as any).id, + // hasSdPath: !!(oldData as any).sd_path, + // firstKey: Object.keys(oldData)[0], + // }); - if (oldPath) { - // This is a File with a path - filter to matching path only - const filteredByPath = filteredResources.filter((resource: any) => { - if (!resource.sd_path) return false; + if (isSingleResource) { + // For File resources with sd_path, validate path matches (prevent cross-path pollution) + const oldPath = (oldData as any).sd_path; - // Deep compare sd_path objects - return JSON.stringify(oldPath) === JSON.stringify(resource.sd_path); - }); + if (oldPath) { + // This is a File with a path - filter to matching path only + const filteredByPath = + filteredResources.filter( + (resource: any) => { + if (!resource.sd_path) + return false; - if (filteredByPath.length === 0) { - return oldData; // No matching paths, don't update - } + // Deep compare sd_path objects + return ( + JSON.stringify(oldPath) === + JSON.stringify( + resource.sd_path, + ) + ); + }, + ); - // Update to only process path-matching resources - filteredResources.length = 0; - filteredResources.push(...filteredByPath); - resourceMap.clear(); - filteredByPath.forEach(r => resourceMap.set(r.id, r)); - } + if (filteredByPath.length === 0) { + return oldData; // No matching paths, don't update + } - // For non-File resources (SpaceLayout, etc), no path filtering needed - // They're already filtered by resourceId above + // Update to only process path-matching resources + filteredResources.length = 0; + filteredResources.push(...filteredByPath); + resourceMap.clear(); + filteredByPath.forEach((r) => + resourceMap.set(r.id, r), + ); + } - // Single object response - check each incoming resource - for (const resource of filteredResources) { - // Match by ID - if ((oldData as any).id === resource.id) { - console.log("[Cache] ✓ Updating single object by ID:", { - name: resource.name, - id: resource.id, - }); - return deepMerge(oldData, resource, noMergeFields) as O; - } + // For non-File resources (SpaceLayout, etc), no path filtering needed + // They're already filtered by resourceId above - // Match by content UUID - if ( - (oldData as any).content_identity?.uuid && - (oldData as any).content_identity.uuid === - resource.content_identity?.uuid - ) { - console.log( - "[Cache] ✓ Updating single object by content UUID:", - { - name: resource.name, - contentId: resource.content_identity.uuid, - }, - ); - return deepMerge(oldData, resource, noMergeFields) as O; - } - } + // Single object response - check each incoming resource + for (const resource of filteredResources) { + // Match by ID + if ((oldData as any).id === resource.id) { + console.log( + "[Cache] ✓ Updating single object by ID:", + { + name: resource.name, + id: resource.id, + }, + ); + return deepMerge( + oldData, + resource, + noMergeFields, + ) as O; + } - console.log("[Cache] ✗ No match found for single object"); - // No match - return unchanged - return oldData; - } + // Match by content UUID + if ( + (oldData as any).content_identity + ?.uuid && + (oldData as any).content_identity + .uuid === + resource.content_identity?.uuid + ) { + console.log( + "[Cache] ✓ Updating single object by content UUID:", + { + name: resource.name, + contentId: + resource.content_identity + .uuid, + }, + ); + return deepMerge( + oldData, + resource, + noMergeFields, + ) as O; + } + } - // Wrapped response with array field - const arrayField = Object.keys(oldData).find((key) => - Array.isArray((oldData as any)[key]), - ); + console.log( + "[Cache] ✗ No match found for single object", + ); + // No match - return unchanged + return oldData; + } - if (arrayField) { - const array = [...(oldData as any)[arrayField]]; - const seenIds = new Set(); + // Wrapped response with array field + const arrayField = Object.keys(oldData).find( + (key) => Array.isArray((oldData as any)[key]), + ); - // Update existing items with deep merge - for (let i = 0; i < array.length; i++) { - const item: any = array[i]; - if (resourceMap.has(item.id)) { - const incomingResource = resourceMap.get(item.id); - array[i] = deepMerge(item, incomingResource); - seenIds.add(item.id); - } - } + if (arrayField) { + const array = [...(oldData as any)[arrayField]]; + const seenIds = new Set(); - // Append new items if: - // - This is a global list query, OR - // - The resource passes the filter (belongs in this query scope) - if (isGlobalList) { - for (const resource of resources) { - if (!seenIds.has(resource.id)) { - array.push(resource); - } - } - } else if (resourceFilter) { - for (const resource of resources) { - if (seenIds.has(resource.id)) { - continue; // Already updated by ID - } + // Update existing items with deep merge + for (let i = 0; i < array.length; i++) { + const item: any = array[i]; + if (resourceMap.has(item.id)) { + const incomingResource = + resourceMap.get(item.id); + array[i] = deepMerge( + item, + incomingResource, + ); + seenIds.add(item.id); + } + } - const shouldAppend = resourceFilter(resource); - if (!shouldAppend) { - continue; - } + // Append new items if no filter OR resource passes filter + for (const resource of resources) { + if (seenIds.has(resource.id)) { + continue; // Already updated by ID + } - // For Content-based paths, update existing item by content UUID - if ( - resource.sd_path?.Content && - resource.content_identity?.uuid - ) { - const contentId = resource.content_identity.uuid; - const existingIndex = array.findIndex( - (item: any) => - item.content_identity?.uuid === contentId, - ); + // Check if we should process this resource + if ( + resourceFilter && + !resourceFilter(resource) + ) { + continue; // Filtered out + } - if (existingIndex >= 0) { - // Update existing item - array[existingIndex] = deepMerge( - array[existingIndex], - resource, - noMergeFields, - ); - console.log( - "[Cache] Updated existing file by content UUID:", - { - name: resource.name, - contentId, - }, - ); - continue; - } - } + // For Content-based paths, update existing item by content UUID + if ( + resource.sd_path?.Content && + resource.content_identity?.uuid + ) { + const contentId = + resource.content_identity.uuid; + const existingIndex = array.findIndex( + (item: any) => + item.content_identity?.uuid === + contentId, + ); - // New item - append - array.push(resource); - } - } + if (existingIndex >= 0) { + // Update existing item + array[existingIndex] = deepMerge( + array[existingIndex], + resource, + noMergeFields, + ); + console.log( + "[Cache] Updated existing file by content UUID:", + { + name: resource.name, + contentId, + }, + ); + continue; + } + } - return { ...oldData, [arrayField]: array }; - } - } + // New item - append + array.push(resource); + } - return oldData; - }); - } - } else if ("ResourceDeleted" in event) { - const { resource_type, resource_id } = event.ResourceDeleted; + return { ...oldData, [arrayField]: array }; + } + } - if (resource_type === resourceType) { - // Atomic update: remove deleted resource - queryClient.setQueryData(queryKey, (oldData) => { - if (!oldData) return oldData; + return oldData; + }); + } + } else if ("ResourceDeleted" in event) { + const { resource_type, resource_id } = event.ResourceDeleted; - if (Array.isArray(oldData)) { - return oldData.filter( - (item: any) => item.id !== resource_id, - ) as O; - } else if (oldData && typeof oldData === "object") { - const arrayField = Object.keys(oldData).find((key) => - Array.isArray((oldData as any)[key]), - ); + if (resource_type === resourceType) { + // Atomic update: remove deleted resource + queryClient.setQueryData(queryKey, (oldData) => { + if (!oldData) return oldData; - if (arrayField) { - const array = (oldData as any)[arrayField]; - return { - ...oldData, - [arrayField]: array.filter( - (item: any) => item.id !== resource_id, - ), - }; - } - } + if (Array.isArray(oldData)) { + return oldData.filter( + (item: any) => item.id !== resource_id, + ) as O; + } else if (oldData && typeof oldData === "object") { + const arrayField = Object.keys(oldData).find( + (key) => Array.isArray((oldData as any)[key]), + ); - return oldData; - }); - } - } - }; + if (arrayField) { + const array = (oldData as any)[arrayField]; + return { + ...oldData, + [arrayField]: array.filter( + (item: any) => item.id !== resource_id, + ), + }; + } + } - // Subscribe to events - const unsubscribe = client.on("spacedrive-event", handleEvent); + return oldData; + }); + } + } + }; - return () => { - client.off("spacedrive-event", handleEvent); - }; - }, [resourceType, queryKey, queryClient, pathScope]); + // Subscribe to events + const unsubscribe = client.on("spacedrive-event", handleEvent); - return query; + return () => { + client.off("spacedrive-event", handleEvent); + }; + }, [resourceType, queryKey, queryClient, pathScope]); + + return query; }