Remove normalised cache (#2390)

* yeetus

* fix

* a

* fix

* todo

* fix library stuff

---------

Co-authored-by: Jamie Pine <32987599+jamiepine@users.noreply.github.com>
This commit is contained in:
Oscar Beaumont 2024-04-30 08:36:39 +08:00 committed by GitHub
parent c76320e19d
commit ce5e285c2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
74 changed files with 278 additions and 1396 deletions

1
.npmrc
View file

@ -8,3 +8,4 @@ node-linker=hoisted
auto-install-peers=true
max-old-space-size=4096
enable-pre-post-scripts=true
package-manager-strict=false

12
Cargo.lock generated
View file

@ -8147,15 +8147,6 @@ dependencies = [
"uuid",
]
[[package]]
name = "sd-cache"
version = "0.0.0"
dependencies = [
"serde",
"serde_json",
"specta",
]
[[package]]
name = "sd-cli"
version = "0.1.0"
@ -8235,7 +8226,6 @@ dependencies = [
"rspc",
"sd-actors",
"sd-ai",
"sd-cache",
"sd-cloud-api",
"sd-core-file-path-helper",
"sd-core-heavy-lifting",
@ -8364,7 +8354,6 @@ name = "sd-core-prisma-helpers"
version = "0.1.0"
dependencies = [
"prisma-client-rust",
"sd-cache",
"sd-prisma",
"serde",
]
@ -8672,7 +8661,6 @@ dependencies = [
"prisma-client-rust",
"rmp-serde",
"rmpv",
"sd-cache",
"sd-sync",
"serde",
"serde_json",

View file

@ -3,7 +3,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
import { listen } from '@tauri-apps/api/event';
import { PropsWithChildren, startTransition, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { CacheProvider, createCache, RspcProvider } from '@sd/client';
import { RspcProvider } from '@sd/client';
import {
createRoutes,
ErrorPage,
@ -56,16 +56,14 @@ export default function App() {
return (
<RspcProvider queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<CacheProvider cache={cache}>
{startupError ? (
<ErrorPage
message={startupError}
submessage="Error occurred starting up the Spacedrive core"
/>
) : (
<AppInner />
)}
</CacheProvider>
{startupError ? (
<ErrorPage
message={startupError}
submessage="Error occurred starting up the Spacedrive core"
/>
) : (
<AppInner />
)}
</QueryClientProvider>
</RspcProvider>
);
@ -74,9 +72,7 @@ export default function App() {
// we have a minimum delay between creating new tabs as react router can't handle creating tabs super fast
const TAB_CREATE_DELAY = 150;
const cache = createCache();
const routes = createRoutes(platform, cache);
const routes = createRoutes(platform);
type redirect = { pathname: string; search: string | undefined };

View file

@ -18,9 +18,7 @@ import { MenuProvider } from 'react-native-popup-menu';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { useSnapshot } from 'valtio';
import {
CacheProvider,
ClientContextProvider,
createCache,
initPlausible,
LibraryContextProvider,
P2PContextProvider,
@ -146,7 +144,6 @@ function AppContainer() {
}
const queryClient = new QueryClient();
const cache = createCache();
export default function App() {
useEffect(() => {
@ -155,9 +152,7 @@ export default function App() {
return (
<RspcProvider queryClient={queryClient}>
<CacheProvider cache={cache}>
<AppContainer />
</CacheProvider>
<AppContainer />
</RspcProvider>
);
}

View file

@ -1,8 +1,8 @@
import { useNavigation } from '@react-navigation/native';
import { useCache, useLibraryQuery, useNodes } from '@sd/client';
import { DotsThreeOutline, Plus } from 'phosphor-react-native';
import { useRef } from 'react';
import { Text, View } from 'react-native';
import { useLibraryQuery } from '@sd/client';
import { ModalRef } from '~/components/layout/Modal';
import { tw } from '~/lib/tailwind';
import { BrowseStackScreenProps } from '~/navigation/tabs/BrowseStack';
@ -22,8 +22,7 @@ const BrowseLocations = () => {
const modalRef = useRef<ModalRef>(null);
const result = useLibraryQuery(['locations.list'], { keepPreviousData: true });
useNodes(result.data?.nodes);
const locations = useCache(result.data?.items);
const locations = result.data;
return (
<View style={tw`gap-5 px-6`}>

View file

@ -1,8 +1,8 @@
import { useNavigation } from '@react-navigation/native';
import { useCache, useLibraryQuery, useNodes } from '@sd/client';
import { DotsThreeOutline, Plus } from 'phosphor-react-native';
import React, { useRef } from 'react';
import { Text, View } from 'react-native';
import { useLibraryQuery } from '@sd/client';
import { ModalRef } from '~/components/layout/Modal';
import { tw } from '~/lib/tailwind';
import { BrowseStackScreenProps } from '~/navigation/tabs/BrowseStack';
@ -16,9 +16,7 @@ const BrowseTags = () => {
const navigation = useNavigation<BrowseStackScreenProps<'Browse'>['navigation']>();
const tags = useLibraryQuery(['tags.list']);
useNodes(tags.data?.nodes);
const tagData = useCache(tags.data?.items);
const tagData = tags.data;
const modalRef = useRef<ModalRef>(null);

View file

@ -11,7 +11,6 @@ import {
useBridgeMutation,
useCachedLibraries,
useMultiZodForm,
useNormalisedCache,
useOnboardingStore,
usePlausibleEvent
} from '@sd/client';
@ -69,12 +68,8 @@ const useFormState = () => {
const submitPlausibleEvent = usePlausibleEvent();
const queryClient = useQueryClient();
const cache = useNormalisedCache();
const createLibrary = useBridgeMutation('library.create', {
onSuccess: (libRaw) => {
cache.withNodes(libRaw.nodes);
const lib = cache.withCache(libRaw.item);
onSuccess: (lib) => {
// We do this instead of invalidating the query because it triggers a full app re-render??
insertLibrary(queryClient, lib);
}
@ -90,15 +85,13 @@ const useFormState = () => {
try {
// show creation screen for a bit for smoothness
const [libraryRaw] = await Promise.all([
const [library] = await Promise.all([
createLibrary.mutateAsync({
name: data.NewLibrary.name,
default_locations: null
}),
new Promise((res) => setTimeout(res, 500))
]);
cache.withNodes(libraryRaw.nodes);
const library = cache.withCache(libraryRaw.item);
if (telemetryState.shareFullTelemetry) {
submitPlausibleEvent({ event: { type: 'libraryCreate' } });

View file

@ -2,15 +2,7 @@ import { DrawerNavigationHelpers } from '@react-navigation/drawer/lib/typescript
import { useNavigation } from '@react-navigation/native';
import { useRef } from 'react';
import { Pressable, Text, View } from 'react-native';
import {
arraysEqual,
byteSize,
Location,
useCache,
useLibraryQuery,
useNodes,
useOnlineLocations
} from '@sd/client';
import { arraysEqual, byteSize, Location, useLibraryQuery, useOnlineLocations } from '@sd/client';
import { ModalRef } from '~/components/layout/Modal';
import { tw, twStyle } from '~/lib/tailwind';
@ -67,8 +59,7 @@ const DrawerLocations = () => {
const modalRef = useRef<ModalRef>(null);
const result = useLibraryQuery(['locations.list'], { keepPreviousData: true });
useNodes(result.data?.nodes);
const locations = useCache(result.data?.items);
const locations = result.data || [];
return (
<>

View file

@ -2,7 +2,7 @@ import { DrawerNavigationHelpers } from '@react-navigation/drawer/lib/typescript
import { useNavigation } from '@react-navigation/native';
import { useRef } from 'react';
import { ColorValue, Pressable, Text, View } from 'react-native';
import { Tag, useCache, useLibraryQuery, useNodes } from '@sd/client';
import { Tag, useLibraryQuery } from '@sd/client';
import { ModalRef } from '~/components/layout/Modal';
import { tw, twStyle } from '~/lib/tailwind';
@ -38,8 +38,7 @@ const DrawerTags = () => {
const tags = useLibraryQuery(['tags.list']);
const navigation = useNavigation<DrawerNavigationHelpers>();
useNodes(tags.data?.nodes);
const tagData = useCache(tags.data?.items);
const tagData = tags.data || [];
const modalRef = useRef<ModalRef>(null);

View file

@ -6,9 +6,7 @@ import {
getItemFilePath,
getItemObject,
isPath,
useCache,
useLibraryQuery,
useNodes
useLibraryQuery
} from '@sd/client';
import { InfoPill, PlaceholderPill } from '~/components/primitive/InfoPill';
import { tw, twStyle } from '~/lib/tailwind';
@ -25,8 +23,7 @@ const InfoTagPills = ({ data, style }: Props) => {
const tagsQuery = useLibraryQuery(['tags.getForObject', objectData?.id ?? -1], {
enabled: objectData != null
});
useNodes(tagsQuery.data?.nodes);
const items = useCache(tagsQuery.data?.items);
const items = tagsQuery.data;
const isDir = data && isPath(data) ? data.item.is_dir : false;

View file

@ -1,12 +1,7 @@
import { useQueryClient } from '@tanstack/react-query';
import { forwardRef, useState } from 'react';
import { Text, View } from 'react-native';
import {
insertLibrary,
useBridgeMutation,
useNormalisedCache,
usePlausibleEvent
} from '@sd/client';
import { insertLibrary, useBridgeMutation, usePlausibleEvent } from '@sd/client';
import { Modal, ModalRef } from '~/components/layout/Modal';
import { Button } from '~/components/primitive/Button';
import { ModalInput } from '~/components/primitive/Input';
@ -18,7 +13,6 @@ const CreateLibraryModal = forwardRef<ModalRef, unknown>((_, ref) => {
const modalRef = useForwardedRef(ref);
const queryClient = useQueryClient();
const cache = useNormalisedCache();
const [libName, setLibName] = useState('');
const submitPlausibleEvent = usePlausibleEvent();
@ -26,10 +20,7 @@ const CreateLibraryModal = forwardRef<ModalRef, unknown>((_, ref) => {
const { mutate: createLibrary, isLoading: createLibLoading } = useBridgeMutation(
'library.create',
{
onSuccess: (libRaw) => {
cache.withNodes(libRaw.nodes);
const lib = cache.withCache(libRaw.item);
onSuccess: (lib) => {
// Reset form
setLibName('');

View file

@ -2,7 +2,7 @@ import { useNavigation } from '@react-navigation/native';
import React, { useRef } from 'react';
import { Pressable, Text, View } from 'react-native';
import { FlatList } from 'react-native-gesture-handler';
import { useCache, useLibraryQuery, useNodes } from '@sd/client';
import { useLibraryQuery } from '@sd/client';
import { tw, twStyle } from '~/lib/tailwind';
import { OverviewStackScreenProps } from '~/navigation/tabs/OverviewStack';
@ -19,63 +19,60 @@ const Locations = () => {
const modalRef = useRef<ModalRef>(null);
const locationsQuery = useLibraryQuery(['locations.list']);
useNodes(locationsQuery.data?.nodes);
const locations = useCache(locationsQuery.data?.items);
const locations = locationsQuery.data;
return (
<>
<OverviewSection title="Locations" count={locations?.length}>
<View style={tw`flex-row items-center`}>
<FlatList
horizontal
data={locations}
contentContainerStyle={tw`px-7`}
showsHorizontalScrollIndicator={false}
keyExtractor={(location) => location.id.toString()}
ItemSeparatorComponent={() => <View style={tw`w-2`} />}
ListEmptyComponent={() => {
return (
<NewCard
style={twStyle(locations?.length !== 0 ? 'ml-2' : 'ml-0')}
icons={['HDD', 'Folder', 'Globe', 'SD']}
text="Connect a local path, volume or network location to Spacedrive."
button={() => (
<Button
style={tw`mt-2.5`}
variant="outline"
onPress={() => {
modalRef.current?.present();
}}
>
<Text style={tw`font-bold text-ink`}>
Add Location
</Text>
</Button>
)}
/>
);
}}
showsVerticalScrollIndicator={false}
renderItem={({ item }) => (
<Pressable
onPress={() =>
navigation.jumpTo('BrowseStack', {
initial: false,
screen: 'Location',
params: { id: item.id }
})
}
>
<StatCard
connectionType={null}
totalSpace={item.size_in_bytes || [0]}
name={item.name || ''}
color="#0362FF"
icon="Folder"
/>
</Pressable>
)}
/>
<FlatList
horizontal
data={locations}
contentContainerStyle={tw`px-7`}
showsHorizontalScrollIndicator={false}
keyExtractor={(location) => location.id.toString()}
ItemSeparatorComponent={() => <View style={tw`w-2`} />}
ListEmptyComponent={() => {
return (
<NewCard
style={twStyle(locations?.length !== 0 ? 'ml-2' : 'ml-0')}
icons={['HDD', 'Folder', 'Globe', 'SD']}
text="Connect a local path, volume or network location to Spacedrive."
button={() => (
<Button
style={tw`mt-2.5`}
variant="outline"
onPress={() => {
modalRef.current?.present();
}}
>
<Text style={tw`font-bold text-ink`}>Add Location</Text>
</Button>
)}
/>
);
}}
showsVerticalScrollIndicator={false}
renderItem={({ item }) => (
<Pressable
onPress={() =>
navigation.jumpTo('BrowseStack', {
initial: false,
screen: 'Location',
params: { id: item.id }
})
}
>
<StatCard
connectionType={null}
totalSpace={item.size_in_bytes || [0]}
name={item.name || ''}
color="#0362FF"
icon="Folder"
/>
</Pressable>
)}
/>
</View>
</OverviewSection>
<ImportModal ref={modalRef} />

View file

@ -2,7 +2,7 @@ import { MotiView } from 'moti';
import { memo, useCallback, useMemo } from 'react';
import { FlatList, Pressable, Text, View } from 'react-native';
import { LinearTransition } from 'react-native-reanimated';
import { Location, useCache, useLibraryQuery, useNodes } from '@sd/client';
import { Location, useLibraryQuery } from '@sd/client';
import { Icon } from '~/components/icons/Icon';
import Card from '~/components/layout/Card';
import Empty from '~/components/layout/Empty';
@ -14,8 +14,7 @@ import { useSearchStore } from '~/stores/searchStore';
const Locations = () => {
const locationsQuery = useLibraryQuery(['locations.list']);
useNodes(locationsQuery.data?.nodes);
const locations = useCache(locationsQuery.data?.items);
const locations = locationsQuery.data;
const searchStore = useSearchStore();
return (

View file

@ -3,7 +3,7 @@ import { memo, useCallback, useMemo } from 'react';
import { Pressable, Text, View } from 'react-native';
import { FlatList } from 'react-native-gesture-handler';
import { LinearTransition } from 'react-native-reanimated';
import { Tag, useCache, useLibraryQuery, useNodes } from '@sd/client';
import { Tag, useLibraryQuery } from '@sd/client';
import Card from '~/components/layout/Card';
import Empty from '~/components/layout/Empty';
import Fade from '~/components/layout/Fade';
@ -14,8 +14,7 @@ import { useSearchStore } from '~/stores/searchStore';
const Tags = () => {
const tags = useLibraryQuery(['tags.list']);
useNodes(tags.data?.nodes);
const tagsData = useCache(tags.data?.items);
const tagsData = tags.data;
const searchStore = useSearchStore();
return (

View file

@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { useCache, useLibraryQuery, useNodes, usePathsExplorerQuery } from '@sd/client';
import { useLibraryQuery, usePathsExplorerQuery } from '@sd/client';
import Explorer from '~/components/explorer/Explorer';
import { BrowseStackScreenProps } from '~/navigation/tabs/BrowseStack';
import { getExplorerStore } from '~/stores/explorerStore';
@ -8,8 +8,7 @@ export default function LocationScreen({ navigation, route }: BrowseStackScreenP
const { id, path } = route.params;
const location = useLibraryQuery(['locations.get', route.params.id]);
useNodes(location.data?.nodes);
const locationData = useCache(location.data?.item);
const locationData = location.data;
const paths = usePathsExplorerQuery({
arg: {

View file

@ -3,7 +3,7 @@ import { Plus } from 'phosphor-react-native';
import { useMemo, useRef } from 'react';
import { FlatList, Pressable, View } from 'react-native';
import { useDebounce } from 'use-debounce';
import { useCache, useLibraryQuery, useNodes } from '@sd/client';
import { useLibraryQuery } from '@sd/client';
import Empty from '~/components/layout/Empty';
import { ModalRef } from '~/components/layout/Modal';
import ScreenContainer from '~/components/layout/ScreenContainer';
@ -20,8 +20,7 @@ interface Props {
export default function LocationsScreen({ viewStyle }: Props) {
const locationsQuery = useLibraryQuery(['locations.list']);
useNodes(locationsQuery.data?.nodes);
const locations = useCache(locationsQuery.data?.items);
const locations = locationsQuery.data;
const { search } = useSearchStore();
const modalRef = useRef<ModalRef>(null);
const [debouncedSearch] = useDebounce(search, 200);

View file

@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { useCache, useLibraryQuery, useNodes, useObjectsExplorerQuery } from '@sd/client';
import { useLibraryQuery, useObjectsExplorerQuery } from '@sd/client';
import Explorer from '~/components/explorer/Explorer';
import { BrowseStackScreenProps } from '~/navigation/tabs/BrowseStack';
@ -7,8 +7,7 @@ export default function TagScreen({ navigation, route }: BrowseStackScreenProps<
const { id } = route.params;
const tag = useLibraryQuery(['tags.get', id]);
useNodes(tag.data?.nodes);
const tagData = useCache(tag.data?.item);
const tagData = tag.data;
const objects = useObjectsExplorerQuery({
arg: { filters: [{ object: { tags: { in: [id] } } }], take: 30 },

View file

@ -3,7 +3,8 @@ import { Plus } from 'phosphor-react-native';
import { useMemo, useRef } from 'react';
import { Pressable, View } from 'react-native';
import { FlatList } from 'react-native-gesture-handler';
import { useCache, useLibraryQuery, useNodes } from '@sd/client';
import { useDebounce } from 'use-debounce';
import { useLibraryQuery } from '@sd/client';
import Empty from '~/components/layout/Empty';
import { ModalRef } from '~/components/layout/Modal';
import ScreenContainer from '~/components/layout/ScreenContainer';
@ -11,7 +12,6 @@ import CreateTagModal from '~/components/modal/tag/CreateTagModal';
import { TagItem } from '~/components/tags/TagItem';
import { tw, twStyle } from '~/lib/tailwind';
import { BrowseStackScreenProps } from '~/navigation/tabs/BrowseStack';
import { useDebounce } from 'use-debounce';
import { useSearchStore } from '~/stores/searchStore';
interface Props {
@ -22,10 +22,9 @@ export default function TagsScreen({ viewStyle = 'list' }: Props) {
const navigation = useNavigation<BrowseStackScreenProps<'Browse'>['navigation']>();
const modalRef = useRef<ModalRef>(null);
const {search} = useSearchStore();
const { search } = useSearchStore();
const tags = useLibraryQuery(['tags.list']);
useNodes(tags.data?.nodes);
const tagData = useCache(tags.data?.items);
const tagData = tags.data || [];
const [debouncedSearch] = useDebounce(search, 200);
const filteredTags = useMemo(
@ -47,39 +46,39 @@ export default function TagsScreen({ viewStyle = 'list' }: Props) {
>
<Plus size={20} weight="bold" style={tw`text-ink`} />
</Pressable>
<FlatList
data={filteredTags}
renderItem={({ item }) => (
<TagItem
viewStyle={viewStyle}
tag={item}
onPress={() => {
navigation.navigate('BrowseStack', {
screen: 'Tag',
params: { id: item.id, color: item.color! }
});
}}
/>
)}
ListEmptyComponent={
<Empty
icon="Tags"
style={'border-0'}
textSize="text-md"
iconSize={84}
description="You have not created any tags"
/>
}
horizontal={false}
numColumns={viewStyle === 'grid' ? 3 : 1}
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
ItemSeparatorComponent={() => <View style={tw`h-2`} />}
contentContainerStyle={twStyle(
`py-6`,
tagData.length === 0 && 'h-full items-center justify-center'
)}
/>
<FlatList
data={filteredTags}
renderItem={({ item }) => (
<TagItem
viewStyle={viewStyle}
tag={item}
onPress={() => {
navigation.navigate('BrowseStack', {
screen: 'Tag',
params: { id: item.id, color: item.color! }
});
}}
/>
)}
ListEmptyComponent={
<Empty
icon="Tags"
style={'border-0'}
textSize="text-md"
iconSize={84}
description="You have not created any tags"
/>
}
horizontal={false}
numColumns={viewStyle === 'grid' ? 3 : 1}
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
ItemSeparatorComponent={() => <View style={tw`h-2`} />}
contentContainerStyle={twStyle(
`py-6`,
tagData.length === 0 && 'h-full items-center justify-center'
)}
/>
<CreateTagModal ref={modalRef} />
</ScreenContainer>
);

View file

@ -2,7 +2,7 @@ import { DotsThreeOutlineVertical, Pen, Trash } from 'phosphor-react-native';
import React, { useEffect, useRef } from 'react';
import { Animated, FlatList, Pressable, Text, View } from 'react-native';
import { Swipeable } from 'react-native-gesture-handler';
import { LibraryConfigWrapped, useBridgeQuery, useCache, useNodes } from '@sd/client';
import { LibraryConfigWrapped, useBridgeQuery } from '@sd/client';
import Fade from '~/components/layout/Fade';
import { ModalRef } from '~/components/layout/Modal';
import ScreenContainer from '~/components/layout/ScreenContainer';
@ -80,8 +80,7 @@ function LibraryItem({
const LibrarySettingsScreen = ({ navigation }: SettingsStackScreenProps<'LibrarySettings'>) => {
const libraryList = useBridgeQuery(['library.list']);
useNodes(libraryList.data?.nodes);
const libraries = useCache(libraryList.data?.items);
const libraries = libraryList.data;
useEffect(() => {
navigation.setOptions({

View file

@ -4,7 +4,7 @@ import { useEffect } from 'react';
import { Controller } from 'react-hook-form';
import { Alert, Text, View } from 'react-native';
import { z } from 'zod';
import { useLibraryMutation, useLibraryQuery, useNormalisedCache, useZodForm } from '@sd/client';
import { useLibraryMutation, useLibraryQuery, useZodForm } from '@sd/client';
import ScreenContainer from '~/components/layout/ScreenContainer';
import { AnimatedButton } from '~/components/primitive/Button';
import { Divider } from '~/components/primitive/Divider';
@ -33,7 +33,6 @@ const EditLocationSettingsScreen = ({
const { id } = route.params;
const queryClient = useQueryClient();
const cache = useNormalisedCache();
const form = useZodForm({ schema });
@ -92,10 +91,7 @@ const EditLocationSettingsScreen = ({
}, [form, navigation, onSubmit]);
useLibraryQuery(['locations.getWithRules', id], {
onSuccess: (dataRaw) => {
cache.withNodes(dataRaw?.nodes);
const data = cache.withCache(dataRaw?.item);
onSuccess: (data) => {
if (data && !form.formState.isDirty)
form.reset({
displayName: data.name,

View file

@ -1,7 +1,7 @@
import { hydrate, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useEffect, useRef, useState } from 'react';
import { createBrowserRouter } from 'react-router-dom';
import { CacheProvider, createCache, RspcProvider } from '@sd/client';
import { RspcProvider } from '@sd/client';
import {
createRoutes,
Platform,
@ -88,9 +88,7 @@ const queryClient = new QueryClient({
}
});
const cache = createCache();
const routes = createRoutes(platform, cache);
const routes = createRoutes(platform);
function App() {
const router = useRouter();
@ -114,18 +112,16 @@ function App() {
<RspcProvider queryClient={queryClient}>
<PlatformProvider platform={platform}>
<QueryClientProvider client={queryClient}>
<CacheProvider cache={cache}>
<SpacedriveInterfaceRoot>
<SpacedriveRouterProvider
routing={{
...router,
tabId: '',
routes,
visible: true
}}
/>
</SpacedriveInterfaceRoot>
</CacheProvider>
<SpacedriveInterfaceRoot>
<SpacedriveRouterProvider
routing={{
...router,
tabId: '',
routes,
visible: true
}}
/>
</SpacedriveInterfaceRoot>
</QueryClientProvider>
</PlatformProvider>
</RspcProvider>

View file

@ -29,7 +29,6 @@ sd-core-sync = { path = "./crates/sync" }
# Spacedrive Sub-crates
sd-actors = { version = "0.1.0", path = "../crates/actors" }
sd-ai = { path = "../crates/ai", optional = true }
sd-cache = { path = "../crates/cache" }
sd-cloud-api = { version = "0.1.0", path = "../crates/cloud-api" }
sd-crypto = { path = "../crates/crypto", features = [
"sys",

View file

@ -11,7 +11,6 @@ edition = { workspace = true }
[dependencies]
# Spacedrive Sub-crates
sd-prisma = { path = "../../../crates/prisma" }
sd-cache = { path = "../../../crates/cache" }
prisma-client-rust = { workspace = true }
serde = { workspace = true }

View file

@ -27,7 +27,7 @@
#![forbid(deprecated_in_future)]
#![allow(clippy::missing_errors_doc, clippy::module_name_repetitions)]
use sd_prisma::prisma::{self, file_path, job, label, location, object};
use sd_prisma::prisma::{file_path, job, label, location, object};
// File Path selectables!
file_path::select!(file_path_pub_id { pub_id });
@ -176,13 +176,6 @@ object::include!(object_with_file_paths {
}
});
impl sd_cache::Model for object_with_file_paths::file_paths::Data {
fn name() -> &'static str {
// This is okay because it's a superset of the available fields.
prisma::file_path::NAME
}
}
// Job selectables!
job::select!(job_without_data {
id

View file

@ -19,7 +19,6 @@ use sd_core_prisma_helpers::{
file_path_to_isolate, file_path_to_isolate_with_id, object_with_file_paths,
};
use sd_cache::{CacheNode, Model, NormalisedResult, Reference};
use sd_file_ext::kind::ObjectKind;
use sd_images::ConvertibleExtension;
use sd_media_metadata::MediaMetadata;
@ -75,21 +74,12 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
pub note: Option<String>,
pub date_created: Option<DateTime<FixedOffset>>,
pub date_accessed: Option<DateTime<FixedOffset>>,
pub file_paths: Vec<Reference<file_path::Data>>,
}
impl Model for ObjectWithFilePaths2 {
fn name() -> &'static str {
"Object" // is a duplicate because it's the same entity but with a relation
}
pub file_paths: Vec<object_with_file_paths::file_paths::Data>,
}
impl ObjectWithFilePaths2 {
pub fn from_db(
nodes: &mut Vec<CacheNode>,
item: object_with_file_paths::Data,
) -> Reference<Self> {
let this = Self {
pub fn from_db(item: object_with_file_paths::Data) -> Self {
Self {
id: item.id,
pub_id: item.pub_id,
kind: item.kind,
@ -100,20 +90,8 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
note: item.note,
date_created: item.date_created,
date_accessed: item.date_accessed,
file_paths: item
.file_paths
.into_iter()
.map(|i| {
let id = i.id.to_string();
nodes.push(CacheNode::new(id.clone(), i));
Reference::new(id)
})
.collect(),
};
let id = this.id.to_string();
nodes.push(CacheNode::new(id.clone(), this));
Reference::new(id)
file_paths: item.file_paths,
}
}
}
@ -126,13 +104,7 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
.include(object_with_file_paths::include())
.exec()
.await?
.map(|item| {
let mut nodes = Vec::new();
NormalisedResult {
item: ObjectWithFilePaths2::from_db(&mut nodes, item),
nodes,
}
}))
.map(|item| ObjectWithFilePaths2::from_db(item)))
})
})
.procedure("getMediaData", {

View file

@ -8,7 +8,6 @@ use crate::{
use futures::StreamExt;
use prisma_client_rust::raw;
use sd_cache::{Model, Normalise, NormalisedResult, NormalisedResults};
use sd_file_ext::kind::ObjectKind;
use sd_p2p::RemoteIdentity;
use sd_prisma::prisma::{indexer_rule, object, statistics};
@ -57,12 +56,6 @@ pub struct LibraryConfigWrapped {
pub config: LibraryConfig,
}
impl Model for LibraryConfigWrapped {
fn name() -> &'static str {
"LibraryConfigWrapped"
}
}
impl LibraryConfigWrapped {
pub async fn from_library(library: &Library) -> Self {
Self {
@ -78,7 +71,7 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
R.router()
.procedure("list", {
R.query(|node, _: ()| async move {
let libraries = node
Ok(node
.libraries
.get_all()
.await
@ -93,11 +86,7 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
})
.collect::<Vec<_>>()
.join()
.await;
let (nodes, items) = libraries.normalise(|i| i.uuid.to_string());
Ok(NormalisedResults { nodes, items })
.await)
})
})
.procedure("statistics", {
@ -318,10 +307,7 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
.await?;
}
Ok(NormalisedResult::from(
LibraryConfigWrapped::from_library(&library).await,
|l| l.uuid.to_string(),
))
Ok(LibraryConfigWrapped::from_library(&library).await)
},
)
})

View file

@ -16,7 +16,6 @@ use sd_core_prisma_helpers::{
file_path_with_object, label_with_objects, location_with_indexer_rules, object_with_file_paths,
};
use sd_cache::{CacheNode, Model, Normalise, NormalisedResult, NormalisedResults, Reference};
use sd_prisma::prisma::{file_path, indexer_rule, indexer_rules_in_location, location, SortOrder};
use std::path::{Path, PathBuf};
@ -67,14 +66,6 @@ pub enum ExplorerItem {
},
}
// TODO: Really this shouldn't be a `Model` but it's easy for now.
// In the future we should store the inner data of the variant on behalf of it's existing model so it works cross queries.
impl Model for ExplorerItem {
fn name() -> &'static str {
"ExplorerItem"
}
}
impl ExplorerItem {
pub fn id(&self) -> String {
let ty = match self {
@ -207,17 +198,13 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
R.router()
.procedure("list", {
R.with2(library()).query(|(_, library), _: ()| async move {
let locations = library
Ok(library
.db
.location()
.find_many(vec![])
.order_by(location::date_created::order(SortOrder::Desc))
.exec()
.await?;
let (nodes, items) = locations.normalise(|i| i.id.to_string());
Ok(NormalisedResults { items, nodes })
.await?)
})
})
.procedure("get", {
@ -228,8 +215,7 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
.location()
.find_unique(location::id::equals(location_id))
.exec()
.await?
.map(|i| NormalisedResult::from(i, |i| i.id.to_string())))
.await?)
})
})
.procedure("getWithRules", {
@ -248,21 +234,12 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
pub hidden: Option<bool>,
pub date_created: Option<DateTime<FixedOffset>>,
pub instance_id: Option<i32>,
pub indexer_rules: Vec<Reference<indexer_rule::Data>>,
}
impl Model for LocationWithIndexerRule {
fn name() -> &'static str {
"Location" // This is a duplicate identifier as `location::Data` but it's fine because because they are the same entity
}
pub indexer_rules: Vec<indexer_rule::Data>,
}
impl LocationWithIndexerRule {
pub fn from_db(
nodes: &mut Vec<CacheNode>,
value: location_with_indexer_rules::Data,
) -> Reference<Self> {
let this = Self {
pub fn from_db(value: location_with_indexer_rules::Data) -> Self {
Self {
id: value.id,
pub_id: value.pub_id,
name: value.name,
@ -279,18 +256,9 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
indexer_rules: value
.indexer_rules
.into_iter()
.map(|i| {
let id = i.indexer_rule.id.to_string();
nodes.push(CacheNode::new(id.clone(), i.indexer_rule));
Reference::new(id)
})
.collect(),
};
let id = this.id.to_string();
nodes.push(CacheNode::new(id.clone(), this));
Reference::new(id)
.map(|i| i.indexer_rule)
.collect::<Vec<_>>(),
}
}
}
@ -303,13 +271,7 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
.include(location_with_indexer_rules::include())
.exec()
.await?
.map(|location| {
let mut nodes = Vec::new();
NormalisedResult {
item: LocationWithIndexerRule::from_db(&mut nodes, location),
nodes,
}
}))
.map(|location| LocationWithIndexerRule::from_db(location)))
})
})
.procedure("create", {
@ -587,34 +549,25 @@ fn mount_indexer_rule_routes() -> AlphaRouter<Ctx> {
format!("Indexer rule <id={indexer_rule_id}> not found"),
)
})
.map(|i| NormalisedResult::from(i, |i| i.id.to_string()))
})
})
.procedure("list", {
R.with2(library()).query(|(_, library), _: ()| async move {
let rules = library.db.indexer_rule().find_many(vec![]).exec().await?;
let (nodes, items) = rules.normalise(|i| i.id.to_string());
Ok(NormalisedResults { items, nodes })
Ok(library.db.indexer_rule().find_many(vec![]).exec().await?)
})
})
// list indexer rules for location, returning the indexer rule
.procedure("listForLocation", {
R.with2(library())
.query(|(_, library), location_id: location::id::Type| async move {
let rules = library
Ok(library
.db
.indexer_rule()
.find_many(vec![indexer_rule::locations::some(vec![
indexer_rules_in_location::location_id::equals(location_id),
])])
.exec()
.await?;
let (nodes, items) = rules.normalise(|i| i.id.to_string());
Ok(NormalisedResults { items, nodes })
.await?)
})
})
}

View file

@ -8,7 +8,6 @@ use crate::{
Node,
};
use sd_cache::patch_typedef;
use sd_p2p::RemoteIdentity;
use sd_prisma::prisma::file_path;
@ -210,8 +209,6 @@ pub(crate) fn mount() -> Arc<Router> {
.merge("backups.", backups::mount())
.merge("invalidation.", utils::mount_invalidate())
.sd_patch_types_dangerously(|type_map| {
patch_typedef(type_map);
let def =
<sd_prisma::prisma::object::Data as specta::NamedType>::definition_named_data_type(
type_map,

View file

@ -6,10 +6,12 @@ use crate::{
util::{unsafe_streamed_query, BatchedStream},
};
use sd_core_indexer_rules::seed::no_hidden;
use sd_core_indexer_rules::IndexerRule;
use sd_core_prisma_helpers::{file_path_with_object, object_with_file_paths};
use sd_cache::{CacheNode, Model, Normalise, Reference};
use sd_prisma::prisma::{self, PrismaClient};
use sd_file_ext::kind::ObjectKind;
use sd_prisma::prisma::{self, location, PrismaClient};
use sd_utils::chain_optional_iter;
use std::path::PathBuf;
@ -33,16 +35,9 @@ use super::{Ctx, R};
const MAX_TAKE: u8 = 100;
#[derive(Serialize, Type, Debug)]
struct SearchData<T: Model> {
struct SearchData<T> {
cursor: Option<Vec<u8>>,
items: Vec<Reference<T>>,
nodes: Vec<CacheNode>,
}
impl<T: Model> Model for SearchData<T> {
fn name() -> &'static str {
T::name()
}
items: Vec<T>,
}
#[derive(Serialize, Deserialize, Type, Debug, Clone)]
@ -89,9 +84,8 @@ pub fn mount() -> AlphaRouter<Ctx> {
}
#[derive(Serialize, Type, Debug)]
struct EphemeralPathsResultItem {
pub entries: Vec<Reference<ExplorerItem>>,
pub entries: Vec<ExplorerItem>,
pub errors: Vec<rspc::Error>,
pub nodes: Vec<CacheNode>,
}
R.with2(library()).subscription(
@ -154,12 +148,9 @@ pub fn mount() -> AlphaRouter<Ctx> {
}
}
let (nodes, entries) = entries.normalise(|item: &ExplorerItem| item.id());
yield EphemeralPathsResultItem {
entries,
errors,
nodes,
};
}
}))
@ -249,12 +240,9 @@ pub fn mount() -> AlphaRouter<Ctx> {
})
}
let (nodes, items) = items.normalise(|item| item.id());
Ok(SearchData {
items,
cursor: None,
nodes,
})
},
)
@ -370,13 +358,7 @@ pub fn mount() -> AlphaRouter<Ctx> {
});
}
let (nodes, items) = items.normalise(|item| item.id());
Ok(SearchData {
nodes,
items,
cursor,
})
Ok(SearchData { items, cursor })
},
)
})

View file

@ -1,6 +1,5 @@
use crate::{invalidate_query, library::Library, object::tag::TagCreateArgs};
use sd_cache::{CacheNode, Normalise, NormalisedResult, NormalisedResults, Reference};
use sd_prisma::{
prisma::{file_path, object, tag, tag_on_object},
prisma_sync,
@ -23,40 +22,26 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
R.router()
.procedure("list", {
R.with2(library()).query(|(_, library), _: ()| async move {
let tags = library.db.tag().find_many(vec![]).exec().await?;
let (nodes, items) = tags.normalise(|i| i.id.to_string());
Ok(NormalisedResults { nodes, items })
Ok(library.db.tag().find_many(vec![]).exec().await?)
})
})
.procedure("getForObject", {
R.with2(library())
.query(|(_, library), object_id: i32| async move {
let tags = library
Ok(library
.db
.tag()
.find_many(vec![tag::tag_objects::some(vec![
tag_on_object::object_id::equals(object_id),
])])
.exec()
.await?;
let (nodes, items) = tags.normalise(|i| i.id.to_string());
Ok(NormalisedResults { nodes, items })
.await?)
})
})
.procedure("getWithObjects", {
#[derive(Serialize, Type)]
pub struct GetWithObjectsResult {
pub data: BTreeMap<u32, Vec<Reference<tag::Data>>>,
pub nodes: Vec<CacheNode>,
}
#[derive(Serialize, Type)]
pub struct ObjectWithDateCreated {
object: Reference<object::Data>,
object: object::Data,
date_created: DateTime<Utc>,
}
@ -97,8 +82,7 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
.tag()
.find_unique(tag::id::equals(tag_id))
.exec()
.await?
.map(|tag| NormalisedResult::from(tag, |i| i.id.to_string())))
.await?)
})
})
.procedure("create", {

View file

@ -1,29 +1,11 @@
use crate::volume::get_volumes;
use sd_cache::{Normalise, NormalisedResults};
use rspc::alpha::AlphaRouter;
use super::{Ctx, R};
pub(crate) fn mount() -> AlphaRouter<Ctx> {
R.router().procedure("list", {
R.query(|_, _: ()| async move {
let volumes = get_volumes().await;
let (nodes, items) = volumes.normalise(|i| {
// TODO: This is a really bad key. Once we hook up volumes with the DB fix this!
blake3::hash(
&i.mount_points
.iter()
.flat_map(|mp| mp.as_os_str().to_string_lossy().as_bytes().to_vec())
.collect::<Vec<u8>>(),
)
.to_hex()
.to_string()
});
Ok(NormalisedResults { nodes, items })
})
R.query(|_, _: ()| async move { Ok(get_volumes().await) })
})
}

View file

@ -1,5 +1,4 @@
// Adapted from: https://github.com/kimlimjustin/xplorer/blob/f4f3590d06783d64949766cc2975205a3b689a56/src-tauri/src/drives.rs
use sd_cache::Model;
use std::{
fmt::Display,
@ -57,12 +56,6 @@ pub struct Volume {
pub is_root_filesystem: bool,
}
impl Model for Volume {
fn name() -> &'static str {
"Volume"
}
}
impl Hash for Volume {
fn hash<H: Hasher>(&self, state: &mut H) {
self.name.hash(state);

View file

@ -1,11 +0,0 @@
[package]
name = "sd-cache"
version = "0.0.0"
license = { workspace = true }
edition = { workspace = true }
repository = { workspace = true }
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
specta = { workspace = true }

View file

@ -1,229 +0,0 @@
use std::{
hash::{Hash, Hasher},
marker::PhantomData,
sync::Arc,
};
use serde::{ser::SerializeMap, Serialize, Serializer};
use specta::{Any, DataType, NamedType, Type, TypeMap};
/// A type that can be used to return a group of `Reference<T>` and `CacheNode`'s
///
/// You don't need to use this, it's just a shortcut to avoid having to write out the full type every time.
#[derive(Serialize, Type, Debug)]
pub struct NormalisedResults<T: Model + Type> {
pub items: Vec<Reference<T>>,
pub nodes: Vec<CacheNode>,
}
/// A type that can be used to return a group of `Reference<T>` and `CacheNode`'s
///
/// You don't need to use this, it's just a shortcut to avoid having to write out the full type every time.
#[derive(Serialize, Type, Debug)]
pub struct NormalisedResult<T: Model + Type> {
pub item: Reference<T>,
pub nodes: Vec<CacheNode>,
}
impl<T: Model + Serialize + Type> NormalisedResult<T> {
pub fn from(item: T, id_fn: impl Fn(&T) -> String) -> Self {
let id = id_fn(&item);
Self {
item: Reference::new(id.clone()),
nodes: vec![CacheNode::new(id, item)],
}
}
}
/// A type which can be stored in the cache.
pub trait Model {
/// Must return a unique identifier for this model within the cache.
fn name() -> &'static str;
}
/// A reference to a `CacheNode`.
///
/// This does not contain the actual data, but instead a reference to it.
/// This allows the CacheNode's to be switched out and the query recomputed without any backend communication.
///
/// If you use a `Reference` in a query, you *must* ensure the corresponding `CacheNode` is also in the query.
#[derive(Type, Debug, Clone, Hash, PartialEq, Eq)]
pub struct Reference<T> {
__type: &'static str,
__id: String,
#[specta(rename = "#type")]
ty: PhantomType<T>,
}
impl<T: Model + Type> Reference<T> {
pub fn new(key: String) -> Self {
Self {
__type: "", // This is just to fake the field for Specta
__id: key,
ty: PhantomType(PhantomData),
}
}
}
impl<T: Model> Serialize for Reference<T> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut map = serializer.serialize_map(Some(2))?;
map.serialize_entry("__type", T::name())?;
map.serialize_entry("__id", &self.__id)?;
map.end()
}
}
/// A node in the cache.
/// This holds the data and is identified by it's type and id.
#[derive(Debug, Clone)]
pub struct CacheNode(
&'static str,
serde_json::Value,
Result<serde_json::Value, Arc<serde_json::Error>>,
);
impl CacheNode {
pub fn new<T: Model + Serialize + Type>(key: String, value: T) -> Self {
Self(
T::name(),
key.into(),
serde_json::to_value(value).map_err(Arc::new),
)
}
}
impl PartialEq for CacheNode {
fn eq(&self, other: &Self) -> bool {
self.0 == other.0
&& self.1 == other.1
&& match (&self.2, &other.2) {
(Ok(v0), Ok(v1)) => v0 == v1,
// Compares the values in the Arcs, not the Arc objects themselves.
(Err(e0), Err(e1)) => {
(*e0).classify() == (*e1).classify()
&& (*e0).column() == (*e1).column()
&& (*e0).line() == (*e1).line()
}
_ => false,
}
}
}
impl Eq for CacheNode {}
impl Hash for CacheNode {
fn hash<H: Hasher>(&self, state: &mut H) {
self.0.hash(state);
self.1.as_str().hash(state);
self.1.as_str().hash(state);
}
}
#[derive(Type, Default)]
#[specta(rename = "CacheNode", remote = CacheNode)]
#[allow(unused)]
struct CacheNodeTy {
__type: String,
__id: String,
#[specta(rename = "#node")]
node: Any,
}
#[derive(Serialize)]
struct NodeSerdeRepr<'a> {
__type: &'static str,
__id: &'a serde_json::Value,
#[serde(flatten)]
v: &'a serde_json::Value,
}
impl Serialize for CacheNode {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
NodeSerdeRepr {
__type: self.0,
__id: &self.1,
v: self.2.as_ref().map_err(|err| {
serde::ser::Error::custom(format!("Failed to serialize node: {}", err))
})?,
}
.serialize(serializer)
}
}
/// A helper for easily normalizing data.
pub trait Normalise {
type Item: Model + Type;
fn normalise(
self,
id_fn: impl Fn(&Self::Item) -> String,
) -> (Vec<CacheNode>, Vec<Reference<Self::Item>>);
}
impl<T: Model + Serialize + Type> Normalise for Vec<T> {
type Item = T;
fn normalise(
self,
id_fn: impl Fn(&Self::Item) -> String,
) -> (Vec<CacheNode>, Vec<Reference<Self::Item>>) {
let mut nodes = Vec::with_capacity(self.len());
let mut references = Vec::with_capacity(self.len());
for item in self.into_iter() {
let id = id_fn(&item);
nodes.push(CacheNode::new(id.clone(), item));
references.push(Reference::new(id));
}
(nodes, references)
}
}
/// Basically `PhantomData`.
///
/// With Specta `PhantomData` is exported as `null`.
/// This will export as `T` but serve the same purpose as `PhantomData` (holding a type without it being instantiated).
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct PhantomType<T>(PhantomData<T>);
/// WARNING: This type is surgically updated within `Reference` in the final typedefs due it being impossible to properly implement.
/// Be careful changing it!
impl<T: Type> Type for PhantomType<T> {
fn inline(type_map: &mut TypeMap, generics: &[DataType]) -> DataType {
T::inline(type_map, generics)
}
fn reference(type_map: &mut TypeMap, generics: &[DataType]) -> specta::reference::Reference {
T::reference(type_map, generics)
}
fn definition(type_map: &mut TypeMap) -> DataType {
T::definition(type_map)
}
}
// This function is cursed.
pub fn patch_typedef(type_map: &mut TypeMap) {
#[derive(Type)]
#[specta(rename = "Reference")]
#[allow(unused)]
struct ReferenceTy<T> {
__type: &'static str,
__id: String,
#[specta(rename = "#type")]
ty: T,
}
let mut def = <Reference<()> as NamedType>::definition_named_data_type(type_map);
def.inner = ReferenceTy::<Any>::definition(type_map);
type_map.insert(<Reference<()> as NamedType>::SID, def)
}

View file

@ -5,7 +5,6 @@ edition = "2021"
[dependencies]
# Spacedrive Sub-crates
sd-cache = { path = "../cache" }
sd-sync = { path = "../sync" }
prisma-client-rust = { workspace = true }

View file

@ -3,22 +3,6 @@ pub mod prisma;
#[allow(warnings, unused)]
pub mod prisma_sync;
macro_rules! impl_model {
($module:ident) => {
impl sd_cache::Model for prisma::$module::Data {
fn name() -> &'static str {
prisma::$module::NAME
}
}
};
}
impl_model!(tag);
impl_model!(object);
impl_model!(location);
impl_model!(indexer_rule);
impl_model!(file_path);
pub async fn test_db() -> std::sync::Arc<prisma::PrismaClient> {
std::sync::Arc::new(
prisma::PrismaClient::_builder()

View file

@ -1,141 +0,0 @@
---
title: Normalised Cache
index: 12
---
# Normalised Cache
We use a normalised cache for our frontend to ensure the UI will always contain consistent data.
## What this system does?
By normalising the data it's impossible to get "state tearing".
Each time `useNodes` or `cache.withNodes` is called all `useCache` hooks will reexecute if they depend on a node that has changed.
This means the queries will always render the newest version of the model.
## Terminology
- `CacheNode`: A node in the cache - this contains the data and can be identified by the model's name and unique ID within the data (eg. database primary key).
- `Reference<T>`: A reference to a node in the cache - This contains the model's name and unique ID.
## High level overview
We turn the data on the backend into a list of `CacheNode`'s and a list of `Reference<T>`'s and then return it to the frontend.
We insert the `CacheNode`'s into a global cache on the frontend and then use the `Reference<T>`'s to reconstruct the data by looking up the `CacheNode`'s.
When the cache changes (from another query, invalidation, etc), we can reconstruct _all_ queries using their `Reference<T>`'s to reflect the updated data.
## Rust usage
The Rust helpers are defined [here](https://github.com/spacedriveapp/spacedrive/blob/main/crates/cache/src/lib.rs) and can be used like the following:
```rust
pub struct Demo {
id: String,
}
impl sd_cache::Model for Demo {
// The name + the ID *must* refer to a unique node.
// If your using an enum, the variant should show up in the ID (although this isn't possible right now)
fn name() -> &'static str {
"Demo"
}
}
let data: Vec<Demo> = vec![];
// We normalised the data but splitting it into a group of reference and a group of `CacheNode`'s.
let (nodes, items) = libraries.normalise(|i| i.id);
// `NormalisedResults` or `NormalisedResult` are optional wrapper types to hold a one or multiple items and their cache nodes.
// You don't have to use them, but they save declaring a bunch of identical structs.
//
// Alternatively add `nodes: Vec<CacheNode>` and `items: Vec<Reference<T>>` to your existing return type.
//
return sd_cache::NormalisedResults { nodes, items };
```
## Typescript usage
The Typescript helpers are defined [here](https://github.com/spacedriveapp/spacedrive/blob/main/packages/client/src/cache.tsx).
### Usage with React
We have helpers designed for easy usage within React's lifecycle.
```ts
const query = useLibraryQuery([...]);
// This will inject all the models into the cache
useNodes(query.data?.nodes);
// This will reconstruct the data from the cache
const data = useCache(query.data?.item);
console.log(data);
```
### Vanilla JS
These API's are really useful for special cases. In general aim to use the React API's unless you have a good reason for these.
```ts
const cache = useNormalisedCache(); // Get the cache within the react context
// Pass `cache` outside React (Eg. `useEffect`, `onSuccess`, etc)
const data = ...;
// This will inject all the models into the cache
cache.withNodes(data.nodes)
// This will reconstruct the data from the cache
//
// *WARNING* This is not reactive. So any changes to the nodes will not be reflected.
// Using this is fine if you need to quickly check the data but don't hold onto it.
const data = useCache(query.data?.item);
console.log(data);
```
## Design decisions
### Why `useNodes` and `useCache`?
This was done to make the system super flexible with what data you can return from your backend.
For example the backend doesn't just have to return `NormalisedResults` or `NormalisedResult`, it could return:
```rust
pub struct AllTheData {
file_paths: Vec<Reference<FilePath>>,
locations: Vec<Reference<Location>>,
nodes: Vec<CacheNode>
}
```
and then on the frontend you could do the following:
```ts
const query = useQuery([...]);
useNodes(query.data?.nodes);
const locations = useCache(query.data?.locations);
const filePaths = useCache(query.data?.file_paths);
```
This is only possible because `useNodes` and `useCache` take in a specific key, instead of the whole `data` object, so you can tell it where to look.
## Known issues
### Specta support
Expressing `Reference<T>` in Specta is really hard so we [surgically update](https://github.com/spacedriveapp/spacedrive/blob/a315dd632da8175b47f9e0713d3c7fc470329352/core/src/api/mod.rs#L219) it's [type definition](https://github.com/spacedriveapp/spacedrive/blob/a315dd632da8175b47f9e0713d3c7fc470329352/crates/cache/src/lib.rs#L215).
This is done using `rspc::Router::sd_patch_types_dangerously` which is a method specific to our fork [spacedrive/rspc](https://github.com/spacedriveapp/rspc).
### Invalidation system integration
The initial implementation of this idea with an MVP. It works with the existing invalidation system like regular queries, but the invalidation system isn't aware of the normalised cache like a better implementation would be.

View file

@ -4,7 +4,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import clsx from 'clsx';
import { RefObject, useMemo, useRef } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ExplorerItem, useCache, useLibraryQuery, useNodes } from '@sd/client';
import { ExplorerItem, useLibraryQuery } from '@sd/client';
import { Button, dialogManager, ModifierKeys, tw } from '@sd/ui';
import CreateDialog, {
AssignTagItems,
@ -23,7 +23,6 @@ interface Props {
function useData({ items }: Props) {
const tags = useLibraryQuery(['tags.list'], { suspense: true });
useNodes(tags.data?.nodes);
// Map<tag::id, Vec<object::id>>
const tagsWithObjects = useLibraryQuery(
@ -42,7 +41,7 @@ function useData({ items }: Props) {
return {
tags: {
...tags,
data: useCache(tags.data?.items)
data: tags.data
},
tagsWithObjects
};

View file

@ -37,10 +37,8 @@ import {
ObjectKindEnum,
ObjectWithFilePaths,
useBridgeQuery,
useCache,
useItemsAsObjects,
useLibraryQuery,
useNodes,
useSelector,
type ExplorerItem
} from '@sd/client';
@ -180,8 +178,7 @@ export const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => {
const { t } = useLocale();
const result = useLibraryQuery(['locations.list']);
useNodes(result.data?.nodes);
const locations = useCache(result.data?.items);
const locations = result.data || [];
switch (item.type) {
case 'NonIndexedPath': {
@ -225,8 +222,7 @@ export const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => {
const tagsQuery = useLibraryQuery(['tags.getForObject', objectData?.id ?? -1], {
enabled: objectData != null && readyToFetch
});
useNodes(tagsQuery.data?.nodes);
const tags = useCache(tagsQuery.data?.items);
const tags = tagsQuery.data;
// const labels = useLibraryQuery(['labels.getForObject', objectData?.id ?? -1], {
// enabled: objectData != null && readyToFetch
@ -434,8 +430,7 @@ const MultiItemMetadata = ({ items }: { items: ExplorerItem[] }) => {
enabled: readyToFetch && !isDragSelecting,
suspense: true
});
useNodes(tagsQuery.data?.nodes);
const tags = useCache(tagsQuery.data?.items);
const tags = tagsQuery.data;
// const labels = useLibraryQuery(['labels.list'], {
// enabled: readyToFetch && !isDragSelecting,

View file

@ -6,14 +6,7 @@ import { useEffect, useState } from 'react';
import CommandPalette, { filterItems, getItemIndex } from 'react-cmdk';
import { useNavigate } from 'react-router';
import { createSearchParams } from 'react-router-dom';
import {
arraysEqual,
useCache,
useLibraryContext,
useLibraryQuery,
useNodes,
useOnlineLocations
} from '@sd/client';
import { arraysEqual, useLibraryContext, useLibraryQuery, useOnlineLocations } from '@sd/client';
import { dialogManager } from '@sd/ui';
import i18n from '~/app/I18n';
import { Icon } from '~/components';
@ -58,8 +51,7 @@ const CMDK = () => {
const [search, setSearch] = useState('');
const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true });
useNodes(locationsQuery.data?.nodes);
const locations = useCache(locationsQuery.data?.items);
const locations = locationsQuery.data;
const onlineLocations = useOnlineLocations();

View file

@ -1,13 +1,12 @@
import clsx from 'clsx';
import CommandPalette from 'react-cmdk';
import { useNavigate } from 'react-router';
import { arraysEqual, useCache, useLibraryQuery, useNodes, useOnlineLocations } from '@sd/client';
import { arraysEqual, useLibraryQuery, useOnlineLocations } from '@sd/client';
import { Icon } from '~/components';
export default function CMDKLocations() {
const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true });
useNodes(locationsQuery.data?.nodes);
const locations = useCache(locationsQuery.data?.items);
const locations = locationsQuery.data;
const onlineLocations = useOnlineLocations();

View file

@ -1,11 +1,10 @@
import CommandPalette from 'react-cmdk';
import { useNavigate } from 'react-router';
import { useCache, useLibraryQuery, useNodes, type Tag } from '@sd/client';
import { useLibraryQuery, type Tag } from '@sd/client';
export default function CMDKTags() {
const result = useLibraryQuery(['tags.list'], { keepPreviousData: true });
useNodes(result.data?.nodes);
const tags = useCache(result.data?.items);
const tags = result.data || [];
const navigate = useNavigate();

View file

@ -1,7 +1,7 @@
import { ArrowRight, EjectSimple } from '@phosphor-icons/react';
import clsx from 'clsx';
import { PropsWithChildren, useMemo } from 'react';
import { useBridgeQuery, useCache, useLibraryQuery, useNodes } from '@sd/client';
import { useBridgeQuery, useLibraryQuery } from '@sd/client';
import { Button, toast, tw } from '@sd/ui';
import { Icon, IconName } from '~/components';
import { useLocale } from '~/hooks';
@ -34,13 +34,11 @@ const SidebarIcon = ({ name }: { name: IconName }) => {
export default function LocalSection() {
const platform = usePlatform();
const locationsQuery = useLibraryQuery(['locations.list']);
useNodes(locationsQuery.data?.nodes);
const locations = useCache(locationsQuery.data?.items);
const locations = locationsQuery.data;
const homeDir = useHomeDir();
const result = useBridgeQuery(['volumes.list']);
useNodes(result.data?.nodes);
const volumes = useCache(result.data?.items);
const volumes = result.data;
const { t } = useLocale();

View file

@ -3,9 +3,7 @@ import { Link, useMatch } from 'react-router-dom';
import {
arraysEqual,
Location as LocationType,
useCache,
useLibraryQuery,
useNodes,
useOnlineLocations
} from '@sd/client';
import { useExplorerDroppable } from '~/app/$libraryId/Explorer/useExplorerDroppable';
@ -21,8 +19,7 @@ import { ContextMenu } from './ContextMenu';
export default function Locations() {
const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true });
useNodes(locationsQuery.data?.nodes);
const locations = useCache(locationsQuery.data?.items);
const locations = locationsQuery.data;
const onlineLocations = useOnlineLocations();
const { t } = useLocale();

View file

@ -1,6 +1,6 @@
import clsx from 'clsx';
import { NavLink, useMatch } from 'react-router-dom';
import { useCache, useLibraryQuery, useNodes, type Tag } from '@sd/client';
import { useLibraryQuery, type Tag } from '@sd/client';
import { useExplorerDroppable } from '~/app/$libraryId/Explorer/useExplorerDroppable';
import { SubtleButton } from '~/components';
import { useLocale } from '~/hooks';
@ -12,8 +12,7 @@ import { ContextMenu } from './ContextMenu';
export default function TagsSection() {
const result = useLibraryQuery(['tags.list'], { keepPreviousData: true });
useNodes(result.data?.nodes);
const tags = useCache(result.data?.items);
const tags = result.data;
const { t } = useLocale();

View file

@ -1,13 +0,0 @@
import { snapshot } from 'valtio';
import { useNormalisedCache } from '@sd/client';
export function Component() {
const cache = useNormalisedCache();
const data = snapshot(cache['#cache']);
return (
<div className="p-4">
<h1>Cache Debug</h1>
<pre className="pt-4">{JSON.stringify(data, null, 2)}</pre>
</div>
);
}

View file

@ -1,7 +1,6 @@
import { RouteObject } from 'react-router';
export const debugRoutes = [
{ path: 'cache', lazy: () => import('./cache') },
{ path: 'cloud', lazy: () => import('./cloud') },
{ path: 'sync', lazy: () => import('./sync') },
{ path: 'actors', lazy: () => import('./actors') },

View file

@ -3,10 +3,8 @@ import { Outlet, useNavigate } from 'react-router';
import {
useBridgeMutation,
useBridgeQuery,
useCache,
useConnectedPeers,
useDiscoveredPeers,
useNodes
useDiscoveredPeers
} from '@sd/client';
import { Button, toast } from '@sd/ui';
import { useZodRouteParams, useZodSearchParams } from '~/hooks';
@ -45,8 +43,7 @@ export function Overview() {
const result = useBridgeQuery(['library.list']);
const connectedPeers = useConnectedPeers();
const discoveredPeers = useDiscoveredPeers();
useNodes(result.data?.nodes);
const libraries = useCache(result.data?.items);
const libraries = result.data || [];
const debugConnect = useBridgeMutation(['p2p.debugConnect'], {
onSuccess: () => {
toast.success('Connected!');

View file

@ -8,7 +8,6 @@ import {
ExplorerItem,
getExplorerItemData,
useLibraryContext,
useNormalisedCache,
useUnsafeStreamedQuery,
type EphemeralPathOrder
} from '@sd/client';
@ -181,7 +180,6 @@ const EphemeralExplorer = memo((props: { args: PathParams }) => {
const settingsSnapshot = explorerSettings.useSettingsSnapshot();
const libraryCtx = useLibraryContext();
const cache = useNormalisedCache();
const query = useUnsafeStreamedQuery(
[
'search.ephemeralPaths',
@ -198,18 +196,16 @@ const EphemeralExplorer = memo((props: { args: PathParams }) => {
enabled: path != null,
suspense: true,
onSuccess: () => explorerStore.resetCache(),
onBatch: (item) => {
cache.withNodes(item.nodes);
}
onBatch: (item) => {}
}
);
const entries = useMemo(() => {
return cache.withCache(
return (
query.data?.flatMap((item) => item.entries) ||
query.streaming.flatMap((item) => item.entries)
query.streaming.flatMap((item) => item.entries)
);
}, [cache, query.streaming, query.data]);
}, [query.streaming, query.data]);
const items = useMemo(() => {
if (!entries) return [];

View file

@ -5,12 +5,10 @@ import {
arraysEqual,
FilePathOrder,
Location,
useCache,
useExplorerLayoutStore,
useLibraryMutation,
useLibraryQuery,
useLibrarySubscription,
useNodes,
useOnlineLocations
} from '@sd/client';
import { Loader, Tooltip } from '@sd/ui';
@ -52,8 +50,7 @@ export const Component = () => {
keepPreviousData: true,
suspense: true
});
useNodes(result.data?.nodes);
const location = useCache(result.data?.item);
const location = result.data;
// 'key' allows search state to be thrown out when entering a folder
return <LocationExplorer key={path} location={location!} />;

View file

@ -1,5 +1,5 @@
import { Link } from 'react-router-dom';
import { useBridgeQuery, useCache, useLibraryQuery, useNodes } from '@sd/client';
import { useBridgeQuery, useLibraryQuery } from '@sd/client';
import { useLocale } from '~/hooks';
import { useRouteTitle } from '~/hooks/useRouteTitle';
import { hardwareModelToIcon } from '~/util/hardware';
@ -20,8 +20,7 @@ export const Component = () => {
const { t } = useLocale();
const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true });
useNodes(locationsQuery.data?.nodes);
const locations = useCache(locationsQuery.data?.items) ?? [];
const locations = locationsQuery.data ?? [];
const { data: node } = useBridgeQuery(['nodeState']);

View file

@ -8,15 +8,7 @@ import {
Textbox
} from '@phosphor-icons/react';
import { useState } from 'react';
import {
InOrNotIn,
ObjectKind,
SearchFilterArgs,
TextMatch,
useCache,
useLibraryQuery,
useNodes
} from '@sd/client';
import { InOrNotIn, ObjectKind, SearchFilterArgs, TextMatch, useLibraryQuery } from '@sd/client';
import { Button, Input } from '@sd/ui';
import { Icon as SDIcon } from '~/components';
@ -437,8 +429,7 @@ export const filterRegistry = [
},
useOptions: () => {
const query = useLibraryQuery(['locations.list'], { keepPreviousData: true });
useNodes(query.data?.nodes);
const locations = useCache(query.data?.items);
const locations = query.data;
return (locations ?? []).map((location) => ({
name: location.name!,
@ -473,8 +464,7 @@ export const filterRegistry = [
},
useOptions: () => {
const query = useLibraryQuery(['tags.list']);
useNodes(query.data?.nodes);
const tags = useCache(query.data?.items);
const tags = query.data;
return (tags ?? []).map((tag) => ({
name: tag.name!,
value: tag.id,

View file

@ -1,6 +1,6 @@
import { iconNames } from '@sd/assets/util';
import { memo, useEffect, useMemo, useState } from 'react';
import { byteSize, useDiscoveredPeers, useLibraryQuery, useNodes } from '@sd/client';
import { byteSize, useDiscoveredPeers, useLibraryQuery } from '@sd/client';
import { Card } from '@sd/ui';
import { Icon } from '~/components';
import { useCounter, useLocale } from '~/hooks';
@ -16,8 +16,6 @@ export const Component = () => {
const locations = useLibraryQuery(['locations.list'], {
refetchOnWindowFocus: false
});
useNodes(locations.data?.nodes);
// const locations = useCache(result.data?.items);
const discoveredPeers = useDiscoveredPeers();
const info = useMemo(() => {
@ -38,8 +36,8 @@ export const Component = () => {
}[] = [
{
icon: 'Folder',
title: locations.data?.items.length === 1 ? 'Location' : 'Locations',
titleCount: locations.data?.items.length ?? 0,
title: locations.data?.length === 1 ? 'Location' : 'Locations',
titleCount: locations.data?.length ?? 0,
sub: 'indexed directories'
},
{

View file

@ -3,7 +3,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { Suspense } from 'react';
import { Controller } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { useCache, useLibraryMutation, useLibraryQuery, useNodes, useZodForm } from '@sd/client';
import { useLibraryMutation, useLibraryQuery, useZodForm } from '@sd/client';
import {
Button,
dialogManager,
@ -57,8 +57,7 @@ const EditLocationForm = () => {
const locationDataQuery = useLibraryQuery(['locations.getWithRules', locationId], {
suspense: true
});
useNodes(locationDataQuery.data?.nodes);
const locationData = useCache(locationDataQuery.data?.item);
const locationData = locationDataQuery.data;
const form = useZodForm({
schema,

View file

@ -4,10 +4,8 @@ import { useDebouncedCallback } from 'use-debounce';
import {
extractInfoRSPCError,
UnionToTuple,
useCache,
useLibraryMutation,
useLibraryQuery,
useNodes,
usePlausibleEvent,
useZodForm
} from '@sd/client';
@ -70,8 +68,7 @@ export const AddLocationDialog = ({
const createLocation = useLibraryMutation('locations.create');
const relinkLocation = useLibraryMutation('locations.relink');
const listIndexerRulesQuery = useLibraryQuery(['locations.indexer_rules.list']);
useNodes(listIndexerRulesQuery.data?.nodes);
const listIndexerRules = useCache(listIndexerRulesQuery.data?.items);
const listIndexerRules = listIndexerRulesQuery.data;
const addLocationToLibrary = useLibraryMutation('locations.addLibrary');
// This is required because indexRules is undefined on first render

View file

@ -2,7 +2,7 @@ import { Trash } from '@phosphor-icons/react';
import clsx from 'clsx';
import { MouseEventHandler, useState } from 'react';
import { ControllerRenderProps } from 'react-hook-form';
import { IndexerRule, useCache, useLibraryMutation, useLibraryQuery, useNodes } from '@sd/client';
import { IndexerRule, useLibraryMutation, useLibraryQuery } from '@sd/client';
import { Button, Divider, Label, toast } from '@sd/ui';
import { InfoText } from '@sd/ui/src/forms';
import { showAlertDialog } from '~/components';
@ -33,8 +33,7 @@ export default function IndexerRuleEditor<T extends IndexerRuleIdFieldType>({
...props
}: IndexerRuleEditorProps<T>) {
const listIndexerRules = useLibraryQuery(['locations.indexer_rules.list']);
useNodes(listIndexerRules.data?.nodes);
const indexRules = useCache(listIndexerRules.data?.items);
const indexRules = listIndexerRules.data;
const [isDeleting, setIsDeleting] = useState(false);
const [selectedRule, setSelectedRule] = useState<IndexerRule | undefined>(undefined);
const [toggleNewRule, setToggleNewRule] = useState(false);

View file

@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { useDebounce } from 'use-debounce';
import { useCache, useLibraryQuery, useNodes } from '@sd/client';
import { useLibraryQuery } from '@sd/client';
import { SearchInput } from '@sd/ui';
import { useLocale } from '~/hooks';
@ -10,8 +10,7 @@ import ListItem from './ListItem';
export const Component = () => {
const locationsQuery = useLibraryQuery(['locations.list']);
useNodes(locationsQuery.data?.nodes);
const locations = useCache(locationsQuery.data?.items);
const locations = locationsQuery.data;
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebounce(search, 200);

View file

@ -1,6 +1,6 @@
import clsx from 'clsx';
import { useEffect, useState } from 'react';
import { Tag, useCache, useLibraryQuery, useNodes } from '@sd/client';
import { Tag, useLibraryQuery } from '@sd/client';
import { Button, Card, dialogManager } from '@sd/ui';
import { Heading } from '~/app/$libraryId/settings/Layout';
import { TagsSettingsParamsSchema } from '~/app/route-schemas';
@ -11,8 +11,7 @@ import EditForm from './EditForm';
export const Component = () => {
const result = useLibraryQuery(['tags.list']);
useNodes(result.data?.nodes);
const tags = useCache(result.data?.items);
const tags = result.data;
const { id: locationId } = useZodRouteParams(TagsSettingsParamsSchema);
const tagSelectedParam = tags?.find((tag) => tag.id === locationId);

View file

@ -1,12 +1,6 @@
import { useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import {
insertLibrary,
useBridgeMutation,
useNormalisedCache,
usePlausibleEvent,
useZodForm
} from '@sd/client';
import { insertLibrary, useBridgeMutation, usePlausibleEvent, useZodForm } from '@sd/client';
import { Dialog, InputField, useDialog, UseDialogProps, z } from '@sd/ui';
import { useLocale } from '~/hooks';
import { usePlatform } from '~/util/Platform';
@ -32,16 +26,13 @@ export default (props: UseDialogProps) => {
const createLibrary = useBridgeMutation('library.create');
const form = useZodForm({ schema });
const cache = useNormalisedCache();
const onSubmit = form.handleSubmit(async (data) => {
try {
const libraryRaw = await createLibrary.mutateAsync({
const library = await createLibrary.mutateAsync({
name: data.name,
default_locations: null
});
cache.withNodes(libraryRaw.nodes);
const library = cache.withCache(libraryRaw.item);
insertLibrary(queryClient, library);

View file

@ -1,4 +1,4 @@
import { useBridgeQuery, useCache, useLibraryContext, useNodes } from '@sd/client';
import { useBridgeQuery, useLibraryContext } from '@sd/client';
import { Button, dialogManager } from '@sd/ui';
import { useLocale } from '~/hooks';
@ -8,8 +8,7 @@ import ListItem from './ListItem';
export const Component = () => {
const librariesQuery = useBridgeQuery(['library.list']);
useNodes(librariesQuery.data?.nodes);
const libraries = useCache(librariesQuery.data?.items);
const libraries = librariesQuery.data;
const { library } = useLibraryContext();

View file

@ -1,5 +1,5 @@
import { useCallback, useMemo } from 'react';
import { ObjectOrder, Tag, useCache, useLibraryQuery, useNodes } from '@sd/client';
import { ObjectOrder, Tag, useLibraryQuery } from '@sd/client';
import { LocationIdParamsSchema } from '~/app/route-schemas';
import { Icon } from '~/components';
import { useLocale, useRouteTitle, useZodRouteParams } from '~/hooks';
@ -20,18 +20,17 @@ import { TopBarPortal } from '../TopBar/Portal';
export function Component() {
const { id: tagId } = useZodRouteParams(LocationIdParamsSchema);
const result = useLibraryQuery(['tags.get', tagId], { suspense: true });
useNodes(result.data?.nodes);
const tag = useCache(result.data?.item);
const tag = result.data;
const { t } = useLocale();
useRouteTitle(tag!.name ?? 'Tag');
const { explorerSettings, preferences } = useTagExplorerSettings(tag);
const { explorerSettings, preferences } = useTagExplorerSettings(tag!);
const search = useSearchFromSearchParams();
const defaultFilters = useMemo(() => [{ object: { tags: { in: [tag.id] } } }], [tag.id]);
const defaultFilters = useMemo(() => [{ object: { tags: { in: [tag!.id] } } }], [tag!.id]);
const items = useSearchExplorerQuery({
search,

View file

@ -11,22 +11,17 @@ import {
type RouteObject
} from 'react-router-dom';
import {
CacheProvider,
ClientContextProvider,
context,
context2,
createCache,
currentLibraryCache,
getCachedLibraries,
LibraryContextProvider,
nonLibraryClient,
NormalisedCache,
Procedures,
useBridgeQuery,
useCache,
useCachedLibraries,
useFeatureFlag,
useNodes,
WithSolid,
type LibraryProceduresDef,
type NonLibraryProceduresDef
@ -60,7 +55,7 @@ function P2PErrorToast() {
return null;
}
export const createRoutes = (platform: Platform, cache: NormalisedCache) =>
export const createRoutes = (platform: Platform) =>
[
{
Component: () => {
@ -103,9 +98,9 @@ export const createRoutes = (platform: Platform, cache: NormalisedCache) =>
return <Navigate to={`${libraryId}`} replace />;
},
loader: async () => {
const libraries = await getCachedLibraries(cache, nonLibraryClient);
const libraries = await getCachedLibraries(nonLibraryClient);
const currentLibrary = libraries.find(
const currentLibrary = (libraries || []).find(
(l) => l.uuid === currentLibraryCache.id
);
@ -135,8 +130,7 @@ export const createRoutes = (platform: Platform, cache: NormalisedCache) =>
Component: () => {
const params = useZodRouteParams(LibraryIdParamsSchema);
const result = useBridgeQuery(['library.list']);
useNodes(result.data?.nodes);
const libraries = useCache(result.data?.items);
const libraries = result.data;
const library = libraries?.find((l) => l.uuid === params.libraryId);
@ -176,7 +170,7 @@ export const createRoutes = (platform: Platform, cache: NormalisedCache) =>
path: ':libraryId',
lazy: () => import('./$libraryId/Layout'),
loader: async ({ params: { libraryId } }) => {
const libraries = await getCachedLibraries(cache, nonLibraryClient);
const libraries = await getCachedLibraries(nonLibraryClient);
const library = libraries.find((l) => l.uuid === libraryId);
if (!library) {
@ -204,12 +198,7 @@ function RemoteLayout() {
// TODO: The caches should instead be prefixed by the remote node ID, instead of completely being recreated but that's too hard to do right now.
const [rspcClient, setRspcClient] =
useState<
[
AlphaClient<NonLibraryProceduresDef>,
AlphaClient<LibraryProceduresDef>,
QueryClient,
NormalisedCache
]
[AlphaClient<NonLibraryProceduresDef>, AlphaClient<LibraryProceduresDef>, QueryClient]
>();
useEffect(() => {
const endpoint = platform.getRemoteRspcEndpoint(params.node);
@ -233,8 +222,7 @@ function RemoteLayout() {
return [keyAndInput[0], { library_id: libraryId, arg: keyAndInput[1] ?? null }];
}
});
const cache = createCache();
setRspcClient([client, libraryClient, new QueryClient(), cache]);
setRspcClient([client, libraryClient, new QueryClient()]);
return () => {
// TODO: We *really* need to cleanup `client` so we aren't leaking all the resources.
@ -273,25 +261,23 @@ function RemoteLayout() {
{/* TODO: Maybe library context too? */}
{rspcClient && (
<QueryClientProvider client={rspcClient[2]}>
<CacheProvider cache={rspcClient[3]}>
<context.Provider
<context.Provider
value={{
// @ts-expect-error
client: rspcClient[0],
queryClient: rspcClient[2]
}}
>
<context2.Provider
value={{
// @ts-expect-error
client: rspcClient[0],
client: rspcClient[1],
queryClient: rspcClient[2]
}}
>
<context2.Provider
value={{
// @ts-expect-error
client: rspcClient[1],
queryClient: rspcClient[2]
}}
>
<Outlet />
</context2.Provider>
</context.Provider>
</CacheProvider>
<Outlet />
</context2.Provider>
</context.Provider>
</QueryClientProvider>
)}
</PlatformProvider>
@ -301,8 +287,7 @@ function RemoteLayout() {
function BrowsePage() {
const navigate = useNavigate();
const result = useBridgeQuery(['library.list']);
useNodes(result.data?.nodes);
const libraries = useCache(result.data?.items);
const libraries = result.data;
return (
<div className="flex flex-col">

View file

@ -11,7 +11,6 @@ import {
useBridgeMutation,
useCachedLibraries,
useMultiZodForm,
useNormalisedCache,
useOnboardingStore,
usePlausibleEvent
} from '@sd/client';
@ -98,7 +97,6 @@ const useFormState = () => {
}
const createLibrary = useBridgeMutation('library.create');
const cache = useNormalisedCache();
const submit = handleSubmit(
async (data) => {
@ -110,15 +108,13 @@ const useFormState = () => {
try {
// show creation screen for a bit for smoothness
const [libraryRaw] = await Promise.all([
const [library] = await Promise.all([
createLibrary.mutateAsync({
name: data['new-library'].name,
default_locations: data.locations.locations
}),
new Promise((res) => setTimeout(res, 500))
]);
cache.withNodes(libraryRaw.nodes);
const library = cache.withCache(libraryRaw.item);
insertLibrary(queryClient, library);
platform.refreshMenuBar && platform.refreshMenuBar();

View file

@ -61,7 +61,7 @@ function CloudLibraries() {
queryClient.setQueryData(['library.list'], (libraries: any) => {
// The invalidation system beat us to it
if (libraries.find((l: any) => l.uuid === library.uuid))
if ((libraries || []).find((l: any) => l.uuid === library.uuid))
return libraries;
return [...(libraries || []), library];

View file

@ -1,296 +0,0 @@
import { useQueryClient } from '@tanstack/react-query';
import {
createContext,
PropsWithChildren,
useContext,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore
} from 'react';
import { proxy, snapshot, subscribe } from 'valtio';
import { type CacheNode } from './core';
import { getPermits } from './rspc-cursed';
declare global {
interface Window {
__REDUX_DEVTOOLS_EXTENSION__: any;
}
}
type Store = ReturnType<typeof defaultStore>;
type Context = ReturnType<typeof createCache>;
export type NormalisedCache = ReturnType<typeof createCache>;
const defaultStore = () => ({
nodes: {} as Record<string, Record<string, unknown>>
});
const Context = createContext<Context>(undefined!);
export function createCache() {
const cache = proxy(defaultStore());
return {
cache,
withNodes(data: CacheNode[] | undefined, suffix?: string) {
updateNodes(cache, data, suffix);
},
withCache<T>(data: T | undefined, suffix?: string): UseCacheResult<T> {
return restore(cache, new Map(), data, suffix) as any;
}
};
}
export function CacheProvider({ cache, children }: PropsWithChildren<{ cache: NormalisedCache }>) {
useEffect(() => {
if ('__REDUX_DEVTOOLS_EXTENSION__' in window === false) return;
const devtools = window.__REDUX_DEVTOOLS_EXTENSION__.connect({});
const unsub = devtools.subscribe((_message: any) => {
// console.log(message);
});
devtools.init();
subscribe(cache.cache, () => devtools.send('change', snapshot(cache.cache)));
return () => {
unsub();
window.__REDUX_DEVTOOLS_EXTENSION__.disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const queryClient = useQueryClient();
useEffect(() => {
const interval = setInterval(() => {
const permits = getPermits();
if (permits !== 0) {
console.warn('Not safe to cleanup cache. ${permits} permits currently held.');
return;
}
const requiredKeys = new StableSet<[string, string]>();
for (const query of queryClient.getQueryCache().getAll()) {
if (query.state.data) scanDataForKeys(cache.cache, requiredKeys, query.state.data);
}
const existingKeys = new StableSet<[string, string]>();
Object.entries(cache.cache.nodes).map(([type, value]) => {
Object.keys(value).map((id) => existingKeys.add([type, id]));
});
for (const [type, id] of existingKeys.entries()) {
// If key is not required. Eg. not in any query within the React Query cache.
if (!requiredKeys.has([type, id])) {
// Yeet the imposter
delete cache.cache.nodes?.[type]?.[id];
}
}
}, 60 * 1000);
return () => clearInterval(interval);
}, [cache.cache, queryClient]);
return <Context.Provider value={cache}>{children}</Context.Provider>;
}
export function useCacheContext() {
const context = useContext(Context);
if (!context) throw new Error('Missing `CacheContext` provider!');
return context;
}
function scanDataForKeys(cache: Store, keys: StableSet<[string, string]>, item: unknown) {
if (item === undefined || item === null) return;
if (Array.isArray(item)) {
for (const v of item) {
scanDataForKeys(cache, keys, v);
}
} else if (typeof item === 'object') {
if ('__type' in item && '__id' in item) {
if (typeof item.__type !== 'string') throw new Error('Invalid `__type`');
if (typeof item.__id !== 'string') throw new Error('Invalid `__id`');
keys.add([item.__type, item.__id]);
const result = cache.nodes?.[item.__type]?.[item.__id];
if (result) scanDataForKeys(cache, keys, result);
}
for (const [_k, value] of Object.entries(item)) {
scanDataForKeys(cache, keys, value);
}
}
}
function restore(
cache: Store,
subscribed: Map<string, Set<unknown>>,
item: unknown,
suffix?: string
): unknown {
if (item === undefined || item === null) {
return item;
} else if (Array.isArray(item)) {
return item.map((v) => restore(cache, subscribed, v));
} else if (typeof item === 'object') {
if ('__type' in item && '__id' in item) {
if (typeof item.__type !== 'string') throw new Error('Invalid `__type`');
if (typeof item.__id !== 'string') throw new Error('Invalid `__id`');
const ty = suffix ? `${suffix}:${item.__type}` : item.__type;
const result = cache.nodes?.[ty]?.[item.__id];
if (!result) throw new Error(`Missing node for id '${item.__id}' of type '${ty}'`);
const v = subscribed.get(ty);
if (v) {
v.add(item.__id);
} else {
subscribed.set(ty, new Set([item.__id]));
}
// We call restore again for arrays and objects to deal with nested relations.
return Object.fromEntries(
Object.entries(result).map(([key, value]) => [
key,
restore(cache, subscribed, value)
])
);
}
return Object.fromEntries(
Object.entries(item).map(([key, value]) => [key, restore(cache, subscribed, value)])
);
}
return item;
}
export function useNodes(data: CacheNode[] | undefined) {
const cache = useCacheContext();
// `useMemo` instead of `useEffect` here is cursed but it needs to run before the `useMemo` in the `useCache` hook.
useMemo(() => {
updateNodes(cache.cache, data);
}, [cache, data]);
}
// Methods to interact with the cache outside of the React lifecycle.
export function useNormalisedCache() {
const cache = useCacheContext();
return {
'#cache': cache.cache,
'withNodes': cache.withNodes,
'withCache': cache.withCache
};
}
function updateNodes(cache: Store, data: CacheNode[] | undefined, suffix?: string) {
if (!data) return;
for (const item of data) {
if (!('__type' in item && '__id' in item)) throw new Error('Missing `__type` or `__id`');
if (typeof item.__type !== 'string') throw new Error('Invalid `__type`');
if (typeof item.__id !== 'string') throw new Error('Invalid `__id`');
const ty = suffix ? `${suffix}:${item.__type}` : item.__type;
const copy = { ...item } as any;
delete copy.__type;
delete copy.__id;
const original = cache.nodes?.[ty]?.[item.__id];
specialMerge(copy, original);
if (!cache.nodes[ty]) cache.nodes[ty] = {};
// TODO: This should be a deepmerge but that would break stuff like `size_in_bytes` or `inode` as the arrays are joined.
cache.nodes[ty]![item.__id] = copy;
}
}
// When using PCR's data structure if you don't fetch a relation `null` is returned.
// If two queries return a single entity but one fetches relations and the other doesn't that null might "win" over the actual data.
// Once it "wins" the normalised cache is updated causing all `useCache`'s to rerun.
//
// The `useCache` hook derives the type from the specific React Query operation.
// Due to this the result of a `useCache` might end up as `null` even when TS says it's `T` causing crashes due to no-null checks.
//
// So this merge function causes the `null` to be replaced with the original value.
function specialMerge(copy: Record<any, any>, original: unknown) {
if (
original &&
typeof original === 'object' &&
typeof copy === 'object' &&
!Array.isArray(original) &&
!Array.isArray(copy)
) {
for (const [property, value] of Object.entries(original)) {
copy[property] = copy[property] || value;
if (typeof copy[property] === 'object' && !Array.isArray(copy[property]))
specialMerge(copy[property], value);
}
}
}
export type UseCacheResult<T> = T extends (infer A)[]
? UseCacheResult<A>[]
: T extends object
? T extends { '__type': any; '__id': string; '#type': infer U }
? UseCacheResult<U>
: { [K in keyof T]: UseCacheResult<T[K]> }
: { [K in keyof T]: UseCacheResult<T[K]> };
export function useCache<T>(data: T | undefined) {
const cache = useCacheContext();
const subscribed = useRef(new Map<string, Set<unknown>>()).current;
const [i, setI] = useState(0); // TODO: Remove this
const state = useMemo(
() => restore(cache.cache, subscribed, data) as UseCacheResult<T>,
// eslint-disable-next-line react-hooks/exhaustive-deps
[cache, data, i]
);
return useSyncExternalStore(
(onStoreChange) => {
return subscribe(cache.cache, (ops) => {
for (const [_, key] of ops) {
const key_type = key[1] as string;
const key_id = key[2] as string;
const v = subscribed.get(key_type);
if (v && v.has(key_id)) {
setI((i) => i + 1);
onStoreChange();
break; // We only need to trigger re-render once so we can break
}
}
});
},
() => state
);
}
class StableSet<T> {
set = new Set<string>();
get size() {
return this.set.size;
}
add(value: T) {
this.set.add(JSON.stringify(value));
}
has(value: T) {
return this.set.has(JSON.stringify(value));
}
*entries() {
for (const v of this.set) {
yield JSON.parse(v);
}
}
}

View file

@ -11,7 +11,7 @@ export type Procedures = {
{ key: "cloud.library.list", input: never, result: CloudLibrary[] } |
{ key: "cloud.locations.list", input: never, result: CloudLocation[] } |
{ key: "ephemeralFiles.getMediaData", input: string, result: ({ type: "Image" } & ImageMetadata) | ({ type: "Video" } & VideoMetadata) | ({ type: "Audio" } & AudioMetadata) | null } |
{ key: "files.get", input: LibraryArgs<number>, result: { item: Reference<ObjectWithFilePaths2>; nodes: CacheNode[] } | null } |
{ key: "files.get", input: LibraryArgs<number>, result: { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null; file_paths: ({ id: number; pub_id: number[]; is_dir: boolean | null; cas_id: string | null; integrity_checksum: string | null; location_id: number | null; materialized_path: string | null; name: string | null; extension: string | null; hidden: boolean | null; size_in_bytes: string | null; size_in_bytes_bytes: number[] | null; inode: number[] | null; object_id: number | null; key_id: number | null; date_created: string | null; date_modified: string | null; date_indexed: string | null; object: { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null; media_data: { resolution: number[] | null; media_date: number[] | null; media_location: number[] | null; camera_data: number[] | null; artist: string | null; description: string | null; copyright: string | null; exif_version: string | null } | null } | null })[] } | null } |
{ key: "files.getConvertibleImageExtensions", input: never, result: string[] } |
{ key: "files.getMediaData", input: LibraryArgs<number>, result: MediaMetadata } |
{ key: "files.getPath", input: LibraryArgs<number>, result: string | null } |
@ -25,14 +25,14 @@ export type Procedures = {
{ key: "labels.list", input: LibraryArgs<null>, result: Label[] } |
{ key: "labels.listWithThumbnails", input: LibraryArgs<string>, result: ExplorerItem[] } |
{ key: "library.kindStatistics", input: LibraryArgs<null>, result: KindStatistics } |
{ key: "library.list", input: never, result: NormalisedResults<LibraryConfigWrapped> } |
{ key: "library.list", input: never, result: LibraryConfigWrapped[] } |
{ key: "library.statistics", input: LibraryArgs<null>, result: StatisticsResponse } |
{ key: "locations.get", input: LibraryArgs<number>, result: { item: Reference<Location>; nodes: CacheNode[] } | null } |
{ key: "locations.getWithRules", input: LibraryArgs<number>, result: { item: Reference<LocationWithIndexerRule>; nodes: CacheNode[] } | null } |
{ key: "locations.indexer_rules.get", input: LibraryArgs<number>, result: NormalisedResult<IndexerRule> } |
{ key: "locations.indexer_rules.list", input: LibraryArgs<null>, result: NormalisedResults<IndexerRule> } |
{ key: "locations.indexer_rules.listForLocation", input: LibraryArgs<number>, result: NormalisedResults<IndexerRule> } |
{ key: "locations.list", input: LibraryArgs<null>, result: NormalisedResults<Location> } |
{ key: "locations.get", input: LibraryArgs<number>, result: { id: number; pub_id: number[]; name: string | null; path: string | null; total_capacity: number | null; available_capacity: number | null; size_in_bytes: number[] | null; is_archived: boolean | null; generate_preview_media: boolean | null; sync_preview_media: boolean | null; hidden: boolean | null; date_created: string | null; scan_state: number; instance_id: number | null } | null } |
{ key: "locations.getWithRules", input: LibraryArgs<number>, result: { id: number; pub_id: number[]; name: string | null; path: string | null; total_capacity: number | null; available_capacity: number | null; size_in_bytes: number[] | null; is_archived: boolean | null; generate_preview_media: boolean | null; sync_preview_media: boolean | null; hidden: boolean | null; date_created: string | null; instance_id: number | null; indexer_rules: IndexerRule[] } | null } |
{ key: "locations.indexer_rules.get", input: LibraryArgs<number>, result: IndexerRule } |
{ key: "locations.indexer_rules.list", input: LibraryArgs<null>, result: IndexerRule[] } |
{ key: "locations.indexer_rules.listForLocation", input: LibraryArgs<number>, result: IndexerRule[] } |
{ key: "locations.list", input: LibraryArgs<null>, result: Location[] } |
{ key: "locations.systemLocations", input: never, result: SystemLocations } |
{ key: "models.image_detection.list", input: never, result: string[] } |
{ key: "nodeState", input: never, result: NodeState } |
@ -51,11 +51,11 @@ export type Procedures = {
{ key: "search.saved.list", input: LibraryArgs<null>, result: SavedSearch[] } |
{ key: "sync.enabled", input: LibraryArgs<null>, result: boolean } |
{ key: "sync.messages", input: LibraryArgs<null>, result: CRDTOperation[] } |
{ key: "tags.get", input: LibraryArgs<number>, result: { item: Reference<Tag>; nodes: CacheNode[] } | null } |
{ key: "tags.getForObject", input: LibraryArgs<number>, result: NormalisedResults<Tag> } |
{ key: "tags.get", input: LibraryArgs<number>, result: { id: number; pub_id: number[]; name: string | null; color: string | null; is_hidden: boolean | null; date_created: string | null; date_modified: string | null } | null } |
{ key: "tags.getForObject", input: LibraryArgs<number>, result: Tag[] } |
{ key: "tags.getWithObjects", input: LibraryArgs<number[]>, result: { [key in number]: ({ date_created: string | null; object: { id: number } })[] } } |
{ key: "tags.list", input: LibraryArgs<null>, result: NormalisedResults<Tag> } |
{ key: "volumes.list", input: never, result: NormalisedResults<Volume> },
{ key: "tags.list", input: LibraryArgs<null>, result: Tag[] } |
{ key: "volumes.list", input: never, result: Volume[] },
mutations:
{ key: "api.sendFeedback", input: Feedback, result: null } |
{ key: "auth.logout", input: never, result: null } |
@ -100,7 +100,7 @@ export type Procedures = {
{ key: "jobs.pause", input: LibraryArgs<string>, result: null } |
{ key: "jobs.resume", input: LibraryArgs<string>, result: null } |
{ key: "labels.delete", input: LibraryArgs<number>, result: null } |
{ key: "library.create", input: CreateLibraryArgs, result: NormalisedResult<LibraryConfigWrapped> } |
{ key: "library.create", input: CreateLibraryArgs, result: LibraryConfigWrapped } |
{ key: "library.delete", input: string, result: null } |
{ key: "library.edit", input: EditLibraryArgs, result: null } |
{ key: "library.startActor", input: LibraryArgs<string>, result: null } |
@ -166,8 +166,6 @@ export type CRDTOperation = { instance: string; timestamp: number; model: number
export type CRDTOperationData = { c: { [key in string]: JsonValue } } | { u: { field: string; value: JsonValue } } | "d"
export type CacheNode = { __type: string; __id: string; "#node": any }
export type CameraData = { device_make: string | null; device_model: string | null; color_space: string | null; color_profile: ColorProfile | null; focal_length: number | null; shutter_speed: number | null; flash: Flash | null; orientation: Orientation; lens_make: string | null; lens_model: string | null; bit_depth: number | null; red_eye: boolean | null; zoom: number | null; iso: number | null; software: string | null; serial_number: string | null; lens_serial_number: string | null; contrast: number | null; saturation: number | null; sharpness: number | null; composite: Composite | null }
export type ChangeNodeNameArgs = { name: string | null; p2p_port: Port | null; p2p_ipv4_enabled: boolean | null; p2p_ipv6_enabled: boolean | null; p2p_discovery: P2PDiscoveryState | null; p2p_remote_access: boolean | null; image_labeler_version: string | null }
@ -242,7 +240,7 @@ export type EphemeralPathOrder = { field: "name"; value: SortOrder } | { field:
export type EphemeralPathSearchArgs = { path: string; withHiddenFiles: boolean; order?: EphemeralPathOrder | null }
export type EphemeralPathsResultItem = { entries: Reference<ExplorerItem>[]; errors: Error[]; nodes: CacheNode[] }
export type EphemeralPathsResultItem = { entries: ExplorerItem[]; errors: Error[] }
export type EphemeralRenameFileArgs = { kind: EphemeralRenameKind }
@ -444,8 +442,6 @@ export type LocationSettings = { explorer: ExplorerSettings<FilePathOrder> }
*/
export type LocationUpdateArgs = { id: number; name: string | null; generate_preview_media: boolean | null; sync_preview_media: boolean | null; hidden: boolean | null; indexer_rules_ids: number[]; path: string | null }
export type LocationWithIndexerRule = { id: number; pub_id: number[]; name: string | null; path: string | null; total_capacity: number | null; available_capacity: number | null; size_in_bytes: number[] | null; is_archived: boolean | null; generate_preview_media: boolean | null; sync_preview_media: boolean | null; hidden: boolean | null; date_created: string | null; instance_id: number | null; indexer_rules: Reference<IndexerRule>[] }
export type MaybeUndefined<T> = null | T
export type MediaDataOrder = { field: "epochTime"; value: SortOrder }
@ -476,20 +472,6 @@ name: string; identity: RemoteIdentity; p2p: NodeConfigP2P; features: BackendFea
export type NonIndexedPathItem = { path: string; name: string; extension: string; kind: number; is_dir: boolean; date_created: string; date_modified: string; size_in_bytes_bytes: number[]; hidden: boolean }
/**
* A type that can be used to return a group of `Reference<T>` and `CacheNode`'s
*
* You don't need to use this, it's just a shortcut to avoid having to write out the full type every time.
*/
export type NormalisedResult<T> = { item: Reference<T>; nodes: CacheNode[] }
/**
* A type that can be used to return a group of `Reference<T>` and `CacheNode`'s
*
* You don't need to use this, it's just a shortcut to avoid having to write out the full type every time.
*/
export type NormalisedResults<T> = { items: Reference<T>[]; nodes: CacheNode[] }
/**
* Represents a single notification.
*/
@ -521,8 +503,6 @@ export type ObjectValidatorArgs = { id: number; path: string }
export type ObjectWithFilePaths = { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null; file_paths: ({ id: number; pub_id: number[]; is_dir: boolean | null; cas_id: string | null; integrity_checksum: string | null; location_id: number | null; materialized_path: string | null; name: string | null; extension: string | null; hidden: boolean | null; size_in_bytes: string | null; size_in_bytes_bytes: number[] | null; inode: number[] | null; object_id: number | null; key_id: number | null; date_created: string | null; date_modified: string | null; date_indexed: string | null; object: { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null; media_data: { resolution: number[] | null; media_date: number[] | null; media_location: number[] | null; camera_data: number[] | null; artist: string | null; description: string | null; copyright: string | null; exif_version: string | null } | null } | null })[] }
export type ObjectWithFilePaths2 = { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null; file_paths: Reference<FilePath>[] }
export type OldFileCopierJobInit = { source_location_id: number; target_location_id: number; sources_file_path_ids: number[]; target_location_relative_directory_path: string }
export type OldFileCutterJobInit = { source_location_id: number; target_location_id: number; sources_file_path_ids: number[]; target_location_relative_directory_path: string }
@ -553,16 +533,6 @@ export type Port = { type: "random" } | { type: "discrete"; value: number }
export type Range<T> = { from: T } | { to: T }
/**
* A reference to a `CacheNode`.
*
* This does not contain the actual data, but instead a reference to it.
* This allows the CacheNode's to be switched out and the query recomputed without any backend communication.
*
* If you use a `Reference` in a query, you *must* ensure the corresponding `CacheNode` is also in the query.
*/
export type Reference<T> = { __type: string; __id: string; "#type": T }
export type RemoteIdentity = string
export type RenameFileArgs = { location_id: number; kind: RenameKind }
@ -583,7 +553,7 @@ export type RuleKind = "AcceptFilesByGlob" | "RejectFilesByGlob" | "AcceptIfChil
export type SavedSearch = { id: number; pub_id: number[]; target: string | null; search: string | null; filters: string | null; name: string | null; icon: string | null; description: string | null; date_created: string | null; date_modified: string | null }
export type SearchData<T> = { cursor: number[] | null; items: Reference<T>[]; nodes: CacheNode[] }
export type SearchData<T> = { cursor: number[] | null; items: T[] }
export type SearchFilterArgs = { filePath: FilePathFilterArgs } | { object: ObjectFilterArgs }

View file

@ -1,7 +1,6 @@
import { UseInfiniteQueryResult, UseQueryResult } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { useCache } from '../cache';
import { SearchData } from '../core';
export function useExplorerQuery<Q>(
@ -16,7 +15,7 @@ export function useExplorerQuery<Q>(
}
}, [query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]);
return { query, items: useCache(items), loadMore, count: count.data };
return { query, items, loadMore, count: count.data };
}
export type UseExplorerQuery<Q> = ReturnType<typeof useExplorerQuery<Q>>;

View file

@ -1,7 +1,6 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useNodes } from '../cache';
import { ExplorerItem, ObjectCursor, ObjectOrder, ObjectSearchArgs } from '../core';
import { useLibraryContext } from '../hooks';
import { useRspcLibraryContext } from '../rspc';
@ -57,17 +56,10 @@ export function useObjectsInfiniteQuery({
},
getNextPageParam: (lastPage) => {
if (lastPage.items.length < arg.take) return undefined;
else return lastPage.nodes[arg.take - 1];
else return lastPage.items[arg.take - 1];
},
...args
});
const nodes = useMemo(
() => query.data?.pages.flatMap((page) => page.nodes) ?? [],
[query.data?.pages]
);
useNodes(nodes);
return query;
}

View file

@ -1,7 +1,6 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useNodes, useNormalisedCache } from '../cache';
import { ObjectOrder, ObjectSearchArgs } from '../core';
import { useLibraryContext } from '../hooks';
import { useRspcLibraryContext } from '../rspc';
@ -14,7 +13,6 @@ export function useObjectsOffsetInfiniteQuery({
}: UseExplorerInfiniteQueryArgs<ObjectSearchArgs, ObjectOrder>) {
const { library } = useLibraryContext();
const ctx = useRspcLibraryContext();
const cache = useNormalisedCache();
if (order) {
arg.orderAndPagination = { orderOnly: order };
@ -39,22 +37,14 @@ export function useObjectsOffsetInfiniteQuery({
arg.orderAndPagination = orderAndPagination;
const result = await ctx.client.query(['search.objects', arg]);
cache.withNodes(result.nodes);
return { ...result, offset: pageParam, arg };
},
getNextPageParam: ({ nodes, offset, arg }) => {
if (nodes.length >= arg.take) return (offset ?? 0) + 1;
getNextPageParam: ({ items, offset, arg }) => {
if (items.length >= arg.take) return (offset ?? 0) + 1;
},
...args
});
const nodes = useMemo(
() => query.data?.pages.flatMap((page) => page.nodes) ?? [],
[query.data?.pages]
);
useNodes(nodes);
return query;
}

View file

@ -1,7 +1,5 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useNodes, useNormalisedCache } from '../cache';
import {
ExplorerItem,
FilePathCursorVariant,
@ -21,7 +19,6 @@ export function usePathsInfiniteQuery({
}: UseExplorerInfiniteQueryArgs<FilePathSearchArgs, FilePathOrder>) {
const { library } = useLibraryContext();
const ctx = useRspcLibraryContext();
const cache = useNormalisedCache();
if (order) {
arg.orderAndPagination = { orderOnly: order };
@ -122,24 +119,16 @@ export function usePathsInfiniteQuery({
arg.orderAndPagination = orderAndPagination;
const result = await ctx.client.query(['search.paths', arg]);
cache.withNodes(result.nodes);
return result;
},
getNextPageParam: (lastPage) => {
if (arg.take === null || arg.take === undefined) return undefined;
if (lastPage.items.length < arg.take) return undefined;
else return lastPage.nodes[arg.take - 1];
else return lastPage.items[arg.take - 1];
},
onSuccess,
...args
});
const nodes = useMemo(
() => query.data?.pages.flatMap((page) => page.nodes) ?? [],
[query.data?.pages]
);
useNodes(nodes);
return query;
}

View file

@ -1,7 +1,5 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useNodes, useNormalisedCache } from '../cache';
import { FilePathOrder, FilePathSearchArgs } from '../core';
import { useLibraryContext } from '../hooks';
import { useRspcLibraryContext } from '../rspc';
@ -17,7 +15,6 @@ export function usePathsOffsetInfiniteQuery({
const { library } = useLibraryContext();
const ctx = useRspcLibraryContext();
const cache = useNormalisedCache();
if (order) {
arg.orderAndPagination = { orderOnly: order };
@ -49,23 +46,15 @@ export function usePathsOffsetInfiniteQuery({
arg.orderAndPagination = orderAndPagination;
const result = await ctx.client.query(['search.paths', arg]);
cache.withNodes(result.nodes);
return { ...result, offset: pageParam, arg };
},
getNextPageParam: ({ nodes, offset, arg }) => {
if (nodes.length >= arg.take) return (offset ?? 0) + 1;
getNextPageParam: ({ items, offset, arg }) => {
if (items.length >= arg.take) return (offset ?? 0) + 1;
},
onSuccess,
...args
});
const nodes = useMemo(
() => query.data?.pages.flatMap((page) => page.nodes) ?? [],
[query.data?.pages]
);
useNodes(nodes);
return query;
}

View file

@ -1,13 +1,12 @@
import { AlphaClient } from '@oscartbeaumont-sd/rspc-client/v2';
import { createContext, PropsWithChildren, useContext, useEffect, useMemo } from 'react';
import { NormalisedCache, useCache, useNodes } from '../cache';
import { LibraryConfigWrapped, Procedures } from '../core';
import { valtioPersist } from '../lib';
import { useBridgeQuery } from '../rspc';
// The name of the localStorage key for caching library data
const libraryCacheLocalStorageKey = 'sd-library-list2'; // `2` is because the format of this underwent a breaking change when introducing normalised caching
const libraryCacheLocalStorageKey = 'sd-library-list3'; // number is because the format of this underwent breaking changes
export const useCachedLibraries = () => {
const result = useBridgeQuery(['library.list'], {
@ -27,36 +26,27 @@ export const useCachedLibraries = () => {
return undefined;
},
onSuccess: (data) => {
if (data.items.length > 0 || data.nodes.length > 0)
if (data.length > 0)
localStorage.setItem(libraryCacheLocalStorageKey, JSON.stringify(data));
}
});
useNodes(result.data?.nodes);
return {
...result,
data: useCache(result.data?.items)
};
return result;
};
export async function getCachedLibraries(cache: NormalisedCache, client: AlphaClient<Procedures>) {
export async function getCachedLibraries(client: AlphaClient<Procedures>) {
const cachedData = localStorage.getItem(libraryCacheLocalStorageKey);
const libraries = client.query(['library.list']).then((result) => {
cache.withNodes(result.nodes);
const libraries = cache.withCache(result.items);
localStorage.setItem(libraryCacheLocalStorageKey, JSON.stringify(result));
return libraries;
return result;
});
if (cachedData) {
// If we fail to load cached data, it's fine
try {
const data = JSON.parse(cachedData);
cache.withNodes(data.nodes);
return cache.withCache(data.items) as LibraryConfigWrapped[];
return data as LibraryConfigWrapped[];
} catch (e) {
console.error("Error loading cached 'sd-library-list' data", e);
}

View file

@ -30,7 +30,6 @@ export * from './core';
export * from './utils';
export * from './lib';
export * from './form';
export * from './cache';
export * from './color';
export * from './solid';
export * from './explorer';

View file

@ -131,24 +131,8 @@ export function formatNumber(n: number) {
export function insertLibrary(queryClient: QueryClient, library: LibraryConfigWrapped) {
queryClient.setQueryData(['library.list'], (libraries: any) => {
// The invalidation system beat us to it
if (libraries.items.find((l: any) => l.__id === library.uuid)) return libraries;
if ((libraries || []).find((l: any) => l.uuid === library.uuid)) return libraries;
return {
items: [
...(libraries.items || []),
{
__type: 'LibraryConfigWrapped',
__id: library.uuid
}
],
nodes: [
...(libraries.nodes || []),
{
__type: 'LibraryConfigWrapped',
__id: library.uuid,
...library
}
]
};
return [library, ...libraries];
});
}