mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2025-12-11 20:15:30 +01:00
824 lines
18 KiB
Plaintext
824 lines
18 KiB
Plaintext
---
|
||
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.
|