Files
spacedrive/docs/react/normalized-cache.mdx
Jamie Pine ddcefe2495 docs
2025-11-14 21:40:49 -08:00

856 lines
20 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.
# Normalized Cache Pattern
**Status**: Production Ready
**Version**: 1.0
**Use Case**: Real-time UI updates without manual refetching
---
## Overview
The **normalized cache** provides instant UI updates when resources change on any device. Instead of manually invalidating queries or polling for changes, events from the backend automatically update your component's data.
### How It Works
```
Device A (Browser) Device B (CLI/Mobile)
│ │
│ │ User creates tag
│ ├──> Backend: tags.create
│ ├──> DB: Insert
│ ┌──────────────────────┤
│ │ Event: ResourceChanged
│ │ { resource_type: "tag", resource: {...} }
│ │
├────┴──> useNormalizedCache
│ ├─ Receives event
│ ├─ Calls queryClient.setQueryData()
│ └─ Component re-renders
└──> New tag appears instantly!
(No loading state, no network call)
```
---
## Basic Usage
### 1. Import the Hook
```tsx
import { useNormalizedCache } from '@sd/interface/context';
import type { LocationInfo, LocationsListOutput } from '@sd/interface/context';
```
### 2. Use in Your Component
```tsx
function LocationList() {
const locationsQuery = useNormalizedCache<null, LocationsListOutput>({
wireMethod: "query:locations.list",
input: null,
resourceType: "location",
});
const locations = locationsQuery.data?.locations || [];
return (
<div>
{locations.map(location => (
<LocationCard key={location.id} location={location} />
))}
</div>
);
}
```
**That's it!** When locations are created, updated, or deleted on any device, your component updates instantly.
---
## API Reference
### `useNormalizedCache<I, O>(options)`
A TanStack Query wrapper that adds event-driven cache updates.
#### Parameters
```typescript
{
wireMethod: string; // e.g., "query:tags.list"
input: I; // Query input (type-safe!)
resourceType: string; // e.g., "tag" (matches Rust Identifiable::resource_type)
enabled?: boolean; // Default: true
}
```
#### Returns
Standard TanStack Query result:
```typescript
{
data: O | undefined;
isLoading: boolean;
isFetching: boolean;
error: Error | null;
refetch: () => void;
// ... all other useQuery fields
}
```
---
## Examples
### Tags
```tsx
function TagBrowser() {
const tagsQuery = useNormalizedCache<TagsListInput, TagsListOutput>({
wireMethod: "query:tags.list",
input: { search: "" },
resourceType: "tag",
});
const tags = tagsQuery.data?.tags || [];
return (
<div className="flex flex-wrap gap-2">
{tags.map(tag => (
<TagChip
key={tag.id}
name={tag.name}
color={tag.color}
/>
))}
</div>
);
}
```
**When a tag is created:**
- Backend emits `ResourceChanged { resource_type: "tag", resource: { id, name, color } }`
- Hook receives event, matches `resource_type === "tag"`
- Calls `setQueryData()` to merge new tag
- Component re-renders with new tag instantly
### Albums
```tsx
function AlbumGrid() {
const albumsQuery = useNormalizedCache<{}, AlbumsListOutput>({
wireMethod: "query:albums.list",
input: {},
resourceType: "album",
});
const albums = albumsQuery.data?.albums || [];
return (
<div className="grid grid-cols-4 gap-4">
{albums.map(album => (
<AlbumCard key={album.id} album={album} />
))}
</div>
);
}
```
### Files (Future - Virtual Resource)
```tsx
function FileExplorer({ path }: { path: string }) {
const filesQuery = useNormalizedCache<{ path: string }, FilesListOutput>({
wireMethod: "query:files.directory_listing",
input: { path },
resourceType: "file",
});
const files = filesQuery.data?.files || [];
return (
<div>
{files.map(file => (
<FileCard key={file.id} file={file} />
))}
</div>
);
}
```
---
## Implementation Guide
Want to add normalized cache to a new resource? Follow these steps:
### Step 1: Rust - Add Identifiable Trait
```rust
// core/src/domain/your_resource.rs
use crate::domain::resource::Identifiable;
use specta::Type;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct YourResource {
pub id: Uuid,
// ... your fields
}
impl Identifiable for YourResource {
fn id(&self) -> Uuid {
self.id
}
fn resource_type() -> &'static str {
"your_resource" // lowercase, singular
}
}
```
### Step 2: Rust - Emit Events in Operations
**In create/update operations:**
```rust
// core/src/ops/your_resources/create.rs
pub async fn create_your_resource(
events: &EventBus,
// ... params
) -> Result<YourResource> {
// ... create in DB
info!("Emitting ResourceChanged event for your_resource: {:?}", resource);
events.emit(Event::ResourceChanged {
resource_type: "your_resource".to_string(),
resource: serde_json::to_value(&resource).unwrap(),
});
Ok(resource)
}
```
**In delete operations:**
```rust
// core/src/ops/your_resources/delete.rs
pub async fn delete_your_resource(
events: &EventBus,
id: Uuid,
) -> Result<()> {
// ... delete from DB
events.emit(Event::ResourceDeleted {
resource_type: "your_resource".to_string(),
resource_id: id,
});
Ok(())
}
```
### Step 3: TypeScript - Use the Hook
```tsx
import { useNormalizedCache } from '@sd/interface/context';
function YourResourceList() {
const query = useNormalizedCache<YourResourceInput, YourResourceOutput>({
wireMethod: "query:your_resources.list",
input: { /* your input */ },
resourceType: "your_resource", // ← Must match Rust!
});
const items = query.data?.items || [];
return (
<div>
{items.map(item => (
<YourResourceCard key={item.id} item={item} />
))}
</div>
);
}
```
---
## Event Flow Details
### What Happens on Create
```rust
// Backend: Create operation completes
let resource = create_your_resource(...).await?;
// Emit event
events.emit(Event::ResourceChanged {
resource_type: "your_resource".to_string(),
resource: serde_json::to_value(&resource).unwrap(),
});
```
```typescript
// Frontend: Event arrives
client.on("spacedrive-event", (event) => {
if ("ResourceChanged" in event) {
const { resource_type, resource } = event.ResourceChanged;
if (resource_type === "your_resource") {
// Atomic update!
queryClient.setQueryData(queryKey, (oldData) => {
// Merge new resource into existing data
return [...oldData, resource];
});
}
}
});
```
### What Happens on Update
```rust
// Backend: Update operation completes
let updated_resource = update_your_resource(...).await?;
// Emit same event type (not a separate "Updated" variant!)
events.emit(Event::ResourceChanged {
resource_type: "your_resource".to_string(),
resource: serde_json::to_value(&updated_resource).unwrap(),
});
```
```typescript
// Frontend: Event arrives, finds existing resource by ID
queryClient.setQueryData(queryKey, (oldData) => {
const existingIndex = oldData.findIndex(item => item.id === resource.id);
if (existingIndex >= 0) {
// Replace existing
const newData = [...oldData];
newData[existingIndex] = resource;
return newData;
}
// Not found - append (shouldn't happen for updates)
return [...oldData, resource];
});
```
### What Happens on Delete
```rust
// Backend: Delete operation completes
delete_your_resource(...).await?;
// Emit deletion event
events.emit(Event::ResourceDeleted {
resource_type: "your_resource".to_string(),
resource_id: id,
});
```
```typescript
// Frontend: Event arrives
queryClient.setQueryData(queryKey, (oldData) => {
// Remove deleted resource
return oldData.filter(item => item.id !== resource_id);
});
```
---
## Library Scoping
The hook automatically handles library switching:
```typescript
function TagList() {
const tagsQuery = useNormalizedCache({
wireMethod: "query:tags.list",
input: {},
resourceType: "tag",
});
// When user switches libraries:
// 1. client.setCurrentLibrary(newId)
// 2. Query key changes: [..., 'old-lib-id'] → [..., 'new-lib-id']
// 3. TanStack Query automatically refetches
// 4. New library's tags appear
}
```
**Query key structure:**
```typescript
[wireMethod, libraryId, input]
// Example: ["query:tags.list", "uuid-123", {}]
```
**When library changes, the entire key changes → automatic refetch!** ✅
---
## TanStack Query Integration
`useNormalizedCache` is **not a replacement** for TanStack Query - it's a **wrapper** that adds event handling.
### All TanStack Query Features Work
```tsx
const tagsQuery = useNormalizedCache({
wireMethod: "query:tags.list",
input: {},
resourceType: "tag",
});
// Standard TanStack Query API:
tagsQuery.refetch(); // Manual refetch
tagsQuery.isLoading; // Loading state
tagsQuery.isFetching; // Background refetch
tagsQuery.error; // Error state
tagsQuery.dataUpdatedAt; // Last update timestamp
```
### Refetching Behavior Preserved
```tsx
// TanStack Query still refetches based on:
// - staleTime (default: 30s)
// - Window focus
// - Network reconnect
// - Manual refetch()
// Events provide INSTANT updates
// Background refetches provide eventual consistency
```
### When to Invalidate Manually
```tsx
// After bulk operations (e.g., "delete all tags with color red")
// Backend emits BulkOperationCompleted (no individual resources)
const deleteTags = useCoreMutation("tags.delete_bulk");
await deleteTags.mutateAsync({ color: "red" });
// Invalidate manually
queryClient.invalidateQueries({ queryKey: ["query:tags.list"] });
```
---
## Response Format Handling
The hook automatically handles both **array** and **wrapped** responses:
### Direct Array
```typescript
// If query returns: Tag[]
const tags = tagsQuery.data || [];
```
### Wrapped Object
```typescript
// If query returns: { tags: Tag[] }
const tags = tagsQuery.data?.tags || [];
// Hook auto-detects the array field and updates it
```
### Custom Structure
If your response has a unique structure, you may need to handle it manually:
```typescript
// Use regular useLibraryQuery and listen to events yourself
const tagsQuery = useLibraryQuery({ type: "tags.list", input: {} });
useEffect(() => {
const handleEvent = (event) => {
if ("ResourceChanged" in event && event.ResourceChanged.resource_type === "tag") {
// Custom update logic
queryClient.setQueryData(queryKey, (old) => {
return customMerge(old, event.ResourceChanged.resource);
});
}
};
client.on("spacedrive-event", handleEvent);
return () => client.off("spacedrive-event", handleEvent);
}, []);
```
---
## Best Practices
### 1. Match Resource Types Exactly
```rust
// Rust
impl Identifiable for Tag {
fn resource_type() -> &'static str {
"tag" // ← lowercase, singular
}
}
```
```typescript
// TypeScript
useNormalizedCache({
resourceType: "tag", // ← Must match exactly!
})
```
### 2. Emit the Same Type the Query Returns
```rust
// If your query returns TagInfo (minimal type)
use crate::ops::tags::list::output::TagInfo;
let tag_info = TagInfo {
id: tag.id,
name: tag.name,
color: tag.color,
};
events.emit(Event::ResourceChanged {
resource_type: "tag".to_string(),
resource: serde_json::to_value(&tag_info).unwrap(),
});
```
```typescript
// Your hook will receive the same TagInfo type
const tags = tagsQuery.data?.tags || []; // TagInfo[]
```
### 3. Emit on All Mutations
Emit events for:
- Create
- Update (same `ResourceChanged` event!)
- Delete (`ResourceDeleted`)
- Bulk updates (emit for each resource OR use `BulkOperationCompleted`)
### 4. Add Logging During Development
```rust
info!("Emitting ResourceChanged event for {}: {:?}",
YourResource::resource_type(),
resource
);
```
```typescript
console.log("Received ResourceChanged event:", event.ResourceChanged);
```
Remove logs once stable.
---
## Advanced Patterns
### Conditional Event Emission
Only emit events when someone is listening:
```rust
if events.subscriber_count() > 0 {
events.emit(Event::ResourceChanged { ... });
}
```
### Optimistic Updates (Future)
For immediate feedback on mutations:
```typescript
const createTag = useCoreMutation("tags.create");
await createTag.mutateAsync(
{ name: "New Tag", color: "#FF0000" },
{
onMutate: async (variables) => {
// Optimistic update
const tempId = crypto.randomUUID();
queryClient.setQueryData(queryKey, (old) => {
return [...old, { id: tempId, ...variables }];
});
return { tempId };
},
onSuccess: (realTag, variables, context) => {
// Replace temp with real (event will also arrive)
queryClient.setQueryData(queryKey, (old) => {
return old.map(item =>
item.id === context.tempId ? realTag : item
);
});
},
}
);
```
### Virtual Resources
For resources that depend on multiple tables (like `File`):
```rust
// core/src/domain/file.rs
impl Identifiable for File {
fn resource_type() -> &'static str { "file" }
// Declare dependencies
fn sync_dependencies() -> &'static [&'static str] {
&["entry", "sidecar", "content_identity"]
}
}
```
**Transaction Manager** (future) will:
1. Detect when Entry/Sidecar/ContentIdentity changes
2. Check "who depends on this?" → File
3. Rebuild File resource from joined data
4. Emit `ResourceChanged` for File
**Your component doesn't change:**
```typescript
const filesQuery = useNormalizedCache({
wireMethod: "query:files.directory_listing",
input: { path: "/" },
resourceType: "file", // Works the same!
});
```
---
## Debugging
### Check Event Flow
```typescript
// Add temporary logging
const client = useSpacedriveClient();
useEffect(() => {
const handleEvent = (event: any) => {
console.log("All events:", event);
if ("ResourceChanged" in event) {
console.log("ResourceChanged:", event.ResourceChanged);
}
};
client.on("spacedrive-event", handleEvent);
return () => client.off("spacedrive-event", handleEvent);
}, []);
```
### Check TanStack Query Cache
Use **React Query DevTools**:
```tsx
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
<ReactQueryDevtools initialIsOpen={false} />
```
Look for your query by key: `["query:tags.list", "lib-id", {}]`
Check:
- **Data** - Should update when events arrive
- **Last Updated** - Timestamp changes
- **Observers** - Your component is subscribed
---
## Performance
### Memory Usage
Uses **TanStack Query's existing cache** - no separate entity store needed.
**Typical:**
- 10 queries × 100 items each = 1,000 items cached
- ~1MB memory (depends on resource size)
### Event Size
**Small resources:**
- Tag: ~150 bytes JSON
- Location: ~300 bytes JSON
- Album: ~200 bytes JSON
**Large resources:**
- File: ~500-1000 bytes JSON (with metadata)
**Even 100 concurrent updates = ~50KB** (negligible)
### Update Latency
- Event received → Cache updated: **Less than 1ms**
- Cache updated → React re-render: **Less than 16ms** (1 frame)
- **Total: Less than 20ms** from backend to UI
---
## Limitations
### Not All Resources Need This
**Use normalized cache for:**
- Lists that change frequently (locations, tags, files)
- Cross-device scenarios (mobile + desktop)
- Real-time collaboration features
**Don't use for:**
- One-time queries (core.status, jobs.info)
- Paginated/infinite lists (use regular useQuery + manual invalidation)
- Search results (volatile, invalidate manually)
### Edge Cases
**Bulk Operations:**
- Indexing 10,000 files → Don't emit 10,000 events!
- Use `BulkOperationCompleted` + manual invalidation
- Or emit events only for resources currently in view
**Pagination:**
- Normalized cache works per-page
- Cross-page updates may require refetch
- Consider using cursor-based pagination with stable IDs
---
## Migration from Regular Queries
### Before (Manual Invalidation)
```tsx
const tagsQuery = useLibraryQuery({
type: "tags.list",
input: {},
});
const createTag = useCoreMutation("tags.create");
await createTag.mutateAsync(newTag, {
onSuccess: () => {
// Manual invalidation required!
queryClient.invalidateQueries({ queryKey: ["tags.list"] });
},
});
```
### After (Automatic Updates)
```tsx
const tagsQuery = useNormalizedCache({
wireMethod: "query:tags.list",
input: {},
resourceType: "tag",
});
const createTag = useCoreMutation("tags.create");
await createTag.mutateAsync(newTag);
// No onSuccess needed! Event handles it automatically.
```
---
## Troubleshooting
### "No events arriving"
**Check:**
1. Is daemon running with latest code? (`bun run tauri:dev` rebuilds it)
2. Are events being emitted? (check daemon logs for `Emitting...`)
3. Is event subscription active? (check console for `"Event subscription active"`)
4. Is `resource_type` matching exactly? (case-sensitive!)
### "Data not updating"
**Check:**
1. Does `resource.id` exist? (required for merging)
2. Is the response format expected? (array vs wrapped object)
3. Check TanStack Query DevTools - is `setQueryData` being called?
4. Are there multiple query instances with different keys?
### "Library switching doesn't refetch"
**Check:**
1. Is `libraryId` in the query key?
2. Is `client.getCurrentLibraryId()` returning the new ID?
3. Is the query `enabled: !!libraryId`?
---
## Future Enhancements
### Phase 2: Transaction Manager Integration
Automatic event emission from Transaction Manager:
```rust
// Future: One-liner in operations
let tag = tm.commit_with_event(library, tag_model, |saved| Tag::from(saved)).await?;
// ↑ Automatically emits ResourceChanged!
```
### Phase 3: Persistence
Cache persists to IndexedDB (web) or SQLite (Tauri) for offline support:
```typescript
// Future: Offline-first queries
const tagsQuery = useNormalizedCache({
wireMethod: "query:tags.list",
input: {},
resourceType: "tag",
persistToDisk: true, // ← Survives app restart
});
```
### Phase 4: Generic Events Everywhere
All resources use `ResourceChanged` instead of specific variants:
- Remove: `EntryCreated`, `VolumeAdded`, `TagUpdated`, etc. (40+ variants)
- Keep: `ResourceChanged`, `ResourceDeleted` (2 variants)
- **Event enum stays small forever!**
---
## Related
- **Implementation Plan**: `workbench/interface/NORMALIZED_CACHE_IMPLEMENTATION_PLAN.md`
- **Unified Events Design**: `workbench/core/sync/UNIFIED_RESOURCE_EVENTS.md`
- **Cache Design**: `workbench/normalized-cache.md`
- **Interface Rules**: `packages/interface/CLAUDE.md`
---
## Quick Start Checklist
Adding normalized cache to a new resource:
- [ ] Implement `Identifiable` trait in Rust
- [ ] Add `Type` derive to struct
- [ ] Emit `ResourceChanged` in create/update operations
- [ ] Emit `ResourceDeleted` in delete operations
- [ ] Use `useNormalizedCache` hook in React component
- [ ] Match `resourceType` string exactly
- [ ] Test: Create resource, verify instant update
- [ ] Test: Delete resource, verify instant removal
- [ ] Test: Switch libraries, verify refetch
**That's it! The pattern scales to infinite resources.**