spacedrive/docs/react/ui/normalized-cache.mdx
2025-11-22 05:12:55 -08:00

824 lines
18 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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';
<FlowDiagram steps={[
{
title: "Device A: Create file",
description: "User creates or modifies a file on their device"
},
{
title: "Backend: Emit event",
description: "Backend detects change and emits events to all connected clients",
metrics: { "During indexing": "10,000 events" }
},
{
title: "Server Filter: Per subscription",
description: "Events are filtered server-side based on subscription criteria",
items: [
"Desktop: 100 events (1%)",
"Movies: 500 events (5%)",
"Inspector: 1-5 events (0.05%)"
]
},
{
title: "Subscription Manager",
description: "Multiplexes identical filters to optimize connections",
items: [
"1 backend sub → N hooks",
"Auto deduplication",
"Reference counting"
]
},
{
title: "Client: Validate & filter",
description: "Final validation and cache updates trigger React re-renders",
items: [
"Valibot validation",
"Client-side filtering",
"Atomic cache updates"
]
}
]} />
## Basic Usage
### Directory Listing
```tsx
import { useNormalizedQuery } from "@sd/ts-client";
function DirectoryView({ path }: { path: SdPath }) {
const { data, isLoading } = useNormalizedQuery({
wireMethod: "query:files.directory_listing",
input: { path },
resourceType: "file",
pathScope: path,
includeDescendants: false, // Only direct children
});
if (isLoading) return <Spinner />;
return (
<div>
{data?.files?.map((file) => <FileCard key={file.id} file={file} />)}
</div>
);
}
```
**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({
wireMethod: "query:files.media_listing",
input: { path, include_descendants: true },
resourceType: "file",
pathScope: path,
includeDescendants: true, // All media in subtree
});
return (
<Grid>
{data?.files?.map((file) => <MediaThumbnail key={file.id} file={file} />)}
</Grid>
);
}
```
### Global Resources
```tsx
function LocationsList() {
const { data } = useNormalizedQuery({
wireMethod: "query:locations.list",
input: null,
resourceType: "location",
// No pathScope - locations are global resources
});
return (
<ul>{data?.locations?.map((loc) => <li key={loc.id}>{loc.name}</li>)}</ul>
);
}
```
### Single Resource Queries
```tsx
function FileInspector({ fileId }: { fileId: string }) {
const { data: file } = useNormalizedQuery({
wireMethod: "query:files.by_id",
input: { file_id: fileId },
resourceType: "file",
resourceId: fileId, // Only events for this file
});
return (
<div>
<h1>{file?.name}</h1>
{/* Updates instantly when thumbnails generate */}
{file?.sidecars?.map((sidecar) => (
<Thumbnail key={sidecar.id} src={sidecar.url} />
))}
</div>
);
}
```
## API Reference
### Options
```tsx
interface UseNormalizedQueryOptions<I> {
// Wire method to call (e.g., "query:files.directory_listing")
wireMethod: 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({
wireMethod: 'query:locations.list',
resourceType: 'location',
});
}
// Component B (mounted at same time)
function LocationsDropdown() {
useNormalizedQuery({
wireMethod: '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({
wireMethod: "query:files.directory_listing",
input: { path },
resourceType: "file",
pathScope: path, // Server filters efficiently
});
// Bad - will skip subscription
const { data } = useNormalizedQuery({
wireMethod: "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({
wireMethod: "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({
wireMethod: "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({
wireMethod: "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({
wireMethod: "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({
wireMethod: "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.