--- title: Normalized Query sidebarTitle: Normalized Query --- # Real-Time Normalized Cache with TanStack Query The `useNormalizedQuery` hook provides instant, event-driven cache updates with server-side filtering. Built with 2025 best practices including runtime validation (Valibot), type-safe merging (ts-deepmerge), and proper subscription cleanup. ## Overview `useNormalizedQuery` wraps TanStack Query to add real-time capabilities: - **Instant updates** across all devices via WebSocket events - **Server-side filtering** reduces network traffic by 90%+ - **Client-side safety** ensures correctness even with unrelated events - **Proper cleanup** prevents connection leaks - **Runtime validation** catches malformed events - **Type-safe merging** preserves data integrity ## Architecture import { FlowDiagram } from '/snippets/FlowDiagram.mdx'; ## Basic Usage ### Directory Listing ```tsx import { useNormalizedQuery } from "@sd/ts-client"; function DirectoryView({ path }: { path: SdPath }) { const { data, isLoading } = useNormalizedQuery({ query: "files.directory_listing", input: { path }, resourceType: "file", pathScope: path, includeDescendants: false, // Only direct children }); if (isLoading) return ; return (
{data?.files?.map((file) => )}
); } ``` **What happens:** 1. Initial query fetches directory listing 2. Hook subscribes to file events for this path (exact mode) 3. When files are created/updated, events arrive instantly 4. Cache updates atomically 5. UI re-renders with new data ### Media View (Recursive) ```tsx function MediaGallery({ path }: { path: SdPath }) { const { data } = useNormalizedQuery({ query: "files.media_listing", input: { path, include_descendants: true }, resourceType: "file", pathScope: path, includeDescendants: true, // All media in subtree }); return ( {data?.files?.map((file) => )} ); } ``` ### Global Resources ```tsx function LocationsList() { const { data } = useNormalizedQuery({ query: "locations.list", input: null, resourceType: "location", // No pathScope - locations are global resources }); return ( ); } ``` ### Single Resource Queries ```tsx function FileInspector({ fileId }: { fileId: string }) { const { data: file } = useNormalizedQuery({ query: "files.by_id", input: { file_id: fileId }, resourceType: "file", resourceId: fileId, // Only events for this file }); return (

{file?.name}

{/* Updates instantly when thumbnails generate */} {file?.sidecars?.map((sidecar) => ( ))}
); } ``` ## API Reference ### Options ```tsx interface UseNormalizedQueryOptions { // Query method to call (e.g., "files.directory_listing") query: string; // Input for the query input: I; // Resource type for event filtering (e.g., "file", "location") resourceType: string; // Whether query is enabled (default: true) enabled?: boolean; // Optional path scope for server-side filtering pathScope?: SdPath; // Whether to include descendants (recursive) or only direct children (exact) // Default: false (exact matching) includeDescendants?: boolean; // Resource ID for single-resource queries resourceId?: string; } ``` ### Path Filtering Modes #### Exact Mode (Default) Only events for files **directly in** the specified directory: ```tsx pathScope: { Physical: { device_slug: 'my-mac', path: '/Photos' } }, includeDescendants: false // or omit (default) ``` **Behavior:** - File in `/Photos/image.jpg` → ✓ Included - File in `/Photos/Vacation/beach.jpg` → ✗ Excluded - Directory `/Photos/Vacation` → ✗ Excluded #### Recursive Mode All events for files **anywhere under** the specified directory: ```tsx pathScope: { Physical: { device_slug: 'my-mac', path: '/Photos' } }, includeDescendants: true ``` **Behavior:** - File in `/Photos/image.jpg` → ✓ Included - File in `/Photos/Vacation/beach.jpg` → ✓ Included - File in `/Photos/Vacation/Cruise/pic.jpg` → ✓ Included ## Server-Side Filtering ### How It Works Each hook creates a filtered subscription on the backend: ```tsx client.subscribeFiltered({ resource_type: "file", // Only file events path_scope: "/Desktop", // Only this path include_descendants: false, // Exact mode library_id: "abc-123", // Current library }); ``` Backend applies filters **before** sending events: 1. ✓ `resource_type` matches? 2. ✓ `library_id` matches? 3. ✓ `path_scope` matches? (with `include_descendants` mode) 4. ✓ `resourceId` matches? (if specified) **Result:** Only matching events are transmitted over the network. ### Filter Logic **Exact Mode:** ``` Event has affected_paths: [ "/Desktop/file.txt", // File path "/Desktop" // Parent directory ] Subscription path_scope: "/Desktop" include_descendants: false Check: Does affected_paths contain "/Desktop" exactly? Result: YES → Forward event ``` **Recursive Mode:** ``` Event has affected_paths: [ "/Desktop/Subfolder/file.txt", "/Desktop/Subfolder" ] Subscription path_scope: "/Desktop" include_descendants: true Check: Does "/Desktop/Subfolder" start with "/Desktop"? Result: YES → Forward event ``` ## Client-Side Safety Filtering Even with server-side filtering, the client applies a safety filter to batch events: ```tsx // Server forwards batch if ANY file matches // Client filters to ONLY files that match Batch has 100 files: - 10 in /Desktop/ (direct children) - 90 in /Desktop/Subfolder/ (subdirectories) Server: Has 1 direct child → forward entire batch Client: Filter batch → keep only 10 direct children Cache: Contains only 10 files ✓ ``` This ensures correctness even if server-side filtering has edge cases. ## Event Types ### ResourceChanged (Single) ```tsx { ResourceChanged: { resource_type: "location", resource: { id: "uuid", name: "Photos", path: "/Users/me/Photos", // ... full resource data }, metadata: { no_merge_fields: ["sd_path"], affected_paths: [], alternate_ids: [] } } } ``` ### ResourceChangedBatch (Multiple) ```tsx { ResourceChangedBatch: { resource_type: "file", resources: [ { id: "1", name: "photo1.jpg", ... }, { id: "2", name: "photo2.jpg", ... } ], metadata: { no_merge_fields: ["sd_path"], affected_paths: [ { Physical: { device_slug: "mac", path: "/Desktop/photo1.jpg" } }, { Physical: { device_slug: "mac", path: "/Desktop" } }, { Content: { content_id: "uuid" } } ], alternate_ids: [] } } } ``` ### ResourceDeleted ```tsx { ResourceDeleted: { resource_type: "location", resource_id: "uuid" } } ``` ### Refresh (Invalidate All) ```tsx "Refresh"; ``` Triggers `queryClient.invalidateQueries()` to refetch all data. ## Deep Merge Behavior Uses `ts-deepmerge` for type-safe, configurable merging: ```tsx // Existing cache { id: "1", name: "Photos", metadata: { size: 1024, created_at: "2024-01-01" } } // Incoming event (partial update) { id: "1", name: "My Photos", metadata: { size: 2048 } } // Result after merge { id: "1", name: "My Photos", // Updated metadata: { size: 2048, // Updated created_at: "2024-01-01" // Preserved ✓ } } ``` ### No-Merge Fields Some fields should be replaced entirely, not merged: ```tsx metadata: { no_merge_fields: ["sd_path"]; } // sd_path is replaced entirely, not deep merged // This prevents incorrect path combinations ``` ## Runtime Validation All events are validated with Valibot before processing: ```tsx const ResourceChangedSchema = v.object({ ResourceChanged: v.object({ resource_type: v.string(), resource: v.any(), metadata: v.nullish(v.object({ ... })) }) }); // Invalid events are logged and ignored // Prevents crashes from malformed backend data ``` ## Subscription Multiplexing Multiple hooks with identical filters automatically share a single backend subscription: ```tsx // Component A function LocationsList() { useNormalizedQuery({ query: 'locations.list', resourceType: 'location', }); } // Component B (mounted at same time) function LocationsDropdown() { useNormalizedQuery({ query: 'locations.list', resourceType: 'location', }); } // Result: Only 1 backend subscription created! // Both hooks receive events from the same connection. ``` **How it works:** 1. First hook creates subscription with filter `{resource_type: "location", library_id: "abc"}` 2. Subscription manager generates key from filter: `{"resource_type":"location","library_id":"abc"}` 3. Second hook with same filter reuses existing subscription 4. Events broadcast to all listeners 5. When both unmount, subscription cleaned up automatically **Benefits:** - Eliminates duplicate subscriptions during render cycles - Reduces backend load (fewer Unix socket connections) - Faster subscription setup (reuses existing connection) - Automatic reference counting prevents premature cleanup ## Subscription Cleanup Subscriptions are properly cleaned up when components unmount: ```tsx useEffect(() => { let unsubscribe: (() => void) | undefined; client.subscribeFiltered(filter, handleEvent).then((unsub) => { unsubscribe = unsub; }); return () => { unsubscribe?.(); // Closes WebSocket subscription }; }, [dependencies]); ``` **Cleanup process:** 1. React calls cleanup function 2. Frontend stops listening to events 3. Tauri sends `Unsubscribe` request to daemon 4. Daemon closes subscription 5. Unix socket connection closed **Result:** No connection leaks, no memory leaks. ## Performance ### Event Reduction ``` Indexing 10,000 files: Without filtering: - Each hook receives: 10,000 events - Total transmitted: 50,000 events (5 hooks × 10,000) - Result: UI lag, slow With filtering: - Desktop hook: 100 events (1%) - Movies hook: 500 events (5%) - Inspector: 1-5 events (0.05%) - Total transmitted: ~600 events - Result: Zero lag ``` ### Connection Management - **Multiplexing:** Multiple hooks with identical filters share one backend subscription - **Reference counting:** Subscriptions cleaned up when last hook unmounts - **Deduplication:** Eliminates duplicate subscriptions during render cycles - **Monitoring:** Check `client.getSubscriptionStats()` for active subscriptions ## Testing ### Test Coverage **Rust (Backend):** - 9/9 event filtering tests passing - Validates exact vs recursive modes - Tests all path types (Physical, Content, Cloud, Sidecar) **TypeScript (Frontend):** - 5/5 integration tests passing - Uses real backend event fixtures - Validates filtering and cache updates - Proves correctness with actual production code ### Run Tests ```bash # Rust tests cargo test --test event_filtering_test # TypeScript tests cd packages/ts-client && bun test # Generate new fixtures from backend cargo test --test normalized_cache_fixtures_test ``` ## Best Practices ### Always Scope File Queries ```tsx // Good const { data } = useNormalizedQuery({ query: "files.directory_listing", input: { path }, resourceType: "file", pathScope: path, // Server filters efficiently }); // Bad - will skip subscription const { data } = useNormalizedQuery({ query: "files.directory_listing", input: { path }, resourceType: "file", // Missing pathScope! Subscription skipped to prevent overload }); ``` ### Use Correct Mode for View Type ```tsx // Directory view - exact mode includeDescendants: false; // Only direct children // Media gallery - recursive mode includeDescendants: true; // All media in subtree // Search results - recursive mode includeDescendants: true; // All matching files ``` ### Combine with TanStack Query Options ```tsx const { data } = useNormalizedQuery({ query: "files.directory_listing", input: { path }, resourceType: "file", pathScope: path, // TanStack Query options enabled: !!path, staleTime: 5 * 60 * 1000, refetchOnWindowFocus: true, }); ``` ## Advanced Usage ### Content-Addressed Files Files use Content-based `sd_path` but have Physical paths in `alternate_paths`: ```tsx // File structure { sd_path: { Content: { content_id: "uuid" } }, alternate_paths: [ { Physical: { device_slug: "mac", path: "/Desktop/file.txt" } } ] } // Client-side filtering uses alternate_paths for path matching // This enables deduplication while maintaining path filtering ``` ### Multiple Instances Multiple files with same content have different IDs: ```tsx // file1.txt (original) { id: "1", content_identity: { uuid: "abc" } } // file2.txt (duplicate) { id: "2", content_identity: { uuid: "abc" } } // Both update when content is processed ``` ## Debugging ### Enable Logging ```tsx // Check console for: // "[useNormalizedQuery] Invalid event: ..." - Validation failures // "[TauriTransport] Unsubscribing: ..." - Cleanup events ``` ### Monitor Subscriptions ```bash # Backend logs show subscription lifecycle RUST_LOG=sd_core::infra::daemon::rpc=debug cargo run -p spacedrive-tauri # Look for: # "New subscription created: ..." - Subscription started # "Subscription cancelled: ..." - Cleanup triggered # "Unsubscribe sent successfully" - Connection closed ``` **Frontend subscription stats:** ```tsx import { useSpacedriveClient } from '@sd/ts-client'; function DebugPanel() { const client = useSpacedriveClient(); const stats = client.getSubscriptionStats(); console.log(`Active subscriptions: ${stats.activeSubscriptions}`); stats.subscriptions.forEach(sub => { console.log(` ${sub.key}: ${sub.refCount} hooks, ${sub.listenerCount} listeners`); }); } ``` ### Inspect Cache ```tsx import { useQueryClient } from "@tanstack/react-query"; const queryClient = useQueryClient(); // View all cached queries console.log(queryClient.getQueryCache().getAll()); // View specific query const queryKey = ["query:files.directory_listing", libraryId, { path }]; console.log(queryClient.getQueryData(queryKey)); ``` ## Migration ### From useLibraryQuery ```tsx // Before (no real-time updates) const { data } = useLibraryQuery({ type: "locations.list", input: {}, }); // After (instant updates) const { data } = useNormalizedQuery({ query: "locations.list", input: null, resourceType: "location", }); ``` ### Backward Compatibility The old `useNormalizedCache` name is aliased: ```tsx // Both work identically import { useNormalizedQuery } from "@sd/ts-client"; import { useNormalizedCache } from "@sd/ts-client"; // Alias // Prefer useNormalizedQuery for new code ``` ## Technical Details ### Exported Functions Core logic is exported for testing: ```tsx import { filterBatchResources, // Filter resources by pathScope updateBatchResources, // Update cache with batch updateSingleResource, // Update single resource deleteResource, // Remove from cache safeMerge, // Deep merge utility handleResourceEvent, // Event dispatcher } from "@sd/ts-client/hooks/useNormalizedQuery"; ``` ### Runtime Dependencies - **ts-deepmerge** - Type-safe deep merging - **valibot** - Runtime event validation - **tiny-invariant** - Assertion helpers - **type-fest** - TypeScript utilities - **@tanstack/react-query** - Core caching ### Subscription Lifecycle ``` 1. Component mounts ↓ 2. useNormalizedQuery creates subscription ↓ 3. Backend creates filtered event stream ↓ 4. Events flow: Backend → Tauri → Frontend → Hook → Cache ↓ 5. Component unmounts ↓ 6. Cleanup function called ↓ 7. Tauri cancels background task ↓ 8. Backend receives Unsubscribe ↓ 9. Unix socket closed ↓ 10. Connection freed ``` ## Common Patterns ### List with Real-Time Updates ```tsx const { data: items } = useNormalizedQuery({ query: "items.list", input: filters, resourceType: "item", }); // Items list updates instantly when: // - New items created // - Existing items modified // - Items deleted ``` ### Directory with Instant File Appearance ```tsx const { data: files } = useNormalizedQuery({ query: "files.directory_listing", input: { path }, resourceType: "file", pathScope: path, }); // New files appear instantly: // - Screenshot taken → appears immediately // - File copied → shows up without refresh // - File renamed → updates in real-time ``` ### Inspector with Sidecar Updates ```tsx const { data: file } = useNormalizedQuery({ query: "files.by_id", input: { file_id }, resourceType: "file", resourceId: file_id, }); // Sidecars update as they're generated: // - Thumbnail generated → appears instantly // - Thumbstrip created → shows immediately // - OCR extracted → updates in real-time ``` ## Summary `useNormalizedQuery` provides production-grade real-time caching: - Server-side filtering (90%+ event reduction) - Client-side safety (validates and filters) - Proper cleanup (no connection leaks) - Runtime validation (catches bad events) - Type-safe merging (preserves data) - Comprehensive tests (9 Rust + 5 TypeScript) - TanStack Query compatible (all features work) - Cross-device sync (instant updates everywhere) Use it for any query where data can change and you want instant updates without manual refetching.