From 89a7f735e512ef4bfe3bcfd46cdc3d696b727618 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Tue, 5 Dec 2023 21:16:03 +0800 Subject: [PATCH] [ENG-1400] Normalised caching (#1734) * prototype * `.normalise` helper + only `String` keys * implement it for 'search.paths' * redux devtools * fix * refactor backend * wip: upgrade to rspc fork * mega cursed * Upgrade Specta-related stuff * Upgrade Typescript * Cache debug page * bruh * Fix optimistic library setting * Cache clearing * better timeout * Fix tags page * bit of cleanup --------- Co-authored-by: Brendan Allan --- .gitignore | 1 - .vscode/settings.json | 3 +- Cargo.lock | Bin 237513 -> 243526 bytes Cargo.toml | 14 +- apps/desktop/src-tauri/src/main.rs | 63 ++-- apps/desktop/src/App.tsx | 158 ++++----- apps/desktop/src/commands.ts | 310 +++++++++++++----- apps/desktop/src/platform.ts | 2 +- apps/desktop/src/updater.tsx | 12 +- .../src/components/drawer/DrawerLocations.tsx | 6 +- .../src/components/drawer/DrawerTags.tsx | 6 +- .../explorer/sections/InfoTagPills.tsx | 8 +- .../components/modal/CreateLibraryModal.tsx | 20 +- .../modal/inspector/FileInfoModal.tsx | 6 +- apps/mobile/src/screens/Location.tsx | 14 +- apps/mobile/src/screens/Search.tsx | 13 +- apps/mobile/src/screens/Tag.tsx | 12 +- .../mobile/src/screens/onboarding/context.tsx | 17 +- .../settings/client/LibrarySettings.tsx | 6 +- .../settings/library/EditLocationSettings.tsx | 10 +- .../settings/library/LocationSettings.tsx | 6 +- .../screens/settings/library/TagsSettings.tsx | 6 +- apps/web/src/App.tsx | 40 ++- core/Cargo.toml | 40 +-- core/src/api/backups.rs | 9 +- core/src/api/categories.rs | 86 ++--- core/src/api/cloud.rs | 2 +- core/src/api/ephemeral_files.rs | 2 +- core/src/api/files.rs | 76 ++++- core/src/api/keys.rs | 2 + core/src/api/libraries.rs | 21 +- core/src/api/locations.rs | 134 +++++++- core/src/api/mod.rs | 26 +- core/src/api/notifications.rs | 4 + core/src/api/p2p.rs | 16 +- core/src/api/search/mod.rs | 38 ++- core/src/api/tags.rs | 35 +- core/src/api/utils/invalidate.rs | 2 +- core/src/api/volumes.rs | 20 +- core/src/p2p/peer_metadata.rs | 6 +- core/src/volume/mod.rs | 7 + crates/cache/Cargo.toml | 11 + crates/cache/src/lib.rs | 198 +++++++++++ crates/p2p/src/spacetunnel/identity.rs | 14 +- crates/prisma/Cargo.toml | 1 + crates/prisma/src/lib.rs | 30 ++ .../ContextMenu/AssignTagMenuItems.tsx | 13 +- .../$libraryId/Explorer/Inspector/index.tsx | 21 +- .../Explorer/queries/useExplorerQuery.ts | 4 +- .../queries/useObjectsInfiniteQuery.ts | 13 +- .../Explorer/queries/usePathsInfiniteQuery.ts | 21 +- .../Layout/Sidebar/DebugPopover.tsx | 5 + .../Layout/Sidebar/EphemeralSection.tsx | 20 +- .../Layout/Sidebar/LibrarySection.tsx | 14 +- interface/app/$libraryId/Search/Filters.tsx | 18 +- interface/app/$libraryId/debug.tsx | 46 --- interface/app/$libraryId/debug/cache.tsx | 13 + interface/app/$libraryId/debug/index.ts | 5 + interface/app/$libraryId/ephemeral.tsx | 10 +- interface/app/$libraryId/index.tsx | 5 +- interface/app/$libraryId/location/$id.tsx | 8 +- interface/app/$libraryId/saved-search/$id.tsx | 8 +- .../app/$libraryId/settings/client/usage.tsx | 9 +- .../settings/library/locations/$id.tsx | 19 +- .../library/locations/AddLocationDialog.tsx | 10 +- .../locations/IndexerRuleEditor/index.tsx | 5 +- .../settings/library/locations/index.tsx | 10 +- .../app/$libraryId/settings/library/nodes.tsx | 21 +- .../settings/library/tags/index.tsx | 15 +- .../settings/node/libraries/CreateDialog.tsx | 18 +- .../settings/node/libraries/index.tsx | 8 +- interface/app/$libraryId/sync.tsx | 6 +- interface/app/$libraryId/tag/$id.tsx | 18 +- interface/app/index.tsx | 14 +- interface/app/onboarding/context.tsx | 15 +- interface/components/Devtools.tsx | 24 ++ interface/hooks/useIsLocationIndexing.ts | 2 +- interface/hooks/useRedirectToNewLocation.ts | 2 +- interface/index.tsx | 22 +- packages/client/src/cache.tsx | 251 ++++++++++++++ packages/client/src/core.ts | 215 ++++++++---- .../client/src/hooks/useClientContext.tsx | 28 +- packages/client/src/index.ts | 1 + packages/client/src/rspc.tsx | 2 +- packages/client/src/utils/explorerItem.ts | 3 +- packages/client/src/utils/index.ts | 29 +- packages/client/src/utils/jobs/index.ts | 2 +- packages/client/src/utils/jobs/useJobInfo.tsx | 4 +- 88 files changed, 1844 insertions(+), 646 deletions(-) create mode 100644 crates/cache/Cargo.toml create mode 100644 crates/cache/src/lib.rs delete mode 100644 interface/app/$libraryId/debug.tsx create mode 100644 interface/app/$libraryId/debug/cache.tsx create mode 100644 interface/app/$libraryId/debug/index.ts create mode 100644 interface/components/Devtools.tsx create mode 100644 packages/client/src/cache.tsx diff --git a/.gitignore b/.gitignore index 9641838ee..2fba6111f 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,6 @@ cli/turbo-new.exe cli/turbo.exe storybook-static/ .DS_Store -cache .env* vendor/ data diff --git a/.vscode/settings.json b/.vscode/settings.json index 3190ae975..9033eae85 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,9 +10,11 @@ "dotenv", "dotenvy", "fontsource", + "ianvs", "ipfs", "Keepsafe", "nodestate", + "normalise", "overscan", "pathctx", "prismjs", @@ -26,7 +28,6 @@ "tailwindcss", "tanstack", "titlebar", - "ianvs", "tsparticles", "unlisten", "upsert", diff --git a/Cargo.lock b/Cargo.lock index da214333ed05842980ad53c5b2b039fc451e257f..f72c1a47d8e5b9d3306a6b2f17fad7816bcf6e7c 100644 GIT binary patch delta 19460 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

#hK!{Jl%)Z<9k|M-X#pKdjqHq!Sup9* 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 R$?NfA3<>) -> Result<(), ()> { }) } -// TODO(@Oscar): A helper like this should probs exist in tauri-specta -macro_rules! tauri_handlers { - ($($name:path),+) => {{ - #[cfg(debug_assertions)] - tauri_specta::ts::export(specta::collect_types![$($name),+], "../src/commands.ts").unwrap(); - - tauri::generate_handler![$($name),+] - }}; -} - const CLIENT_ID: &str = "2abb241e-40b8-4517-a3e3-5594375c8fbb"; #[tokio::main] @@ -219,9 +210,41 @@ async fn main() -> tauri::Result<()> { } }); + let specta_builder = { + let specta_builder = ts::builder() + .commands(tauri_specta::collect_commands![ + app_ready, + reset_spacedrive, + open_logs_dir, + refresh_menu_bar, + reload_webview, + set_menu_bar_item_state, + request_fda_macos, + file::open_file_paths, + file::open_ephemeral_files, + file::get_file_path_open_with_apps, + file::get_ephemeral_files_open_with_apps, + file::open_file_path_with, + file::open_ephemeral_file_with, + file::reveal_items, + theme::lock_app_theme, + // TODO: move to plugin w/tauri-specta + updater::check_for_update, + updater::install_update + ]) + // .events(tauri_specta::collect_events![]) + .config(specta::ts::ExportConfig::default().formatter(specta::ts::formatter::prettier)); + + #[cfg(debug_assertions)] + let specta_builder = specta_builder.path("../src/commands.ts"); + + specta_builder.into_plugin() + }; + let app = app .plugin(updater::plugin()) .plugin(tauri_plugin_window_state::Builder::default().build()) + .plugin(specta_builder) .setup(move |app| { let app = app.handle(); @@ -300,26 +323,6 @@ async fn main() -> tauri::Result<()> { }) .menu(menu::get_menu()) .manage(updater::State::default()) - .invoke_handler(tauri_handlers![ - app_ready, - reset_spacedrive, - open_logs_dir, - refresh_menu_bar, - reload_webview, - set_menu_bar_item_state, - request_fda_macos, - file::open_file_paths, - file::open_ephemeral_files, - file::get_file_path_open_with_apps, - file::get_ephemeral_files_open_with_apps, - file::open_file_path_with, - file::open_ephemeral_file_with, - file::reveal_items, - theme::lock_app_theme, - // TODO: move to plugin w/tauri-specta - updater::check_for_update, - updater::install_update - ]) .build(tauri::generate_context!())?; app.run(|_, _| {}); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index cc680e652..536cfa256 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -4,7 +4,7 @@ import { listen } from '@tauri-apps/api/event'; import { appWindow } from '@tauri-apps/api/window'; import { startTransition, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { RspcProvider } from '@sd/client'; +import { CacheProvider, createCache, RspcProvider } from '@sd/client'; import { createRoutes, ErrorPage, @@ -19,7 +19,7 @@ import { getSpacedropState } from '@sd/interface/hooks/useSpacedropState'; import '@sd/ui/style/style.scss'; -import * as commands from './commands'; +import { commands } from './commands'; import { platform } from './platform'; import { queryClient } from './query'; import { createMemoryRouterWithHistory } from './router'; @@ -80,7 +80,9 @@ 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 routes = createRoutes(platform); +const cache = createCache(); + +const routes = createRoutes(platform, cache); function AppInner() { const [tabs, setTabs] = useState(() => [createTab()]); @@ -142,84 +144,86 @@ function AppInner() { }, [tab.element]); return ( - ({ - setTitle(title) { - setTabs((oldTabs) => { - const tabs = [...oldTabs]; - const tab = tabs[tabIndex]; - if (!tab) return tabs; - - tabs[tabIndex] = { ...tab, title }; - - return tabs; - }); - } - }), - [tabIndex] - )} - > - ({ router, title })), - createTab() { - createTabPromise.current = createTabPromise.current.then( - () => - new Promise((res) => { - startTransition(() => { - setTabs((tabs) => { - const newTabs = [...tabs, createTab()]; - - setTabIndex(newTabs.length - 1); - - return newTabs; - }); - }); - - setTimeout(res, TAB_CREATE_DELAY); - }) - ); - }, - removeTab(index: number) { - startTransition(() => { - setTabs((tabs) => { - const tab = tabs[index]; + + ({ + setTitle(title) { + setTabs((oldTabs) => { + const tabs = [...oldTabs]; + const tab = tabs[tabIndex]; if (!tab) return tabs; - tab.dispose(); + tabs[tabIndex] = { ...tab, title }; - tabs.splice(index, 1); - - setTabIndex(Math.min(tabIndex, tabs.length - 1)); - - return [...tabs]; + return tabs; }); - }); - } - }} + } + }), + [tabIndex] + )} > - - {tabs.map((tab) => - createPortal( - , - tab.element - ) - )} -

- - - + ({ router, title })), + createTab() { + createTabPromise.current = createTabPromise.current.then( + () => + new Promise((res) => { + startTransition(() => { + setTabs((tabs) => { + const newTabs = [...tabs, createTab()]; + + setTabIndex(newTabs.length - 1); + + return newTabs; + }); + }); + + setTimeout(res, TAB_CREATE_DELAY); + }) + ); + }, + removeTab(index: number) { + startTransition(() => { + setTabs((tabs) => { + const tab = tabs[index]; + if (!tab) return tabs; + + tab.dispose(); + + tabs.splice(index, 1); + + setTabIndex(Math.min(tabIndex, tabs.length - 1)); + + return [...tabs]; + }); + }); + } + }} + > + + {tabs.map((tab) => + createPortal( + , + tab.element + ) + )} +
+ + + + ); } diff --git a/apps/desktop/src/commands.ts b/apps/desktop/src/commands.ts index 69b428610..d3860a850 100644 --- a/apps/desktop/src/commands.ts +++ b/apps/desktop/src/commands.ts @@ -1,86 +1,230 @@ -/* eslint-disable */ +/** tauri-specta globals **/ + +import { invoke as TAURI_INVOKE } from '@tauri-apps/api'; +import * as TAURI_API_EVENT from '@tauri-apps/api/event'; +import { type WebviewWindowHandle as __WebviewWindowHandle__ } from '@tauri-apps/api/window'; + // This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually. -declare global { - interface Window { - __TAURI_INVOKE__(cmd: string, args?: Record): Promise; - } +export const commands = { + async appReady(): Promise { + return await TAURI_INVOKE('plugin:tauri-specta|app_ready'); + }, + async resetSpacedrive(): Promise { + return await TAURI_INVOKE('plugin:tauri-specta|reset_spacedrive'); + }, + async openLogsDir(): Promise<__Result__> { + try { + return { status: 'ok', data: await TAURI_INVOKE('plugin:tauri-specta|open_logs_dir') }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: 'error', error: e as any }; + } + }, + async refreshMenuBar(): Promise<__Result__> { + try { + return { + status: 'ok', + data: await TAURI_INVOKE('plugin:tauri-specta|refresh_menu_bar') + }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: 'error', error: e as any }; + } + }, + async reloadWebview(): Promise { + return await TAURI_INVOKE('plugin:tauri-specta|reload_webview'); + }, + async setMenuBarItemState(id: string, enabled: boolean): Promise { + return await TAURI_INVOKE('plugin:tauri-specta|set_menu_bar_item_state', { id, enabled }); + }, + async requestFdaMacos(): Promise { + return await TAURI_INVOKE('plugin:tauri-specta|request_fda_macos'); + }, + async openFilePaths( + library: string, + ids: number[] + ): Promise< + __Result__< + ( + | { t: 'NoLibrary' } + | { t: 'NoFile'; c: number } + | { t: 'OpenError'; c: [number, string] } + | { t: 'AllGood'; c: number } + | { t: 'Internal'; c: string } + )[], + null + > + > { + try { + return { + status: 'ok', + data: await TAURI_INVOKE('plugin:tauri-specta|open_file_paths', { library, ids }) + }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: 'error', error: e as any }; + } + }, + async openEphemeralFiles( + paths: string[] + ): Promise<__Result__<({ t: 'Ok'; c: string } | { t: 'Err'; c: string })[], null>> { + try { + return { + status: 'ok', + data: await TAURI_INVOKE('plugin:tauri-specta|open_ephemeral_files', { paths }) + }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: 'error', error: e as any }; + } + }, + async getFilePathOpenWithApps( + library: string, + ids: number[] + ): Promise<__Result__<{ url: string; name: string }[], null>> { + try { + return { + status: 'ok', + data: await TAURI_INVOKE('plugin:tauri-specta|get_file_path_open_with_apps', { + library, + ids + }) + }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: 'error', error: e as any }; + } + }, + async getEphemeralFilesOpenWithApps( + paths: string[] + ): Promise<__Result__<{ url: string; name: string }[], null>> { + try { + return { + status: 'ok', + data: await TAURI_INVOKE('plugin:tauri-specta|get_ephemeral_files_open_with_apps', { + paths + }) + }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: 'error', error: e as any }; + } + }, + async openFilePathWith( + library: string, + fileIdsAndUrls: [number, string][] + ): Promise<__Result__> { + try { + return { + status: 'ok', + data: await TAURI_INVOKE('plugin:tauri-specta|open_file_path_with', { + library, + fileIdsAndUrls + }) + }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: 'error', error: e as any }; + } + }, + async openEphemeralFileWith(pathsAndUrls: [string, string][]): Promise<__Result__> { + try { + return { + status: 'ok', + data: await TAURI_INVOKE('plugin:tauri-specta|open_ephemeral_file_with', { + pathsAndUrls + }) + }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: 'error', error: e as any }; + } + }, + async revealItems(library: string, items: RevealItem[]): Promise<__Result__> { + try { + return { + status: 'ok', + data: await TAURI_INVOKE('plugin:tauri-specta|reveal_items', { library, items }) + }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: 'error', error: e as any }; + } + }, + async lockAppTheme(themeType: AppThemeType): Promise { + return await TAURI_INVOKE('plugin:tauri-specta|lock_app_theme', { themeType }); + }, + async checkForUpdate(): Promise< + __Result__<{ version: string; body: string | null } | null, string> + > { + try { + return { + status: 'ok', + data: await TAURI_INVOKE('plugin:tauri-specta|check_for_update') + }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: 'error', error: e as any }; + } + }, + async installUpdate(): Promise<__Result__> { + try { + return { status: 'ok', data: await TAURI_INVOKE('plugin:tauri-specta|install_update') }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: 'error', error: e as any }; + } + } +}; + +/** user-defined types **/ + +export type AppThemeType = 'Auto' | 'Light' | 'Dark'; +export type RevealItem = + | { Location: { id: number } } + | { FilePath: { id: number } } + | { Ephemeral: { path: string } }; + +type __EventObj__ = { + listen: (cb: TAURI_API_EVENT.EventCallback) => ReturnType>; + once: (cb: TAURI_API_EVENT.EventCallback) => ReturnType>; + emit: T extends null + ? (payload?: T) => ReturnType + : (payload: T) => ReturnType; +}; + +type __Result__ = { status: 'ok'; data: T } | { status: 'error'; error: E }; + +function __makeEvents__>(mappings: Record) { + return new Proxy( + {} as unknown as { + [K in keyof T]: __EventObj__ & { + (handle: __WebviewWindowHandle__): __EventObj__; + }; + }, + { + get: (_, event) => { + const name = mappings[event as keyof T]; + + return new Proxy((() => {}) as any, { + apply: (_, __, [window]: [__WebviewWindowHandle__]) => ({ + listen: (arg: any) => window.listen(name, arg), + once: (arg: any) => window.once(name, arg), + emit: (arg: any) => window.emit(name, arg) + }), + get: (_, command: keyof __EventObj__) => { + switch (command) { + case 'listen': + return (arg: any) => TAURI_API_EVENT.listen(name, arg); + case 'once': + return (arg: any) => TAURI_API_EVENT.once(name, arg); + case 'emit': + return (arg: any) => TAURI_API_EVENT.emit(name, arg); + } + } + }); + } + } + ); } - -// Function avoids 'window not defined' in SSR -const invoke = () => window.__TAURI_INVOKE__; - -export function appReady() { - return invoke()("app_ready") -} - -export function resetSpacedrive() { - return invoke()("reset_spacedrive") -} - -export function openLogsDir() { - return invoke()("open_logs_dir") -} - -export function refreshMenuBar() { - return invoke()("refresh_menu_bar") -} - -export function reloadWebview() { - return invoke()("reload_webview") -} - -export function setMenuBarItemState(id: string, enabled: boolean) { - return invoke()("set_menu_bar_item_state", { id,enabled }) -} - -export function requestFdaMacos() { - return invoke()("request_fda_macos") -} - -export function openFilePaths(library: string, ids: number[]) { - return invoke()("open_file_paths", { library,ids }) -} - -export function openEphemeralFiles(paths: string[]) { - return invoke()("open_ephemeral_files", { paths }) -} - -export function getFilePathOpenWithApps(library: string, ids: number[]) { - return invoke()("get_file_path_open_with_apps", { library,ids }) -} - -export function getEphemeralFilesOpenWithApps(paths: string[]) { - return invoke()("get_ephemeral_files_open_with_apps", { paths }) -} - -export function openFilePathWith(library: string, fileIdsAndUrls: ([number, string])[]) { - return invoke()("open_file_path_with", { library,fileIdsAndUrls }) -} - -export function openEphemeralFileWith(pathsAndUrls: ([string, string])[]) { - return invoke()("open_ephemeral_file_with", { pathsAndUrls }) -} - -export function revealItems(library: string, items: RevealItem[]) { - return invoke()("reveal_items", { library,items }) -} - -export function lockAppTheme(themeType: AppThemeType) { - return invoke()("lock_app_theme", { themeType }) -} - -export function checkForUpdate() { - return invoke()("check_for_update") -} - -export function installUpdate() { - return invoke()("install_update") -} - -export type Update = { version: string; body: string | null } -export type OpenWithApplication = { url: string; name: string } -export type AppThemeType = "Auto" | "Light" | "Dark" -export type EphemeralFileOpenResult = { t: "Ok"; c: string } | { t: "Err"; c: string } -export type OpenFilePathResult = { t: "NoLibrary" } | { t: "NoFile"; c: number } | { t: "OpenError"; c: [number, string] } | { t: "AllGood"; c: number } | { t: "Internal"; c: string } -export type RevealItem = { Location: { id: number } } | { FilePath: { id: number } } | { Ephemeral: { path: string } } diff --git a/apps/desktop/src/platform.ts b/apps/desktop/src/platform.ts index 40f8ebe24..d5f2831a1 100644 --- a/apps/desktop/src/platform.ts +++ b/apps/desktop/src/platform.ts @@ -4,7 +4,7 @@ import { homeDir } from '@tauri-apps/api/path'; import { open } from '@tauri-apps/api/shell'; import { OperatingSystem, Platform } from '@sd/interface'; -import * as commands from './commands'; +import { commands } from './commands'; import { env } from './env'; import { createUpdater } from './updater'; diff --git a/apps/desktop/src/updater.tsx b/apps/desktop/src/updater.tsx index 7a5a640a8..2bc2c757a 100644 --- a/apps/desktop/src/updater.tsx +++ b/apps/desktop/src/updater.tsx @@ -3,7 +3,7 @@ import { proxy, useSnapshot } from 'valtio'; import { UpdateStore } from '@sd/interface'; import { toast, ToastId } from '@sd/ui'; -import * as commands from './commands'; +import { commands } from './commands'; declare global { interface Window { @@ -27,9 +27,15 @@ export function createUpdater() { const onInstallCallbacks = new Set<() => void>(); async function checkForUpdate() { - const update = await commands.checkForUpdate(); + const result = await commands.checkForUpdate(); - if (!update) return null; + if (result.status === 'error') { + console.error('UPDATER ERROR', result.error); + // TODO: Show some UI? + return null; + } + if (!result.data) return null; + const update = result.data; let id: ToastId | null = null; diff --git a/apps/mobile/src/components/drawer/DrawerLocations.tsx b/apps/mobile/src/components/drawer/DrawerLocations.tsx index e1612495d..af26e6fe9 100644 --- a/apps/mobile/src/components/drawer/DrawerLocations.tsx +++ b/apps/mobile/src/components/drawer/DrawerLocations.tsx @@ -2,7 +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 { useLibraryQuery } from '@sd/client'; +import { useCache, useLibraryQuery, useNodes } from '@sd/client'; import { ModalRef } from '~/components/layout/Modal'; import { tw, twStyle } from '~/lib/tailwind'; @@ -39,7 +39,9 @@ const DrawerLocations = ({ stackName }: DrawerLocationsProp) => { const modalRef = useRef(null); - const { data: locations } = useLibraryQuery(['locations.list'], { keepPreviousData: true }); + const result = useLibraryQuery(['locations.list'], { keepPreviousData: true }); + useNodes(result.data?.nodes); + const locations = useCache(result.data?.items); return ( <> diff --git a/apps/mobile/src/components/drawer/DrawerTags.tsx b/apps/mobile/src/components/drawer/DrawerTags.tsx index 1e6c6aa8a..6ce411c74 100644 --- a/apps/mobile/src/components/drawer/DrawerTags.tsx +++ b/apps/mobile/src/components/drawer/DrawerTags.tsx @@ -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 { useLibraryQuery } from '@sd/client'; +import { useCache, useLibraryQuery, useNodes } from '@sd/client'; import { ModalRef } from '~/components/layout/Modal'; import { tw, twStyle } from '~/lib/tailwind'; @@ -37,6 +37,8 @@ const DrawerTags = ({ stackName }: DrawerTagsProp) => { const navigation = useNavigation(); const tags = useLibraryQuery(['tags.list']); + useNodes(tags.data?.nodes); + const tagData = useCache(tags.data?.items); const modalRef = useRef(null); @@ -47,7 +49,7 @@ const DrawerTags = ({ stackName }: DrawerTagsProp) => { containerStyle={tw`mb-3 ml-1 mt-6`} > - {tags.data?.map((tag) => ( + {tagData?.map((tag) => ( { const tagsQuery = useLibraryQuery(['tags.getForObject', objectData?.id ?? -1], { enabled: objectData != null }); + useNodes(tagsQuery.data?.nodes); + const items = useCache(tagsQuery.data?.items); const isDir = data && isPath(data) ? data.item.is_dir : false; @@ -35,7 +39,7 @@ const InfoTagPills = ({ data, style }: Props) => { )} {/* TODO: What happens if I have too many? */} - {tagsQuery.data?.map((tag) => ( + {items?.map((tag) => ( ((_, ref) => { const modalRef = useForwardedRef(ref); const queryClient = useQueryClient(); + const cache = useNormalisedCache(); const [libName, setLibName] = useState(''); const submitPlausibleEvent = usePlausibleEvent(); @@ -20,17 +26,15 @@ const CreateLibraryModal = forwardRef((_, ref) => { const { mutate: createLibrary, isLoading: createLibLoading } = useBridgeMutation( 'library.create', { - onSuccess: (lib) => { + onSuccess: (libRaw) => { + cache.withNodes(libRaw.nodes); + const lib = cache.withCache(libRaw.item); + // Reset form setLibName(''); // We do this instead of invalidating the query because it triggers a full app re-render?? - queryClient.setQueryData(['library.list'], (libraries: any) => { - // The invalidation system beat us to it - if (libraries.find((l: any) => l.uuid === lib.uuid)) return libraries; - - return [...(libraries || []), lib]; - }); + insertLibrary(queryClient, lib); // Switch to the new library currentLibraryStore.id = lib.uuid; diff --git a/apps/mobile/src/components/modal/inspector/FileInfoModal.tsx b/apps/mobile/src/components/modal/inspector/FileInfoModal.tsx index d9d2e59d3..74bc5e584 100644 --- a/apps/mobile/src/components/modal/inspector/FileInfoModal.tsx +++ b/apps/mobile/src/components/modal/inspector/FileInfoModal.tsx @@ -61,9 +61,9 @@ const FileInfoModal = forwardRef((props, ref) => { const objectData = data && getItemObject(data); const filePathData = data && getItemFilePath(data); - const fullObjectData = useLibraryQuery(['files.get', { id: objectData?.id || -1 }], { - enabled: objectData?.id !== undefined - }); + // const fullObjectData = useLibraryQuery(['files.get', objectData?.id || -1], { + // enabled: objectData?.id !== undefined + // }); return ( data?.items ?? [], [data]); + const pathsItems = useCache(pathsItemsReferences); useEffect(() => { // Set screen title to location. @@ -36,15 +40,15 @@ export default function LocationScreen({ navigation, route }: SharedScreenProps< }); } else { navigation.setOptions({ - title: location.data?.name ?? 'Location' + title: locationData?.name ?? 'Location' }); } - }, [location.data?.name, navigation, path]); + }, [locationData?.name, navigation, path]); useEffect(() => { getExplorerStore().locationId = id; getExplorerStore().path = path ?? ''; }, [id, path]); - return ; + return ; } diff --git a/apps/mobile/src/screens/Search.tsx b/apps/mobile/src/screens/Search.tsx index 161c928d1..702663393 100644 --- a/apps/mobile/src/screens/Search.tsx +++ b/apps/mobile/src/screens/Search.tsx @@ -2,7 +2,7 @@ import { MagnifyingGlass } from 'phosphor-react-native'; import { Suspense, useDeferredValue, useMemo, useState } from 'react'; import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { getExplorerItemData, SearchFilterArgs, useLibraryQuery } from '@sd/client'; +import { getExplorerItemData, SearchFilterArgs, useCache, useLibraryQuery } from '@sd/client'; import Explorer from '~/components/explorer/Explorer'; import { tw, twStyle } from '~/lib/tailwind'; import { RootStackScreenProps } from '~/navigation'; @@ -45,19 +45,20 @@ const SearchScreen = ({ navigation }: RootStackScreenProps<'Search'>) => { } ); - const items = useMemo(() => { - const items = query.data?.items; + const pathsItemsReferences = useMemo(() => query.data?.items ?? [], [query.data]); + const pathsItems = useCache(pathsItemsReferences); + const items = useMemo(() => { // Mobile does not thave media layout - // if (explorerStore.layoutMode !== 'media') return items; + // if (explorerStore.layoutMode !== 'media') return pathsItems; return ( - items?.filter((item) => { + pathsItems?.filter((item) => { const { kind } = getExplorerItemData(item); return kind === 'Video' || kind === 'Image'; }) ?? [] ); - }, [query.data]); + }, [pathsItems]); return ( diff --git a/apps/mobile/src/screens/Tag.tsx b/apps/mobile/src/screens/Tag.tsx index a613ea9cb..c70790b49 100644 --- a/apps/mobile/src/screens/Tag.tsx +++ b/apps/mobile/src/screens/Tag.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { useLibraryQuery } from '@sd/client'; +import { useCache, useLibraryQuery, useNodes } from '@sd/client'; import Explorer from '~/components/explorer/Explorer'; import { SharedScreenProps } from '~/navigation/SharedScreens'; @@ -13,15 +13,19 @@ export default function TagScreen({ navigation, route }: SharedScreenProps<'Tag' take: 100 } ]); + useNodes(search.data?.nodes); + const searchData = useCache(search.data?.items); const tag = useLibraryQuery(['tags.get', id]); + useNodes(tag.data?.nodes); + const tagData = useCache(tag.data?.item); useEffect(() => { // Set screen title to tag name. navigation.setOptions({ - title: tag.data?.name ?? 'Tag' + title: tagData?.name ?? 'Tag' }); - }, [tag.data?.name, navigation]); + }, [tagData?.name, navigation]); - return ; + return ; } diff --git a/apps/mobile/src/screens/onboarding/context.tsx b/apps/mobile/src/screens/onboarding/context.tsx index 6bd4c3151..869fb90ea 100644 --- a/apps/mobile/src/screens/onboarding/context.tsx +++ b/apps/mobile/src/screens/onboarding/context.tsx @@ -5,11 +5,13 @@ import { z } from 'zod'; import { currentLibraryCache, getOnboardingStore, + insertLibrary, resetOnboardingStore, telemetryStore, useBridgeMutation, useCachedLibraries, useMultiZodForm, + useNormalisedCache, useOnboardingStore, usePlausibleEvent } from '@sd/client'; @@ -66,13 +68,14 @@ const useFormState = () => { const submitPlausibleEvent = usePlausibleEvent(); const queryClient = useQueryClient(); + const cache = useNormalisedCache(); const createLibrary = useBridgeMutation('library.create', { - onSuccess: (lib) => { + onSuccess: (libRaw) => { + cache.withNodes(libRaw.nodes); + const lib = cache.withCache(libRaw.item); + // We do this instead of invalidating the query because it triggers a full app re-render?? - queryClient.setQueryData(['library.list'], (libraries: any) => [ - ...(libraries || []), - lib - ]); + insertLibrary(queryClient, lib); } }); @@ -86,13 +89,15 @@ const useFormState = () => { try { // show creation screen for a bit for smoothness - const [library] = await Promise.all([ + const [libraryRaw] = 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 (telemetryStore.shareFullTelemetry) { submitPlausibleEvent({ event: { type: 'libraryCreate' } }); diff --git a/apps/mobile/src/screens/settings/client/LibrarySettings.tsx b/apps/mobile/src/screens/settings/client/LibrarySettings.tsx index a136462ea..16061dca3 100644 --- a/apps/mobile/src/screens/settings/client/LibrarySettings.tsx +++ b/apps/mobile/src/screens/settings/client/LibrarySettings.tsx @@ -2,7 +2,7 @@ import { CaretRight, Pen, Trash } from 'phosphor-react-native'; import React, { useEffect, useRef } from 'react'; import { Animated, FlatList, Text, View } from 'react-native'; import { Swipeable } from 'react-native-gesture-handler'; -import { LibraryConfigWrapped, useBridgeQuery } from '@sd/client'; +import { LibraryConfigWrapped, useBridgeQuery, useCache, useNodes } from '@sd/client'; import { ModalRef } from '~/components/layout/Modal'; import DeleteLibraryModal from '~/components/modal/confirmModals/DeleteLibraryModal'; import { AnimatedButton, FakeButton } from '~/components/primitive/Button'; @@ -68,7 +68,9 @@ function LibraryItem({ } const LibrarySettingsScreen = ({ navigation }: SettingsStackScreenProps<'LibrarySettings'>) => { - const { data: libraries } = useBridgeQuery(['library.list']); + const libraryList = useBridgeQuery(['library.list']); + useNodes(libraryList.data?.nodes); + const libraries = useCache(libraryList.data?.items); useEffect(() => { navigation.setOptions({ diff --git a/apps/mobile/src/screens/settings/library/EditLocationSettings.tsx b/apps/mobile/src/screens/settings/library/EditLocationSettings.tsx index 2f1a28c14..3029c58e5 100644 --- a/apps/mobile/src/screens/settings/library/EditLocationSettings.tsx +++ b/apps/mobile/src/screens/settings/library/EditLocationSettings.tsx @@ -4,7 +4,7 @@ import { useEffect } from 'react'; import { Controller } from 'react-hook-form'; import { Alert, ScrollView, Text, View } from 'react-native'; import { z } from 'zod'; -import { useLibraryMutation, useLibraryQuery, useZodForm } from '@sd/client'; +import { useLibraryMutation, useLibraryQuery, useNormalisedCache, useZodForm } from '@sd/client'; import { Input } from '~/components/form/Input'; import { Switch } from '~/components/form/Switch'; import DeleteLocationModal from '~/components/modal/confirmModals/DeleteLocationModal'; @@ -36,6 +36,7 @@ const EditLocationSettingsScreen = ({ const { id } = route.params; const queryClient = useQueryClient(); + const cache = useNormalisedCache(); const form = useZodForm({ schema }); @@ -93,12 +94,15 @@ const EditLocationSettingsScreen = ({ }, [form, navigation, onSubmit]); useLibraryQuery(['locations.getWithRules', id], { - onSuccess: (data) => { + onSuccess: (dataRaw) => { + cache.withNodes(dataRaw?.nodes); + const data = cache.withCache(dataRaw?.item); + if (data && !form.formState.isDirty) form.reset({ displayName: data.name, localPath: data.path, - indexer_rules_ids: data.indexer_rules.map((i) => i.indexer_rule.id.toString()), + indexer_rules_ids: data.indexer_rules.map((i) => i.id.toString()), generatePreviewMedia: data.generate_preview_media, syncPreviewMedia: data.sync_preview_media, hidden: data.hidden diff --git a/apps/mobile/src/screens/settings/library/LocationSettings.tsx b/apps/mobile/src/screens/settings/library/LocationSettings.tsx index 760653fc1..3b5fc9e7c 100644 --- a/apps/mobile/src/screens/settings/library/LocationSettings.tsx +++ b/apps/mobile/src/screens/settings/library/LocationSettings.tsx @@ -5,8 +5,10 @@ import { Swipeable } from 'react-native-gesture-handler'; import { arraysEqual, Location, + useCache, useLibraryMutation, useLibraryQuery, + useNodes, useOnlineLocations } from '@sd/client'; import FolderIcon from '~/components/icons/FolderIcon'; @@ -130,7 +132,9 @@ function LocationItem({ location, index, navigation }: LocationItemProps) { } const LocationSettingsScreen = ({ navigation }: SettingsStackScreenProps<'LocationSettings'>) => { - const { data: locations } = useLibraryQuery(['locations.list']); + const result = useLibraryQuery(['locations.list']); + useNodes(result.data?.nodes); + const locations = useCache(result.data?.items); useEffect(() => { navigation.setOptions({ diff --git a/apps/mobile/src/screens/settings/library/TagsSettings.tsx b/apps/mobile/src/screens/settings/library/TagsSettings.tsx index d92a06af5..8b02f85d5 100644 --- a/apps/mobile/src/screens/settings/library/TagsSettings.tsx +++ b/apps/mobile/src/screens/settings/library/TagsSettings.tsx @@ -2,7 +2,7 @@ import { ArrowLeft, CaretRight, Pen, Trash } from 'phosphor-react-native'; import { useEffect, useRef } from 'react'; import { Animated, FlatList, Text, View } from 'react-native'; import { Swipeable } from 'react-native-gesture-handler'; -import { Tag, useLibraryQuery } from '@sd/client'; +import { Tag, useCache, useLibraryQuery, useNodes } from '@sd/client'; import { ModalRef } from '~/components/layout/Modal'; import DeleteTagModal from '~/components/modal/confirmModals/DeleteTagModal'; import CreateTagModal from '~/components/modal/tag/CreateTagModal'; @@ -70,7 +70,9 @@ function TagItem({ tag, index }: { tag: Tag; index: number }) { // TODO: Add "New Tag" button const TagsSettingsScreen = ({ navigation }: SettingsStackScreenProps<'TagsSettings'>) => { - const { data: tags } = useLibraryQuery(['tags.list']); + const result = useLibraryQuery(['tags.list']); + useNodes(result.data?.nodes); + const tags = useCache(result.data?.items); useEffect(() => { navigation.setOptions({ diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 21881a543..6dfc03aff 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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 { RspcProvider } from '@sd/client'; +import { CacheProvider, createCache, RspcProvider } from '@sd/client'; import { createRoutes, Platform, @@ -81,7 +81,9 @@ const queryClient = new QueryClient({ } }); -const routes = createRoutes(platform); +const cache = createCache(); + +const routes = createRoutes(platform, cache); function App() { const router = useRouter(); @@ -102,21 +104,23 @@ function App() { return (
- - - - - - - - - + + + + + + + + + + +
); @@ -126,7 +130,7 @@ export default App; function useRouter() { const [router, setRouter] = useState(() => { - const router = createBrowserRouter(createRoutes(platform)); + const router = createBrowserRouter(routes); router.subscribe((event) => { setRouter((router) => { diff --git a/core/Cargo.toml b/core/Cargo.toml index 79f3bb795..36d7d5bd9 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -22,16 +22,16 @@ sd-media-metadata = { path = "../crates/media-metadata" } sd-prisma = { path = "../crates/prisma" } sd-ffmpeg = { path = "../crates/ffmpeg", optional = true } sd-crypto = { path = "../crates/crypto", features = [ - "rspc", - "specta", - "serde", - "keymanager", + "rspc", + "specta", + "serde", + "keymanager", ] } - +sd-cache = { path = "../crates/cache" } sd-images = { path = "../crates/images", features = [ - "rspc", - "serde", - "specta", + "rspc", + "serde", + "specta", ] } sd-file-ext = { path = "../crates/file-ext" } sd-sync = { path = "../crates/sync" } @@ -40,21 +40,21 @@ sd-utils = { path = "../crates/utils" } sd-core-sync = { path = "./crates/sync" } rspc = { workspace = true, features = [ - "uuid", - "chrono", - "tracing", - "alpha", - "unstable", + "uuid", + "chrono", + "tracing", + "alpha", + "unstable", ] } prisma-client-rust = { workspace = true } specta = { workspace = true } tokio = { workspace = true, features = [ - "sync", - "rt-multi-thread", - "io-util", - "macros", - "time", - "process", + "sync", + "rt-multi-thread", + "io-util", + "macros", + "time", + "process", ] } serde = { version = "1.0", features = ["derive"] } chrono = { version = "0.4.31", features = ["serde"] } @@ -82,7 +82,7 @@ http-range = "0.1.5" mini-moka = "0.10.2" serde_with = "3.4.0" notify = { version = "=5.2.0", default-features = false, features = [ - "macos_fsevent", + "macos_fsevent", ], optional = true } static_assertions = "1.1.0" serde-hashkey = "0.4.5" diff --git a/core/src/api/backups.rs b/core/src/api/backups.rs index a911b08a2..412d257c5 100644 --- a/core/src/api/backups.rs +++ b/core/src/api/backups.rs @@ -115,12 +115,17 @@ pub(crate) fn mount() -> AlphaRouter { }) .procedure("backup", { R.with2(library()) - .mutation(|(node, library), _: ()| start_backup(node, library)) + .mutation( + |(node, library), _: ()| async move { Ok(start_backup(node, library).await) }, + ) }) .procedure("restore", { R // TODO: Paths as strings is bad but here we want the flexibility of the frontend allowing any path - .mutation(|node, path: String| start_restore(node, path.into())) + .mutation(|node, path: String| async move { + start_restore(node, path.into()).await; + Ok(()) + }) }) .procedure("delete", { R diff --git a/core/src/api/categories.rs b/core/src/api/categories.rs index 8fa14e9fb..5f6a8fdf2 100644 --- a/core/src/api/categories.rs +++ b/core/src/api/categories.rs @@ -1,47 +1,49 @@ -use crate::library::Category; +// TODO: Ensure this file has normalised caching setup before reenabling -use std::{collections::BTreeMap, str::FromStr}; +// use crate::library::Category; -use rspc::{alpha::AlphaRouter, ErrorCode}; -use strum::VariantNames; +// use std::{collections::BTreeMap, str::FromStr}; -use super::{utils::library, Ctx, R}; +// use rspc::{alpha::AlphaRouter, ErrorCode}; +// use strum::VariantNames; -pub(crate) fn mount() -> AlphaRouter { - R.router().procedure("list", { - R.with2(library()).query(|(_, library), _: ()| async move { - let (categories, queries): (Vec<_>, Vec<_>) = Category::VARIANTS - .iter() - .map(|category| { - let category = Category::from_str(category) - .expect("it's alright this category string exists"); - ( - category, - library.db.object().count(vec![category.to_where_param()]), - ) - }) - .unzip(); +// use super::{utils::library, Ctx, R}; - Ok(categories - .into_iter() - .zip( - library - .db - ._batch(queries) - .await? - .into_iter() - // TODO(@Oscar): rspc bigint support - .map(|count| { - i32::try_from(count).map_err(|_| { - rspc::Error::new( - ErrorCode::InternalServerError, - "category item count overflowed 'i32'!".into(), - ) - }) - }) - .collect::, _>>()?, - ) - .collect::>()) - }) - }) -} +// pub(crate) fn mount() -> AlphaRouter { +// R.router().procedure("list", { +// R.with2(library()).query(|(_, library), _: ()| async move { +// let (categories, queries): (Vec<_>, Vec<_>) = Category::VARIANTS +// .iter() +// .map(|category| { +// let category = Category::from_str(category) +// .expect("it's alright this category string exists"); +// ( +// category, +// library.db.object().count(vec![category.to_where_param()]), +// ) +// }) +// .unzip(); + +// Ok(categories +// .into_iter() +// .zip( +// library +// .db +// ._batch(queries) +// .await? +// .into_iter() +// // TODO(@Oscar): rspc bigint support +// .map(|count| { +// i32::try_from(count).map_err(|_| { +// rspc::Error::new( +// ErrorCode::InternalServerError, +// "category item count overflowed 'i32'!".into(), +// ) +// }) +// }) +// .collect::, _>>()?, +// ) +// .collect::>()) +// }) +// }) +// } diff --git a/core/src/api/cloud.rs b/core/src/api/cloud.rs index 71744182f..3b17d416e 100644 --- a/core/src/api/cloud.rs +++ b/core/src/api/cloud.rs @@ -28,7 +28,7 @@ pub(crate) fn mount() -> AlphaRouter { } mod library { - use chrono::{DateTime, Utc}; + use chrono::Utc; use crate::api::libraries::LibraryConfigWrapped; diff --git a/core/src/api/ephemeral_files.rs b/core/src/api/ephemeral_files.rs index 54da3e7bc..504f24a3f 100644 --- a/core/src/api/ephemeral_files.rs +++ b/core/src/api/ephemeral_files.rs @@ -52,7 +52,7 @@ pub(crate) fn mount() -> AlphaRouter { return Ok(None); } - match extract_media_data(full_path).await { + match extract_media_data(full_path.clone()).await { Ok(img_media_data) => Ok(Some(MediaMetadata::Image(Box::new(img_media_data)))), Err(MediaDataError::MediaData(sd_media_metadata::Error::NoExifDataOnPath( _, diff --git a/core/src/api/files.rs b/core/src/api/files.rs index 58a137a57..fbe75fde8 100644 --- a/core/src/api/files.rs +++ b/core/src/api/files.rs @@ -1,5 +1,5 @@ use crate::{ - api::utils::library, + api::{locations::object_with_file_paths, utils::library}, invalidate_query, job::Job, library::Library, @@ -21,6 +21,7 @@ use crate::{ util::{db::maybe_missing, error::FileIOError}, }; +use sd_cache::{CacheNode, Model, NormalisedResult, Reference}; use sd_file_ext::kind::ObjectKind; use sd_images::ConvertableExtension; use sd_media_metadata::MediaMetadata; @@ -31,11 +32,11 @@ use std::{ sync::Arc, }; -use chrono::Utc; +use chrono::{DateTime, FixedOffset, Utc}; use futures::future::join_all; use regex::Regex; use rspc::{alpha::AlphaRouter, ErrorCode}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use specta::Type; use tokio::{fs, io, task::spawn_blocking}; use tracing::{error, warn}; @@ -47,19 +48,76 @@ const UNTITLED_FOLDER_STR: &str = "Untitled Folder"; pub(crate) fn mount() -> AlphaRouter { R.router() .procedure("get", { - #[derive(Type, Deserialize)] - pub struct GetArgs { + #[derive(Type, Serialize)] + pub struct ObjectWithFilePaths2 { pub id: i32, + pub pub_id: Vec, + pub kind: Option, + pub key_id: Option, + pub hidden: Option, + pub favorite: Option, + pub important: Option, + pub note: Option, + pub date_created: Option>, + pub date_accessed: Option>, + pub file_paths: Vec>, } + + impl Model for ObjectWithFilePaths2 { + fn name() -> &'static str { + "Object" // is a duplicate because it's the same entity but with a relation + } + } + + impl ObjectWithFilePaths2 { + pub fn from_db( + nodes: &mut Vec, + item: object_with_file_paths::Data, + ) -> Reference { + let this = Self { + id: item.id, + pub_id: item.pub_id, + kind: item.kind, + key_id: item.key_id, + hidden: item.hidden, + favorite: item.favorite, + important: item.important, + 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) + } + } + R.with2(library()) - .query(|(_, library), args: GetArgs| async move { + .query(|(_, library), object_id: i32| async move { Ok(library .db .object() - .find_unique(object::id::equals(args.id)) - .include(object::include!({ file_paths })) + .find_unique(object::id::equals(object_id)) + .include(object_with_file_paths::include()) .exec() - .await?) + .await? + .map(|item| { + let mut nodes = Vec::new(); + NormalisedResult { + item: ObjectWithFilePaths2::from_db(&mut nodes, item), + nodes, + } + })) }) }) .procedure("getMediaData", { diff --git a/core/src/api/keys.rs b/core/src/api/keys.rs index 3799431aa..890e7d35b 100644 --- a/core/src/api/keys.rs +++ b/core/src/api/keys.rs @@ -1,3 +1,5 @@ +// TODO: Ensure this file has normalised caching setup before reenabling + // use rspc::alpha::AlphaRouter; // use rspc::ErrorCode; // use sd_crypto::keys::keymanager::{StoredKey, StoredKeyType}; diff --git a/core/src/api/libraries.rs b/core/src/api/libraries.rs index c774dbe95..9394ea626 100644 --- a/core/src/api/libraries.rs +++ b/core/src/api/libraries.rs @@ -6,6 +6,7 @@ use crate::{ Node, }; +use sd_cache::{Model, Normalise, NormalisedResult, NormalisedResults}; use sd_p2p::spacetunnel::RemoteIdentity; use sd_prisma::prisma::{indexer_rule, statistics}; @@ -35,6 +36,12 @@ 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 { @@ -50,7 +57,8 @@ pub(crate) fn mount() -> AlphaRouter { R.router() .procedure("list", { R.query(|node, _: ()| async move { - node.libraries + let libraries = node + .libraries .get_all() .await .into_iter() @@ -64,7 +72,11 @@ pub(crate) fn mount() -> AlphaRouter { }) .collect::>() .join() - .await + .await; + + let (nodes, items) = libraries.normalise(|i| i.uuid.to_string()); + + Ok(NormalisedResults { nodes, items }) }) }) .procedure("statistics", { @@ -279,7 +291,10 @@ pub(crate) fn mount() -> AlphaRouter { .await?; } - Ok(LibraryConfigWrapped::from_library(&library).await) + Ok(NormalisedResult::from( + LibraryConfigWrapped::from_library(&library).await, + |l| l.uuid.to_string(), + )) }, ) }) diff --git a/core/src/api/locations.rs b/core/src/api/locations.rs index 9eefd9f29..9385cb277 100644 --- a/core/src/api/locations.rs +++ b/core/src/api/locations.rs @@ -17,9 +17,10 @@ use crate::{ use std::path::{Path, PathBuf}; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, FixedOffset, Utc}; use directories::UserDirs; use rspc::{self, alpha::AlphaRouter, ErrorCode}; +use sd_cache::{CacheNode, Model, Normalise, NormalisedResult, NormalisedResults, Reference}; use serde::{Deserialize, Serialize}; use specta::Type; use tracing::error; @@ -58,6 +59,34 @@ pub enum ExplorerItem { item: PeerMetadata, }, } + +// 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 { + ExplorerItem::Path { .. } => "FilePath", + ExplorerItem::Object { .. } => "Object", + ExplorerItem::Location { .. } => "Location", + ExplorerItem::NonIndexedPath { .. } => "NonIndexedPath", + ExplorerItem::SpacedropPeer { .. } => "SpacedropPeer", + }; + match self { + ExplorerItem::Path { item, .. } => format!("{ty}:{}", item.id), + ExplorerItem::Object { item, .. } => format!("{ty}:{}", item.id), + ExplorerItem::Location { item, .. } => format!("{ty}:{}", item.id), + ExplorerItem::NonIndexedPath { item, .. } => format!("{ty}:{}", item.path), + ExplorerItem::SpacedropPeer { item, .. } => format!("{ty}:{}", item.name), // TODO: Use a proper primary key + } + } +} + #[derive(Serialize, Type, Debug)] pub struct SystemLocations { desktop: Option, @@ -172,13 +201,17 @@ pub(crate) fn mount() -> AlphaRouter { R.router() .procedure("list", { R.with2(library()).query(|(_, library), _: ()| async move { - Ok(library + let locations = library .db .location() .find_many(vec![]) .order_by(location::date_created::order(SortOrder::Desc)) .exec() - .await?) + .await?; + + let (nodes, items) = locations.normalise(|i| i.id.to_string()); + + Ok(NormalisedResults { items, nodes }) }) }) .procedure("get", { @@ -189,10 +222,72 @@ pub(crate) fn mount() -> AlphaRouter { .location() .find_unique(location::id::equals(location_id)) .exec() - .await?) + .await? + .map(|i| NormalisedResult::from(i, |i| i.id.to_string()))) }) }) .procedure("getWithRules", { + #[derive(Type, Serialize)] + struct LocationWithIndexerRule { + pub id: i32, + pub pub_id: Vec, + pub name: Option, + pub path: Option, + pub total_capacity: Option, + pub available_capacity: Option, + pub size_in_bytes: Option>, + pub is_archived: Option, + pub generate_preview_media: Option, + pub sync_preview_media: Option, + pub hidden: Option, + pub date_created: Option>, + pub instance_id: Option, + pub indexer_rules: Vec>, + } + + 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 + } + } + + impl LocationWithIndexerRule { + pub fn from_db( + nodes: &mut Vec, + value: location_with_indexer_rules::Data, + ) -> Reference { + let this = Self { + id: value.id, + pub_id: value.pub_id, + name: value.name, + path: value.path, + total_capacity: value.total_capacity, + available_capacity: value.available_capacity, + size_in_bytes: value.size_in_bytes, + is_archived: value.is_archived, + generate_preview_media: value.generate_preview_media, + sync_preview_media: value.sync_preview_media, + hidden: value.hidden, + date_created: value.date_created, + instance_id: value.instance_id, + 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) + } + } + R.with2(library()) .query(|(_, library), location_id: location::id::Type| async move { Ok(library @@ -201,7 +296,14 @@ pub(crate) fn mount() -> AlphaRouter { .find_unique(location::id::equals(location_id)) .include(location_with_indexer_rules::include()) .exec() - .await?) + .await? + .map(|location| { + let mut nodes = Vec::new(); + NormalisedResult { + item: LocationWithIndexerRule::from_db(&mut nodes, location), + nodes, + } + })) }) }) .procedure("create", { @@ -477,32 +579,34 @@ fn mount_indexer_rule_routes() -> AlphaRouter { format!("Indexer rule not found"), ) }) + .map(|i| NormalisedResult::from(i, |i| i.id.to_string())) }) }) .procedure("list", { R.with2(library()).query(|(_, library), _: ()| async move { - library - .db - .indexer_rule() - .find_many(vec![]) - .exec() - .await - .map_err(Into::into) + 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 }) }) }) // list indexer rules for location, returning the indexer rule .procedure("listForLocation", { R.with2(library()) .query(|(_, library), location_id: location::id::Type| async move { - library + let rules = library .db .indexer_rule() .find_many(vec![indexer_rule::locations::some(vec![ indexer_rules_in_location::location_id::equals(location_id), ])]) .exec() - .await - .map_err(Into::into) + .await?; + + let (nodes, items) = rules.normalise(|i| i.id.to_string()); + + Ok(NormalisedResults { items, nodes }) }) }) } diff --git a/core/src/api/mod.rs b/core/src/api/mod.rs index 922200b61..23aa21fd3 100644 --- a/core/src/api/mod.rs +++ b/core/src/api/mod.rs @@ -1,14 +1,14 @@ +use std::sync::{atomic::Ordering, Arc}; + use crate::{ invalidate_query, job::JobProgressEvent, node::config::{NodeConfig, NodePreferences}, Node, }; - +use sd_cache::patch_typedef; use sd_p2p::P2PStatus; -use std::sync::{atomic::Ordering, Arc}; - use itertools::Itertools; use rspc::{alpha::Rspc, Config, ErrorCode}; use serde::{Deserialize, Serialize}; @@ -121,9 +121,11 @@ pub(crate) fn mount() -> Arc { commit: &'static str, } - R.query(|_, _: ()| BuildInfo { - version: env!("CARGO_PKG_VERSION"), - commit: env!("GIT_HASH"), + R.query(|_, _: ()| { + Ok(BuildInfo { + version: env!("CARGO_PKG_VERSION"), + commit: env!("GIT_HASH"), + }) }) }) .procedure("nodeState", { @@ -198,6 +200,18 @@ pub(crate) fn mount() -> Arc { .merge("notifications.", notifications::mount()) .merge("backups.", backups::mount()) .merge("invalidation.", utils::mount_invalidate()) + .sd_patch_types_dangerously(|type_map| { + patch_typedef(type_map); + + let def = + ::definition_named_data_type( + type_map, + ); + type_map.insert( + ::SID, + def, + ); + }) .build( #[allow(clippy::let_and_return)] { diff --git a/core/src/api/notifications.rs b/core/src/api/notifications.rs index 7b96183fd..ec8fa8a13 100644 --- a/core/src/api/notifications.rs +++ b/core/src/api/notifications.rs @@ -162,6 +162,8 @@ pub(crate) fn mount() -> AlphaRouter { .procedure("test", { R.mutation(|node, _: ()| async move { node.emit_notification(NotificationData::Test, None).await; + + Ok(()) }) }) .procedure("testLibrary", { @@ -170,6 +172,8 @@ pub(crate) fn mount() -> AlphaRouter { library .emit_notification(NotificationData::Test, None) .await; + + Ok(()) }) }) } diff --git a/core/src/api/p2p.rs b/core/src/api/p2p.rs index 38f4e6101..28bad7051 100644 --- a/core/src/api/p2p.rs +++ b/core/src/api/p2p.rs @@ -51,9 +51,10 @@ pub(crate) fn mount() -> AlphaRouter { }) }) }) - .procedure("state", { - R.query(|node, _: ()| async move { node.p2p.state() }) - }) + // TODO: This has a potentially invalid map key and Specta don't like that. Can bring back in another PR. + // .procedure("state", { + // R.query(|node, _: ()| async move { Ok(node.p2p.state()) }) + // }) .procedure("spacedrop", { #[derive(Type, Deserialize)] pub struct SpacedropArgs { @@ -81,20 +82,23 @@ pub(crate) fn mount() -> AlphaRouter { match path { Some(path) => node.p2p.accept_spacedrop(id, path).await, None => node.p2p.reject_spacedrop(id).await, - } + }; + + Ok(()) }) }) .procedure("cancelSpacedrop", { - R.mutation(|node, id: Uuid| async move { node.p2p.cancel_spacedrop(id).await }) + R.mutation(|node, id: Uuid| async move { Ok(node.p2p.cancel_spacedrop(id).await) }) }) .procedure("pair", { R.mutation(|node, id: RemoteIdentity| async move { - node.p2p.pairing.clone().originator(id, node).await + Ok(node.p2p.pairing.clone().originator(id, node).await) }) }) .procedure("pairingResponse", { R.mutation(|node, (pairing_id, decision): (u16, PairingDecision)| { node.p2p.pairing.decision(pairing_id, decision); + Ok(()) }) }) } diff --git a/core/src/api/search/mod.rs b/core/src/api/search/mod.rs index 16556abfe..79cbbeaee 100644 --- a/core/src/api/search/mod.rs +++ b/core/src/api/search/mod.rs @@ -19,6 +19,7 @@ use crate::{ use std::path::PathBuf; use rspc::{alpha::AlphaRouter, ErrorCode}; +use sd_cache::{CacheNode, Model, Normalise, Reference}; use sd_prisma::prisma::{self, PrismaClient}; use serde::{Deserialize, Serialize}; use specta::Type; @@ -28,9 +29,16 @@ use super::{Ctx, R}; const MAX_TAKE: u8 = 100; #[derive(Serialize, Type, Debug)] -struct SearchData { +struct SearchData { cursor: Option>, - items: Vec, + items: Vec>, + nodes: Vec, +} + +impl Model for SearchData { + fn name() -> &'static str { + T::name() + } } #[derive(Serialize, Deserialize, Type, Debug, Clone)] @@ -91,6 +99,13 @@ pub fn mount() -> AlphaRouter { order: Option, } + #[derive(Serialize, Type, Debug)] + struct EphemeralPathsResult { + pub entries: Vec>, + pub errors: Vec, + pub nodes: Vec, + } + R.with2(library()).query( |(node, library), EphemeralPathSearchArgs { @@ -133,7 +148,13 @@ pub fn mount() -> AlphaRouter { ) } - Ok(paths) + let (nodes, entries) = paths.entries.normalise(|item| item.id()); + + Ok(EphemeralPathsResult { + entries, + errors: paths.errors, + nodes, + }) }, ) }) @@ -217,9 +238,12 @@ pub fn mount() -> AlphaRouter { }) } + let (nodes, items) = items.normalise(|item| item.id()); + Ok(SearchData { items, cursor: None, + nodes, }) }, ) @@ -333,7 +357,13 @@ pub fn mount() -> AlphaRouter { }); } - Ok(SearchData { items, cursor }) + let (nodes, items) = items.normalise(|item| item.id()); + + Ok(SearchData { + nodes, + items, + cursor, + }) }, ) }) diff --git a/core/src/api/tags.rs b/core/src/api/tags.rs index c682aa4c1..2e4cb5066 100644 --- a/core/src/api/tags.rs +++ b/core/src/api/tags.rs @@ -1,13 +1,14 @@ use std::collections::BTreeMap; -use chrono::Utc; +use chrono::{DateTime, Utc}; use itertools::{Either, Itertools}; use rspc::{alpha::AlphaRouter, ErrorCode}; +use sd_cache::{CacheNode, Normalise, NormalisedResult, NormalisedResults, Reference}; use sd_file_ext::kind::ObjectKind; use sd_prisma::{prisma, prisma_sync}; use sd_sync::OperationFactory; use sd_utils::uuid_to_bytes; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use specta::Type; use serde_json::json; @@ -26,23 +27,43 @@ pub(crate) fn mount() -> AlphaRouter { R.router() .procedure("list", { R.with2(library()).query(|(_, library), _: ()| async move { - Ok(library.db.tag().find_many(vec![]).exec().await?) + let tags = library.db.tag().find_many(vec![]).exec().await?; + + let (nodes, items) = tags.normalise(|i| i.id.to_string()); + + Ok(NormalisedResults { nodes, items }) }) }) .procedure("getForObject", { R.with2(library()) .query(|(_, library), object_id: i32| async move { - Ok(library + let tags = library .db .tag() .find_many(vec![tag::tag_objects::some(vec![ tag_on_object::object_id::equals(object_id), ])]) .exec() - .await?) + .await?; + + let (nodes, items) = tags.normalise(|i| i.id.to_string()); + + Ok(NormalisedResults { nodes, items }) }) }) .procedure("getWithObjects", { + #[derive(Serialize, Type)] + pub struct GetWithObjectsResult { + pub data: BTreeMap>>, + pub nodes: Vec, + } + + #[derive(Serialize, Type)] + pub struct ObjectWithDateCreated { + object: Reference, + date_created: DateTime, + } + R.with2(library()).query( |(_, library), object_ids: Vec| async move { let Library { db, .. } = library.as_ref(); @@ -64,6 +85,7 @@ pub(crate) fn mount() -> AlphaRouter { .exec() .await?; + // This doesn't need normalised caching because it doesn't return whole models. Ok(tags_with_objects .into_iter() .map(|tag| (tag.id, tag.tag_objects)) @@ -79,7 +101,8 @@ pub(crate) fn mount() -> AlphaRouter { .tag() .find_unique(tag::id::equals(tag_id)) .exec() - .await?) + .await? + .map(|tag| NormalisedResult::from(tag, |i| i.id.to_string()))) }) }) .procedure("create", { diff --git a/core/src/api/utils/invalidate.rs b/core/src/api/utils/invalidate.rs index 404510677..e8957c8cd 100644 --- a/core/src/api/utils/invalidate.rs +++ b/core/src/api/utils/invalidate.rs @@ -282,7 +282,7 @@ pub(crate) fn mount_invalidate() -> AlphaRouter { R.router() .procedure( "test-invalidate", - R.query(move |_, _: ()| count.fetch_add(1, Ordering::SeqCst)), + R.query(move |_, _: ()| Ok(count.fetch_add(1, Ordering::SeqCst))), ) .procedure( "test-invalidate-mutation", diff --git a/core/src/api/volumes.rs b/core/src/api/volumes.rs index 07adaaa00..cf4aee31d 100644 --- a/core/src/api/volumes.rs +++ b/core/src/api/volumes.rs @@ -1,4 +1,5 @@ use rspc::alpha::AlphaRouter; +use sd_cache::{Normalise, NormalisedResults}; use crate::volume::get_volumes; @@ -6,6 +7,23 @@ use super::{Ctx, R}; pub(crate) fn mount() -> AlphaRouter { R.router().procedure("list", { - R.query(|_, _: ()| async move { Ok(get_volumes().await) }) + 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() + .map(|mp| mp.as_os_str().to_string_lossy().as_bytes().to_vec()) + .flatten() + .collect::>(), + ) + .to_hex() + .to_string() + }); + + Ok(NormalisedResults { nodes, items }) + }) }) } diff --git a/core/src/p2p/peer_metadata.rs b/core/src/p2p/peer_metadata.rs index 4a84c4051..6cd3a1483 100644 --- a/core/src/p2p/peer_metadata.rs +++ b/core/src/p2p/peer_metadata.rs @@ -8,9 +8,9 @@ use crate::node::Platform; #[derive(Debug, Clone, Type, Serialize, Deserialize)] pub struct PeerMetadata { - pub(super) name: String, - pub(super) operating_system: Option, - pub(super) version: Option, + pub name: String, + pub operating_system: Option, + pub version: Option, } impl Metadata for PeerMetadata { diff --git a/core/src/volume/mod.rs b/core/src/volume/mod.rs index 3622c1ee3..734b4c5bc 100644 --- a/core/src/volume/mod.rs +++ b/core/src/volume/mod.rs @@ -7,6 +7,7 @@ use std::{ sync::OnceLock, }; +use sd_cache::Model; use serde::{Deserialize, Serialize}; use serde_with::{serde_as, DisplayFromStr}; use specta::Type; @@ -56,6 +57,12 @@ pub struct Volume { pub is_root_filesystem: bool, } +impl Model for Volume { + fn name() -> &'static str { + "Volume" + } +} + impl Hash for Volume { fn hash(&self, state: &mut H) { self.name.hash(state); diff --git a/crates/cache/Cargo.toml b/crates/cache/Cargo.toml new file mode 100644 index 000000000..279c194ef --- /dev/null +++ b/crates/cache/Cargo.toml @@ -0,0 +1,11 @@ +[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 diff --git a/crates/cache/src/lib.rs b/crates/cache/src/lib.rs new file mode 100644 index 000000000..2f798688a --- /dev/null +++ b/crates/cache/src/lib.rs @@ -0,0 +1,198 @@ +use std::{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` and `CacheNode`'s +/// +/// You don't need to use this, it's just a shortcut to avoid having to write out the full type everytime. +#[derive(Serialize, Type, Debug)] +pub struct NormalisedResults { + pub items: Vec>, + pub nodes: Vec, +} + +/// A type that can be used to return a group of `Reference` and `CacheNode`'s +/// +/// You don't need to use this, it's just a shortcut to avoid having to write out the full type everytime. +#[derive(Serialize, Type, Debug)] +pub struct NormalisedResult { + pub item: Reference, + pub nodes: Vec, +} + +impl NormalisedResult { + 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 { + __type: &'static str, + __id: String, + #[specta(rename = "#type")] + ty: PhantomType, +} + +impl Reference { + pub fn new(key: String) -> Self { + Self { + __type: "", // This is just to fake the field for Specta + __id: key, + ty: PhantomType(PhantomData), + } + } +} + +impl Serialize for Reference { + fn serialize(&self, serializer: S) -> Result + 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)] // TODO: `Hash, PartialEq, Eq` +pub struct CacheNode( + &'static str, + serde_json::Value, + Result>, +); + +impl CacheNode { + pub fn new(key: String, value: T) -> Self { + Self( + T::name(), + key.into(), + serde_json::to_value(value).map_err(Arc::new), + ) + } +} + +#[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(&self, serializer: S) -> Result + 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 serialise node: {}", err)) + })?, + } + .serialize(serializer) + } +} + +/// A helper for easily normalising data. +pub trait Normalise { + type Item: Model + Type; + + fn normalise( + self, + id_fn: impl Fn(&Self::Item) -> String, + ) -> (Vec, Vec>); +} + +impl Normalise for Vec { + type Item = T; + + fn normalise( + self, + id_fn: impl Fn(&Self::Item) -> String, + ) -> (Vec, Vec>) { + 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(PhantomData); + +/// WARNING: This type is surgically updated within `Reference` in the final typedefs due it being impossible to properly implement. +/// Be careful changing it! + +impl Type for PhantomType { + 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 { + __type: &'static str, + __id: String, + #[specta(rename = "#type")] + ty: T, + } + + let mut def = as NamedType>::definition_named_data_type(type_map); + def.inner = ReferenceTy::::definition(type_map); + type_map.insert( as NamedType>::SID, def) +} diff --git a/crates/p2p/src/spacetunnel/identity.rs b/crates/p2p/src/spacetunnel/identity.rs index 42d4c0fea..66e41e477 100644 --- a/crates/p2p/src/spacetunnel/identity.rs +++ b/crates/p2p/src/spacetunnel/identity.rs @@ -62,8 +62,9 @@ impl Identity { } } -#[derive(Copy, Clone, PartialEq, Eq)] -pub struct RemoteIdentity(ed25519_dalek::VerifyingKey); +#[derive(Copy, Clone, PartialEq, Eq, Type)] +#[specta(transparent)] +pub struct RemoteIdentity(#[specta(type = String)] ed25519_dalek::VerifyingKey); impl Hash for RemoteIdentity { fn hash(&self, state: &mut H) { @@ -141,15 +142,6 @@ impl FromStr for RemoteIdentity { } } -impl Type for RemoteIdentity { - fn inline( - _: specta::DefOpts, - _: &[specta::DataType], - ) -> Result { - Ok(specta::DataType::Primitive(specta::PrimitiveType::String)) - } -} - impl RemoteIdentity { pub fn from_bytes(bytes: &[u8]) -> Result { Ok(Self(ed25519_dalek::VerifyingKey::from_bytes( diff --git a/crates/prisma/Cargo.toml b/crates/prisma/Cargo.toml index 5a6e7ab73..d57dfd69d 100644 --- a/crates/prisma/Cargo.toml +++ b/crates/prisma/Cargo.toml @@ -8,3 +8,4 @@ prisma-client-rust = { workspace = true } serde = "1.0" serde_json = "1.0" sd-sync = { path = "../sync" } +sd-cache = { path = "../cache" } diff --git a/crates/prisma/src/lib.rs b/crates/prisma/src/lib.rs index 8fc4cadc3..a2ecc0253 100644 --- a/crates/prisma/src/lib.rs +++ b/crates/prisma/src/lib.rs @@ -2,3 +2,33 @@ pub mod prisma; #[allow(warnings, unused)] pub mod prisma_sync; + +impl sd_cache::Model for prisma::tag::Data { + fn name() -> &'static str { + "Tag" + } +} + +impl sd_cache::Model for prisma::object::Data { + fn name() -> &'static str { + "Object" + } +} + +impl sd_cache::Model for prisma::location::Data { + fn name() -> &'static str { + "Location" + } +} + +impl sd_cache::Model for prisma::indexer_rule::Data { + fn name() -> &'static str { + "IndexerRule" + } +} + +impl sd_cache::Model for prisma::file_path::Data { + fn name() -> &'static str { + "FilePath" + } +} diff --git a/interface/app/$libraryId/Explorer/ContextMenu/AssignTagMenuItems.tsx b/interface/app/$libraryId/Explorer/ContextMenu/AssignTagMenuItems.tsx index f59e2e1f0..307d2263c 100644 --- a/interface/app/$libraryId/Explorer/ContextMenu/AssignTagMenuItems.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu/AssignTagMenuItems.tsx @@ -2,9 +2,9 @@ import { Plus } from '@phosphor-icons/react'; import { useQueryClient } from '@tanstack/react-query'; import { useVirtualizer } from '@tanstack/react-virtual'; import clsx from 'clsx'; -import { forwardRef, MutableRefObject, RefObject, useMemo, useRef } from 'react'; +import { RefObject, useMemo, useRef } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; -import { ExplorerItem, useLibraryQuery } from '@sd/client'; +import { ExplorerItem, useCache, useLibraryQuery, useNodes } from '@sd/client'; import { Button, dialogManager, ModifierKeys, tw } from '@sd/ui'; import CreateDialog, { AssignTagItems, @@ -23,6 +23,7 @@ interface Props { function useData({ items }: Props) { const tags = useLibraryQuery(['tags.list'], { suspense: true }); + useNodes(tags.data?.nodes); // Map> const tagsWithObjects = useLibraryQuery( @@ -38,7 +39,13 @@ function useData({ items }: Props) { { suspense: true } ); - return { tags, tagsWithObjects }; + return { + tags: { + ...tags, + data: useCache(tags.data?.items) + }, + tagsWithObjects + }; } export default (props: Props) => { diff --git a/interface/app/$libraryId/Explorer/Inspector/index.tsx b/interface/app/$libraryId/Explorer/Inspector/index.tsx index 2e5e444a2..8285c333e 100644 --- a/interface/app/$libraryId/Explorer/Inspector/index.tsx +++ b/interface/app/$libraryId/Explorer/Inspector/index.tsx @@ -37,8 +37,10 @@ import { ObjectKindEnum, ObjectWithFilePaths, useBridgeQuery, + useCache, useItemsAsObjects, useLibraryQuery, + useNodes, type ExplorerItem } from '@sd/client'; import { Button, Divider, DropdownMenu, toast, Tooltip, tw } from '@sd/ui'; @@ -172,7 +174,9 @@ export const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => { let filePathData: FilePath | FilePathWithObject | null = null; let ephemeralPathData: NonIndexedPathItem | null = null; - const locations = useLibraryQuery(['locations.list']); + const result = useLibraryQuery(['locations.list']); + useNodes(result.data?.nodes); + const locations = useCache(result.data?.items); switch (item.type) { case 'NonIndexedPath': { @@ -209,12 +213,15 @@ export const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => { }, [item]); const fileLocations = - locations.data?.filter((location) => uniqueLocationIds.includes(location.id)) || []; + locations?.filter((location) => uniqueLocationIds.includes(location.id)) || []; const readyToFetch = useIsFetchReady(item); - const tags = useLibraryQuery(['tags.getForObject', objectData?.id ?? -1], { + const tagsQuery = useLibraryQuery(['tags.getForObject', objectData?.id ?? -1], { enabled: objectData != null && readyToFetch }); + useNodes(tagsQuery.data?.nodes); + const tags = useCache(tagsQuery.data?.items); + const { libraryId } = useZodRouteParams(LibraryIdParamsSchema); const queriedFullPath = useLibraryQuery(['files.getPath', filePathData?.id ?? -1], { @@ -332,7 +339,7 @@ export const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => { {extension && {extension}} - {tags.data?.map((tag) => ( + {tags?.map((tag) => ( { const { libraryId } = useZodRouteParams(LibraryIdParamsSchema); - const tags = useLibraryQuery(['tags.list'], { + const tagsQuery = useLibraryQuery(['tags.list'], { enabled: readyToFetch && !explorerStore.isDragging, suspense: true }); + useNodes(tagsQuery.data?.nodes); + const tags = useCache(tagsQuery.data?.items); const tagsWithObjects = useLibraryQuery( ['tags.getWithObjects', selectedObjects.map(({ id }) => id)], @@ -487,7 +496,7 @@ const MultiItemMetadata = ({ items }: { items: ExplorerItem[] }) => { {`${kind} (${items.length})`} ))} - {tags.data?.map((tag) => { + {tags?.map((tag) => { const objectsWithTag = tagsWithObjects.data?.[tag.id] || []; if (objectsWithTag.length === 0) return null; diff --git a/interface/app/$libraryId/Explorer/queries/useExplorerQuery.ts b/interface/app/$libraryId/Explorer/queries/useExplorerQuery.ts index cef450742..cec82d0c3 100644 --- a/interface/app/$libraryId/Explorer/queries/useExplorerQuery.ts +++ b/interface/app/$libraryId/Explorer/queries/useExplorerQuery.ts @@ -1,6 +1,6 @@ import { UseInfiniteQueryResult, UseQueryResult } from '@tanstack/react-query'; import { useCallback, useMemo } from 'react'; -import { SearchData } from '@sd/client'; +import { SearchData, useCache } from '@sd/client'; export function useExplorerQuery( query: UseInfiniteQueryResult>, @@ -14,7 +14,7 @@ export function useExplorerQuery( } }, [query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]); - return { query, items, loadMore, count: count.data }; + return { query, items: useCache(items), loadMore, count: count.data }; } export type UseExplorerQuery = ReturnType>; diff --git a/interface/app/$libraryId/Explorer/queries/useObjectsInfiniteQuery.ts b/interface/app/$libraryId/Explorer/queries/useObjectsInfiniteQuery.ts index 4da209df8..ae75148b3 100644 --- a/interface/app/$libraryId/Explorer/queries/useObjectsInfiniteQuery.ts +++ b/interface/app/$libraryId/Explorer/queries/useObjectsInfiniteQuery.ts @@ -1,10 +1,12 @@ import { useInfiniteQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; import { ExplorerItem, ObjectCursor, ObjectOrder, ObjectSearchArgs, useLibraryContext, + useNodes, useRspcLibraryContext } from '@sd/client'; @@ -23,7 +25,7 @@ export function useObjectsInfiniteQuery({ arg.orderAndPagination = { orderOnly: settings.order }; } - return useInfiniteQuery({ + const query = useInfiniteQuery({ queryKey: ['search.objects', { library_id: library.uuid, arg }] as const, queryFn: ({ pageParam, queryKey: [_, { arg }] }) => { const cItem: Extract = pageParam; @@ -66,4 +68,13 @@ export function useObjectsInfiniteQuery({ }, ...args }); + + const nodes = useMemo( + () => query.data?.pages.flatMap((page) => page.nodes) ?? [], + [query.data?.pages] + ); + + useNodes(nodes); + + return query; } diff --git a/interface/app/$libraryId/Explorer/queries/usePathsInfiniteQuery.ts b/interface/app/$libraryId/Explorer/queries/usePathsInfiniteQuery.ts index 3e722a7ca..85dbafd9c 100644 --- a/interface/app/$libraryId/Explorer/queries/usePathsInfiniteQuery.ts +++ b/interface/app/$libraryId/Explorer/queries/usePathsInfiniteQuery.ts @@ -1,4 +1,5 @@ import { useInfiniteQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; import { ExplorerItem, FilePathCursorVariant, @@ -6,6 +7,8 @@ import { FilePathOrder, FilePathSearchArgs, useLibraryContext, + useNodes, + useNormalisedCache, useRspcLibraryContext } from '@sd/client'; @@ -20,15 +23,16 @@ export function usePathsInfiniteQuery({ const { library } = useLibraryContext(); const ctx = useRspcLibraryContext(); const settings = explorerSettings.useSettingsSnapshot(); + const cache = useNormalisedCache(); if (settings.order) { arg.orderAndPagination = { orderOnly: settings.order }; if (arg.orderAndPagination.orderOnly.field === 'sizeInBytes') delete arg.take; } - return useInfiniteQuery({ + const query = useInfiniteQuery({ queryKey: ['search.paths', { library_id: library.uuid, arg }] as const, - queryFn: ({ pageParam, queryKey: [_, { arg }] }) => { + queryFn: async ({ pageParam, queryKey: [_, { arg }] }) => { const cItem: Extract = pageParam; const { order } = settings; @@ -120,7 +124,9 @@ export function usePathsInfiniteQuery({ arg.orderAndPagination = orderAndPagination; - return ctx.client.query(['search.paths', arg]); + 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; @@ -130,4 +136,13 @@ export function usePathsInfiniteQuery({ onSuccess: () => getExplorerStore().resetNewThumbnails(), ...args }); + + const nodes = useMemo( + () => query.data?.pages.flatMap((page) => page.nodes) ?? [], + [query.data?.pages] + ); + + useNodes(nodes); + + return query; } diff --git a/interface/app/$libraryId/Layout/Sidebar/DebugPopover.tsx b/interface/app/$libraryId/Layout/Sidebar/DebugPopover.tsx index 33ed43cbe..673809a23 100644 --- a/interface/app/$libraryId/Layout/Sidebar/DebugPopover.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/DebugPopover.tsx @@ -1,4 +1,5 @@ import { CheckSquare } from '@phosphor-icons/react'; +import { useNavigate } from 'react-router'; import { backendFeatures, features, @@ -31,6 +32,7 @@ export default () => { const debugState = useDebugState(); const platform = usePlatform(); + const navigate = useNavigate(); return ( { + {/* {platform.showDevtools && ( { }; export const EphemeralSection = () => { - const locations = useLibraryQuery(['locations.list']); + const locationsQuery = useLibraryQuery(['locations.list']); + useNodes(locationsQuery.data?.nodes); + const locations = useCache(locationsQuery.data?.items); const homeDir = useHomeDir(); - const volumes = useBridgeQuery(['volumes.list']); + const result = useBridgeQuery(['volumes.list']); + useNodes(result.data?.nodes); + const volumes = useCache(result.data?.items); // this will return an array of location ids that are also volumes // { "/Mount/Point": 1, "/Mount/Point2": 2"} const locationIdsForVolumes = useMemo(() => { - if (!locations.data || !volumes.data) return {}; + if (!locations || !volumes) return {}; - const volumePaths = volumes.data.map((volume) => volume.mount_points[0] ?? null); + const volumePaths = volumes.map((volume) => volume.mount_points[0] ?? null); - const matchedLocations = locations.data.filter((location) => + const matchedLocations = locations.filter((location) => volumePaths.includes(location.path) ); @@ -57,9 +61,9 @@ export const EphemeralSection = () => { ); return locationIdsMap; - }, [locations.data, volumes.data]); + }, [locations, volumes]); - const mountPoints = (volumes.data || []).flatMap((volume, volumeIndex) => + const mountPoints = (volumes || []).flatMap((volume, volumeIndex) => volume.mount_points.map((mountPoint, index) => mountPoint !== homeDir.data ? { type: 'volume', volume, mountPoint, volumeIndex, index } diff --git a/interface/app/$libraryId/Layout/Sidebar/LibrarySection.tsx b/interface/app/$libraryId/Layout/Sidebar/LibrarySection.tsx index e6dbbef52..46ceccfa9 100644 --- a/interface/app/$libraryId/Layout/Sidebar/LibrarySection.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/LibrarySection.tsx @@ -5,9 +5,11 @@ import { Link, NavLink } from 'react-router-dom'; import { arraysEqual, useBridgeQuery, + useCache, useFeatureFlag, useLibraryMutation, useLibraryQuery, + useNodes, useOnlineLocations } from '@sd/client'; import { Button, Tooltip } from '@sd/ui'; @@ -138,6 +140,8 @@ function Devices() { function Locations() { const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true }); + useNodes(locationsQuery.data?.nodes); + const locations = useCache(locationsQuery.data?.items); const onlineLocations = useOnlineLocations(); return ( @@ -150,7 +154,7 @@ function Locations() { } > - {locationsQuery.data?.map((location) => ( + {locations?.map((location) => ( - {tags.data?.map((tag) => ( + {tags?.map((tag) => ( { const query = useLibraryQuery(['locations.list'], { keepPreviousData: true }); + useNodes(query.data?.nodes); + const locations = useCache(query.data?.items); - return (query.data ?? []).map((location) => ({ + return (locations ?? []).map((location) => ({ name: location.name!, value: location.id, icon: 'Folder' // Spacedrive folder icon @@ -455,8 +465,10 @@ export const filterRegistry = [ }, useOptions: () => { const query = useLibraryQuery(['tags.list'], { keepPreviousData: true }); + useNodes(query.data?.nodes); + const tags = useCache(query.data?.items); - return (query.data ?? []).map((tag) => ({ + return (tags ?? []).map((tag) => ({ name: tag.name!, value: tag.id, icon: tag.color || 'CircleDashed' diff --git a/interface/app/$libraryId/debug.tsx b/interface/app/$libraryId/debug.tsx deleted file mode 100644 index 6fd222ff0..000000000 --- a/interface/app/$libraryId/debug.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useBridgeQuery, useLibraryQuery } from '@sd/client'; -import { CodeBlock } from '~/components/Codeblock'; -import { useRouteTitle } from '~/hooks/useRouteTitle'; - -// TODO: Bring this back with a button in the sidebar near settings at the bottom -export const Component = () => { - useRouteTitle('Debug'); - - const { data: nodeState } = useBridgeQuery(['nodeState']); - const { data: libraryState } = useBridgeQuery(['library.list']); - // const { data: jobs } = useLibraryQuery(['jobs.getRunning']); - // const { data: jobHistory } = useLibraryQuery(['jobs.getHistory']); - // const { mutate: purgeDB } = useBridgeCommand('PurgeDatabase', { - // onMutate: () => { - // alert('Database purged'); - // } - // }); - // const { mutate: identifyFiles } = useLibraryMutation('jobs.identifyUniqueFiles'); - return ( -
-

Developer Debugger

- {/*
- -
*/} - {/*

Running Jobs

- -

Job History

- */} -

Node State

- -

Libraries

- -
- ); -}; diff --git a/interface/app/$libraryId/debug/cache.tsx b/interface/app/$libraryId/debug/cache.tsx new file mode 100644 index 000000000..4f6bdd304 --- /dev/null +++ b/interface/app/$libraryId/debug/cache.tsx @@ -0,0 +1,13 @@ +import { snapshot } from 'valtio'; +import { useNormalisedCache } from '@sd/client'; + +export function Component() { + const cache = useNormalisedCache(); + const data = snapshot(cache['#cache']); + return ( +
+

Cache Debug

+
{JSON.stringify(data, null, 2)}
+
+ ); +} diff --git a/interface/app/$libraryId/debug/index.ts b/interface/app/$libraryId/debug/index.ts new file mode 100644 index 000000000..7c6058b2e --- /dev/null +++ b/interface/app/$libraryId/debug/index.ts @@ -0,0 +1,5 @@ +import { RouteObject } from 'react-router'; + +export const debugRoutes: RouteObject = { + children: [{ path: 'cache', lazy: () => import('./cache') }] +}; diff --git a/interface/app/$libraryId/ephemeral.tsx b/interface/app/$libraryId/ephemeral.tsx index 310f0b3d3..2ad3fba27 100644 --- a/interface/app/$libraryId/ephemeral.tsx +++ b/interface/app/$libraryId/ephemeral.tsx @@ -7,7 +7,9 @@ import { useLocation } from 'react-router'; import { ExplorerItem, getExplorerItemData, + useCache, useLibraryQuery, + useNodes, type EphemeralPathOrder } from '@sd/client'; import { Button, Tooltip } from '@sd/ui'; @@ -189,13 +191,15 @@ const EphemeralExplorer = memo((props: { args: PathParams }) => { onSuccess: () => getExplorerStore().resetNewThumbnails() } ); + useNodes(query.data?.nodes); + const entries = useCache(query.data?.entries); const items = useMemo(() => { - if (!query.data) return []; + if (!entries) return []; const ret: ExplorerItem[] = []; - for (const item of query.data.entries) { + for (const item of entries) { if (settingsSnapshot.layoutMode !== 'media') ret.push(item); else { const { kind } = getExplorerItemData(item); @@ -205,7 +209,7 @@ const EphemeralExplorer = memo((props: { args: PathParams }) => { } return ret; - }, [query.data, settingsSnapshot.layoutMode]); + }, [entries, settingsSnapshot.layoutMode]); const explorer = useExplorer({ items, diff --git a/interface/app/$libraryId/index.tsx b/interface/app/$libraryId/index.tsx index 9c22e5ebd..77b89f949 100644 --- a/interface/app/$libraryId/index.tsx +++ b/interface/app/$libraryId/index.tsx @@ -3,6 +3,7 @@ import { Navigate, type RouteObject } from 'react-router-dom'; import { useHomeDir } from '~/hooks/useHomeDir'; import { Platform } from '~/util/Platform'; +import { debugRoutes } from './debug'; import settingsRoutes from './settings'; // Routes that should be contained within the standard Page layout @@ -12,9 +13,9 @@ const pageRoutes: RouteObject = { { path: 'people', lazy: () => import('./people') }, { path: 'media', lazy: () => import('./media') }, { path: 'spaces', lazy: () => import('./spaces') }, - { path: 'debug', lazy: () => import('./debug') }, { path: 'sync', lazy: () => import('./sync') }, - { path: 'cloud', lazy: () => import('./cloud') } + { path: 'cloud', lazy: () => import('./cloud') }, + { path: 'debug', children: [debugRoutes] } ] }; diff --git a/interface/app/$libraryId/location/$id.tsx b/interface/app/$libraryId/location/$id.tsx index 222899c26..4d3b02897 100644 --- a/interface/app/$libraryId/location/$id.tsx +++ b/interface/app/$libraryId/location/$id.tsx @@ -7,9 +7,11 @@ import { FilePathOrder, Location, ObjectKindEnum, + useCache, useLibraryMutation, useLibraryQuery, useLibrarySubscription, + useNodes, useOnlineLocations, useRspcLibraryContext } from '@sd/client'; @@ -41,12 +43,14 @@ import LocationOptions from './LocationOptions'; export const Component = () => { const { id: locationId } = useZodRouteParams(LocationIdParamsSchema); - const location = useLibraryQuery(['locations.get', locationId], { + const result = useLibraryQuery(['locations.get', locationId], { keepPreviousData: true, suspense: true }); + useNodes(result.data?.nodes); + const location = useCache(result.data?.item); - return ; + return ; }; const LocationExplorer = ({ location }: { location: Location; path?: string }) => { diff --git a/interface/app/$libraryId/saved-search/$id.tsx b/interface/app/$libraryId/saved-search/$id.tsx index d47d4e9d6..023c8cdd2 100644 --- a/interface/app/$libraryId/saved-search/$id.tsx +++ b/interface/app/$libraryId/saved-search/$id.tsx @@ -1,7 +1,13 @@ import { MagnifyingGlass } from '@phosphor-icons/react'; import { getIcon, iconNames } from '@sd/assets/util'; import { useMemo } from 'react'; -import { FilePathOrder, SearchFilterArgs, useLibraryMutation, useLibraryQuery } from '@sd/client'; +import { + FilePathOrder, + SearchFilterArgs, + useCache, + useLibraryMutation, + useLibraryQuery +} from '@sd/client'; import { Button } from '@sd/ui'; import { SearchIdParamsSchema } from '~/app/route-schemas'; import { useRouteTitle, useZodRouteParams } from '~/hooks'; diff --git a/interface/app/$libraryId/settings/client/usage.tsx b/interface/app/$libraryId/settings/client/usage.tsx index 9508a9a44..4e8e336c5 100644 --- a/interface/app/$libraryId/settings/client/usage.tsx +++ b/interface/app/$libraryId/settings/client/usage.tsx @@ -1,6 +1,6 @@ import { iconNames } from '@sd/assets/util'; import { memo, useEffect, useMemo, useState } from 'react'; -import { byteSize, useDiscoveredPeers, useLibraryQuery } from '@sd/client'; +import { byteSize, useDiscoveredPeers, useLibraryQuery, useNodes } from '@sd/client'; import { Card } from '@sd/ui'; import { Icon } from '~/components'; import { useCounter } from '~/hooks'; @@ -15,6 +15,9 @@ 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(() => { if (locations.data && discoveredPeers) { @@ -33,8 +36,8 @@ export const Component = () => { }[] = [ { icon: 'Folder', - title: locations.data.length === 1 ? 'Location' : 'Locations', - titleCount: locations.data?.length ?? 0, + title: locations.data?.items.length === 1 ? 'Location' : 'Locations', + titleCount: locations.data?.items.length ?? 0, sub: 'indexed directories' }, { diff --git a/interface/app/$libraryId/settings/library/locations/$id.tsx b/interface/app/$libraryId/settings/library/locations/$id.tsx index aa0655f0b..706ee1b88 100644 --- a/interface/app/$libraryId/settings/library/locations/$id.tsx +++ b/interface/app/$libraryId/settings/library/locations/$id.tsx @@ -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 { useLibraryMutation, useLibraryQuery, useZodForm } from '@sd/client'; +import { useCache, useLibraryMutation, useLibraryQuery, useNodes, useZodForm } from '@sd/client'; import { Button, dialogManager, @@ -54,21 +54,22 @@ const EditLocationForm = () => { const fullRescan = useLibraryMutation('locations.fullRescan'); const queryClient = useQueryClient(); - const locationData = useLibraryQuery(['locations.getWithRules', locationId], { + const locationDataQuery = useLibraryQuery(['locations.getWithRules', locationId], { suspense: true }); + useNodes(locationDataQuery.data?.nodes); + const locationData = useCache(locationDataQuery.data?.item); const form = useZodForm({ schema, defaultValues: { - indexerRulesIds: - locationData.data?.indexer_rules.map((rule) => rule.indexer_rule.id) ?? [], + indexerRulesIds: locationData?.indexer_rules.map((rule) => rule.id) ?? [], locationType: 'normal', - name: locationData.data?.name ?? '', - path: locationData.data?.path ?? '', - hidden: locationData.data?.hidden ?? false, - syncPreviewMedia: locationData.data?.sync_preview_media ?? false, - generatePreviewMedia: locationData.data?.generate_preview_media ?? false + name: locationData?.name ?? '', + path: locationData?.path ?? '', + hidden: locationData?.hidden ?? false, + syncPreviewMedia: locationData?.sync_preview_media ?? false, + generatePreviewMedia: locationData?.generate_preview_media ?? false } }); diff --git a/interface/app/$libraryId/settings/library/locations/AddLocationDialog.tsx b/interface/app/$libraryId/settings/library/locations/AddLocationDialog.tsx index b8229982b..323af33a8 100644 --- a/interface/app/$libraryId/settings/library/locations/AddLocationDialog.tsx +++ b/interface/app/$libraryId/settings/library/locations/AddLocationDialog.tsx @@ -4,8 +4,10 @@ import { useDebouncedCallback } from 'use-debounce'; import { extractInfoRSPCError, UnionToTuple, + useCache, useLibraryMutation, useLibraryQuery, + useNodes, usePlausibleEvent, useZodForm } from '@sd/client'; @@ -57,13 +59,15 @@ export const AddLocationDialog = ({ const listLocations = useLibraryQuery(['locations.list']); const createLocation = useLibraryMutation('locations.create'); const relinkLocation = useLibraryMutation('locations.relink'); - const listIndexerRules = useLibraryQuery(['locations.indexer_rules.list']); + const listIndexerRulesQuery = useLibraryQuery(['locations.indexer_rules.list']); + useNodes(listIndexerRulesQuery.data?.nodes); + const listIndexerRules = useCache(listIndexerRulesQuery.data?.items); const addLocationToLibrary = useLibraryMutation('locations.addLibrary'); // This is required because indexRules is undefined on first render const indexerRulesIds = useMemo( - () => listIndexerRules.data?.filter((rule) => rule.default).map((rule) => rule.id) ?? [], - [listIndexerRules.data] + () => listIndexerRules?.filter((rule) => rule.default).map((rule) => rule.id) ?? [], + [listIndexerRules] ); const form = useZodForm({ diff --git a/interface/app/$libraryId/settings/library/locations/IndexerRuleEditor/index.tsx b/interface/app/$libraryId/settings/library/locations/IndexerRuleEditor/index.tsx index 4801cb50a..ff1880869 100644 --- a/interface/app/$libraryId/settings/library/locations/IndexerRuleEditor/index.tsx +++ b/interface/app/$libraryId/settings/library/locations/IndexerRuleEditor/index.tsx @@ -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, useLibraryMutation, useLibraryQuery } from '@sd/client'; +import { IndexerRule, useCache, useLibraryMutation, useLibraryQuery, useNodes } from '@sd/client'; import { Button, Divider, Label, toast } from '@sd/ui'; import { InfoText } from '@sd/ui/src/forms'; import { showAlertDialog } from '~/components'; @@ -33,7 +33,8 @@ export default function IndexerRuleEditor({ ...props }: IndexerRuleEditorProps) { const listIndexerRules = useLibraryQuery(['locations.indexer_rules.list']); - const indexRules = listIndexerRules.data; + useNodes(listIndexerRules.data?.nodes); + const indexRules = useCache(listIndexerRules.data?.items); const [isDeleting, setIsDeleting] = useState(false); const [selectedRule, setSelectedRule] = useState(undefined); const [toggleNewRule, setToggleNewRule] = useState(false); diff --git a/interface/app/$libraryId/settings/library/locations/index.tsx b/interface/app/$libraryId/settings/library/locations/index.tsx index 948780d03..da71c6f43 100644 --- a/interface/app/$libraryId/settings/library/locations/index.tsx +++ b/interface/app/$libraryId/settings/library/locations/index.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react'; import { useDebounce } from 'use-debounce'; -import { useLibraryQuery } from '@sd/client'; +import { useCache, useLibraryQuery, useNodes } from '@sd/client'; import { SearchInput } from '@sd/ui'; import { Heading } from '../../Layout'; @@ -8,17 +8,19 @@ import { AddLocationButton } from './AddLocationButton'; import ListItem from './ListItem'; export const Component = () => { - const locations = useLibraryQuery(['locations.list']); + const locationsQuery = useLibraryQuery(['locations.list']); + useNodes(locationsQuery.data?.nodes); + const locations = useCache(locationsQuery.data?.items); const [search, setSearch] = useState(''); const [debouncedSearch] = useDebounce(search, 200); const filteredLocations = useMemo( () => - locations.data?.filter( + locations?.filter( (location) => location.name?.toLowerCase().includes(debouncedSearch.toLowerCase()) ) ?? [], - [debouncedSearch, locations.data] + [debouncedSearch, locations] ); return ( diff --git a/interface/app/$libraryId/settings/library/nodes.tsx b/interface/app/$libraryId/settings/library/nodes.tsx index 8ac974ca5..22b5b32f7 100644 --- a/interface/app/$libraryId/settings/library/nodes.tsx +++ b/interface/app/$libraryId/settings/library/nodes.tsx @@ -1,9 +1,11 @@ import { useBridgeMutation, useBridgeQuery, + useCache, useConnectedPeers, useDiscoveredPeers, - useFeatureFlag + useFeatureFlag, + useNodes } from '@sd/client'; import { Button } from '@sd/ui'; import { startPairing } from '~/app/p2p/pairing'; @@ -41,10 +43,17 @@ function IncorrectP2PPairingPane() { console.log(data); } }); - const nlmState = useBridgeQuery(['p2p.state'], { - refetchInterval: 1000 - }); - const libraries = useBridgeQuery(['library.list']); + + const nlmState = { + data: JSON.stringify('lol no') + }; + // TODO: Bring this back + // useBridgeQuery(['p2p.state'], { + // refetchInterval: 1000 + // }); + const result = useBridgeQuery(['library.list']); + useNodes(result.data?.nodes); + const libraries = useCache(result.data?.items); return ( <> @@ -86,7 +95,7 @@ function IncorrectP2PPairingPane() {

Libraries:

- {libraries.data?.map((v) => ( + {libraries?.map((v) => (

{v.config.name} - {v.uuid} diff --git a/interface/app/$libraryId/settings/library/tags/index.tsx b/interface/app/$libraryId/settings/library/tags/index.tsx index 2fd95f89f..6ad489964 100644 --- a/interface/app/$libraryId/settings/library/tags/index.tsx +++ b/interface/app/$libraryId/settings/library/tags/index.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx'; import { useEffect, useState } from 'react'; -import { Tag, useLibraryQuery } from '@sd/client'; +import { Tag, useCache, useLibraryQuery, useNodes } from '@sd/client'; import { Button, Card, dialogManager } from '@sd/ui'; import { Heading } from '~/app/$libraryId/settings/Layout'; import { TagsSettingsParamsSchema } from '~/app/route-schemas'; @@ -10,11 +10,14 @@ import CreateDialog from './CreateDialog'; import EditForm from './EditForm'; export const Component = () => { - const tags = useLibraryQuery(['tags.list']); + const result = useLibraryQuery(['tags.list']); + useNodes(result.data?.nodes); + const tags = useCache(result.data?.items); + const { id: locationId } = useZodRouteParams(TagsSettingsParamsSchema); - const tagSelectedParam = tags.data?.find((tag) => tag.id === locationId); + const tagSelectedParam = tags?.find((tag) => tag.id === locationId); const [selectedTag, setSelectedTag] = useState( - tagSelectedParam ?? tags.data?.[0] ?? null + tagSelectedParam ?? tags?.[0] ?? null ); // Update selected tag when the route param changes @@ -24,7 +27,7 @@ export const Component = () => { // Set the first tag as selected when the tags list data is first loaded useEffect(() => { - if (tags?.data?.length || (0 > 1 && !selectedTag)) setSelectedTag(tags.data?.[0] ?? null); + if (tags?.length || (0 > 1 && !selectedTag)) setSelectedTag(tags?.[0] ?? null); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -49,7 +52,7 @@ export const Component = () => { />

- {tags.data?.map((tag) => ( + {tags?.map((tag) => (
setSelectedTag(tag.id === selectedTag?.id ? null : tag)} key={tag.id} diff --git a/interface/app/$libraryId/settings/node/libraries/CreateDialog.tsx b/interface/app/$libraryId/settings/node/libraries/CreateDialog.tsx index 919ef5de0..62c6370ca 100644 --- a/interface/app/$libraryId/settings/node/libraries/CreateDialog.tsx +++ b/interface/app/$libraryId/settings/node/libraries/CreateDialog.tsx @@ -1,6 +1,12 @@ import { useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; -import { LibraryConfigWrapped, useBridgeMutation, usePlausibleEvent, useZodForm } from '@sd/client'; +import { + insertLibrary, + useBridgeMutation, + useNormalisedCache, + usePlausibleEvent, + useZodForm +} from '@sd/client'; import { Dialog, InputField, useDialog, UseDialogProps, z } from '@sd/ui'; import { usePlatform } from '~/util/Platform'; @@ -23,18 +29,18 @@ export default (props: UseDialogProps) => { const createLibrary = useBridgeMutation('library.create'); const form = useZodForm({ schema }); + const cache = useNormalisedCache(); const onSubmit = form.handleSubmit(async (data) => { try { - const library = await createLibrary.mutateAsync({ + const libraryRaw = await createLibrary.mutateAsync({ name: data.name, default_locations: null }); + cache.withNodes(libraryRaw.nodes); + const library = cache.withCache(libraryRaw.item); - queryClient.setQueryData(['library.list'], (libraries) => [ - ...(libraries || []), - library - ]); + insertLibrary(queryClient, library); submitPlausibleEvent({ event: { type: 'libraryCreate' } diff --git a/interface/app/$libraryId/settings/node/libraries/index.tsx b/interface/app/$libraryId/settings/node/libraries/index.tsx index 1889f2adb..5b551d226 100644 --- a/interface/app/$libraryId/settings/node/libraries/index.tsx +++ b/interface/app/$libraryId/settings/node/libraries/index.tsx @@ -1,4 +1,4 @@ -import { useBridgeQuery, useLibraryContext } from '@sd/client'; +import { useBridgeQuery, useCache, useLibraryContext, useNodes } from '@sd/client'; import { Button, dialogManager } from '@sd/ui'; import { Heading } from '../../Layout'; @@ -6,7 +6,9 @@ import CreateDialog from './CreateDialog'; import ListItem from './ListItem'; export const Component = () => { - const libraries = useBridgeQuery(['library.list']); + const librariesQuery = useBridgeQuery(['library.list']); + useNodes(librariesQuery.data?.nodes); + const libraries = useCache(librariesQuery.data?.items); const { library } = useLibraryContext(); @@ -31,7 +33,7 @@ export const Component = () => { />
- {libraries.data + {libraries ?.sort((a, b) => { if (a.uuid === library.uuid) return -1; if (b.uuid === library.uuid) return 1; diff --git a/interface/app/$libraryId/sync.tsx b/interface/app/$libraryId/sync.tsx index 45c53ce6c..9564fac83 100644 --- a/interface/app/$libraryId/sync.tsx +++ b/interface/app/$libraryId/sync.tsx @@ -114,7 +114,7 @@ function calculateGroups(messages: CRDTOperation[]) { const { typ } = curr; if ('model' in typ) { - const id = stringify(typ.record_id.pub_id); + const id = stringify((typ.record_id as any).pub_id); const latest = (() => { const latest = acc[acc.length - 1]; @@ -146,8 +146,8 @@ function calculateGroups(messages: CRDTOperation[]) { }); } else { const id = { - item: stringify(typ.relation_item.pub_id), - group: stringify(typ.relation_group.pub_id) + item: stringify((typ.relation_item as any).pub_id), + group: stringify((typ.relation_group as any).pub_id) }; const latest = (() => { diff --git a/interface/app/$libraryId/tag/$id.tsx b/interface/app/$libraryId/tag/$id.tsx index 95aa9e2e2..d8ac07d3e 100644 --- a/interface/app/$libraryId/tag/$id.tsx +++ b/interface/app/$libraryId/tag/$id.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { ObjectKindEnum, ObjectOrder, useLibraryQuery } from '@sd/client'; +import { ObjectKindEnum, ObjectOrder, useCache, useLibraryQuery, useNodes } from '@sd/client'; import { LocationIdParamsSchema } from '~/app/route-schemas'; import { Icon } from '~/components'; import { useRouteTitle, useZodRouteParams } from '~/hooks'; @@ -17,9 +17,11 @@ import { TopBarPortal } from '../TopBar/Portal'; export function Component() { const { id: tagId } = useZodRouteParams(LocationIdParamsSchema); - const tag = useLibraryQuery(['tags.get', tagId], { suspense: true }); + const result = useLibraryQuery(['tags.get', tagId], { suspense: true }); + useNodes(result.data?.nodes); + const tag = useCache(result.data?.item); - useRouteTitle(tag.data!.name ?? 'Tag'); + useRouteTitle(tag!.name ?? 'Tag'); const explorerSettings = useExplorerSettings({ settings: useMemo(() => { @@ -32,12 +34,12 @@ export function Component() { const fixedFilters = useMemo( () => [ - { object: { tags: { in: [tag.data!.id] } } }, + { object: { tags: { in: [tag!.id] } } }, ...(explorerSettingsSnapshot.layoutMode === 'media' ? [{ object: { kind: { in: [ObjectKindEnum.Image, ObjectKindEnum.Video] } } }] : []) ], - [tag.data, explorerSettingsSnapshot.layoutMode] + [tag, explorerSettingsSnapshot.layoutMode] ); const search = useSearch({ @@ -53,7 +55,7 @@ export function Component() { ...objects, isFetchingNextPage: objects.query.isFetchingNextPage, settings: explorerSettings, - parent: { type: 'Tag', tag: tag.data! } + parent: { type: 'Tag', tag: tag! } }); return ( @@ -65,9 +67,9 @@ export function Component() {
- {tag?.data?.name} + {tag?.name}
} right={} diff --git a/interface/app/index.tsx b/interface/app/index.tsx index 4f8ba6957..f0e3ae68f 100644 --- a/interface/app/index.tsx +++ b/interface/app/index.tsx @@ -1,9 +1,13 @@ import { useMemo } from 'react'; import { Navigate, Outlet, redirect, useMatches, type RouteObject } from 'react-router-dom'; -import { currentLibraryCache, getCachedLibraries, useCachedLibraries } from '@sd/client'; +import { + currentLibraryCache, + getCachedLibraries, + NormalisedCache, + useCachedLibraries +} from '@sd/client'; import { Dialogs, Toaster } from '@sd/ui'; import { RouterErrorBoundary } from '~/ErrorFallback'; -import { useOperatingSystem } from '~/hooks'; import { useRoutingContext } from '~/RoutingContext'; import { Platform } from '..'; @@ -17,7 +21,7 @@ import './style.scss'; // the `usePlausiblePageViewMonitor` hook, as early as possible (ideally within the layout itself). // the hook should only be included if there's a valid `ClientContext` (so not onboarding) -export const createRoutes = (platform: Platform) => +export const createRoutes = (platform: Platform, cache: NormalisedCache) => [ { Component: () => { @@ -54,7 +58,7 @@ export const createRoutes = (platform: Platform) => return ; }, loader: async () => { - const libraries = await getCachedLibraries(); + const libraries = await getCachedLibraries(cache); const currentLibrary = libraries.find( (l) => l.uuid === currentLibraryCache.id @@ -76,7 +80,7 @@ export const createRoutes = (platform: Platform) => path: ':libraryId', lazy: () => import('./$libraryId/Layout'), loader: async ({ params: { libraryId } }) => { - const libraries = await getCachedLibraries(); + const libraries = await getCachedLibraries(cache); const library = libraries.find((l) => l.uuid === libraryId); if (!library) { diff --git a/interface/app/onboarding/context.tsx b/interface/app/onboarding/context.tsx index 041d79876..9e3b42c63 100644 --- a/interface/app/onboarding/context.tsx +++ b/interface/app/onboarding/context.tsx @@ -5,11 +5,13 @@ import { currentLibraryCache, getOnboardingStore, getUnitFormatStore, + insertLibrary, resetOnboardingStore, telemetryStore, useBridgeMutation, useCachedLibraries, useMultiZodForm, + useNormalisedCache, useOnboardingStore, usePlausibleEvent } from '@sd/client'; @@ -95,6 +97,7 @@ const useFormState = () => { } const createLibrary = useBridgeMutation('library.create'); + const cache = useNormalisedCache(); const submit = handleSubmit( async (data) => { @@ -106,20 +109,16 @@ const useFormState = () => { try { // show creation screen for a bit for smoothness - const [library] = await Promise.all([ + const [libraryRaw] = await Promise.all([ createLibrary.mutateAsync({ name: data['new-library'].name, default_locations: data.locations.locations }), new Promise((res) => setTimeout(res, 500)) ]); - - queryClient.setQueryData(['library.list'], (libraries: any) => { - // The invalidation system beat us to it - if (libraries.find((l: any) => l.uuid === library.uuid)) return libraries; - - return [...(libraries || []), library]; - }); + cache.withNodes(libraryRaw.nodes); + const library = cache.withCache(libraryRaw.item); + insertLibrary(queryClient, library); platform.refreshMenuBar && platform.refreshMenuBar(); diff --git a/interface/components/Devtools.tsx b/interface/components/Devtools.tsx new file mode 100644 index 000000000..acb72e803 --- /dev/null +++ b/interface/components/Devtools.tsx @@ -0,0 +1,24 @@ +import { defaultContext } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { useDebugState } from '@sd/client'; + +export const Devtools = () => { + const debugState = useDebugState(); + + return ( + <> + {debugState.reactQueryDevtools !== 'disabled' ? ( + + ) : null} + + ); +}; diff --git a/interface/hooks/useIsLocationIndexing.ts b/interface/hooks/useIsLocationIndexing.ts index 45c7cbd1e..70308895a 100644 --- a/interface/hooks/useIsLocationIndexing.ts +++ b/interface/hooks/useIsLocationIndexing.ts @@ -16,7 +16,7 @@ export const useIsLocationIndexing = (locationId: number): boolean => { group.jobs.some((job) => { if ( job.name === 'indexer' && - job.metadata?.location.id === locationId && + (job.metadata as any)?.location.id === locationId && (job.status === 'Running' || job.status === 'Queued') ) { return job.completed_task_count === 0; diff --git a/interface/hooks/useRedirectToNewLocation.ts b/interface/hooks/useRedirectToNewLocation.ts index 2b3fe8a42..7101ebbcf 100644 --- a/interface/hooks/useRedirectToNewLocation.ts +++ b/interface/hooks/useRedirectToNewLocation.ts @@ -25,7 +25,7 @@ export const useRedirectToNewLocation = () => { .some( (j) => j.name === 'indexer' && - j.metadata?.location.id === newLocation && + (j.metadata as any)?.location.id === newLocation && (j.completed_task_count > 0 || j.completed_at != null) ); diff --git a/interface/index.tsx b/interface/index.tsx index b1225b7c6..b832df5d4 100644 --- a/interface/index.tsx +++ b/interface/index.tsx @@ -1,8 +1,6 @@ import '@fontsource/inter/variable.css'; import { init, Integrations } from '@sentry/browser'; -import { defaultContext } from '@tanstack/react-query'; -import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import dayjs from 'dayjs'; import advancedFormat from 'dayjs/plugin/advancedFormat'; import duration from 'dayjs/plugin/duration'; @@ -10,9 +8,9 @@ import relativeTime from 'dayjs/plugin/relativeTime'; import { PropsWithChildren, Suspense } from 'react'; import { RouterProvider, RouterProviderProps } from 'react-router-dom'; import { + CacheProvider, NotificationContextProvider, P2PContextProvider, - useDebugState, useInvalidateQuery, useLoadBackendFeatureFlags } from '@sd/client'; @@ -20,6 +18,7 @@ import { TooltipProvider } from '@sd/ui'; import { createRoutes } from './app'; import { P2P, useP2PErrorToast } from './app/p2p'; +import { Devtools } from './components/Devtools'; import { WithPrismTheme } from './components/TextViewer/prism'; import ErrorFallback, { BetterErrorBoundary } from './ErrorFallback'; import { useTheme } from './hooks'; @@ -42,23 +41,6 @@ init({ integrations: [new Integrations.HttpContext(), new Integrations.Dedupe()] }); -const Devtools = () => { - const debugState = useDebugState(); - - // The `context={defaultContext}` part is required for this to work on Windows. - // Why, idk, don't question it - return debugState.reactQueryDevtools !== 'disabled' ? ( - - ) : null; -}; - export type Router = RouterProviderProps['router']; export function SpacedriveRouterProvider(props: { diff --git a/packages/client/src/cache.tsx b/packages/client/src/cache.tsx new file mode 100644 index 000000000..09952a2d5 --- /dev/null +++ b/packages/client/src/cache.tsx @@ -0,0 +1,251 @@ +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'; + +declare global { + interface Window { + __REDUX_DEVTOOLS_EXTENSION__: any; + } +} + +type Store = ReturnType; +type Context = ReturnType; +export type NormalisedCache = ReturnType; + +const defaultStore = () => ({ + nodes: {} as Record> +}); + +const Context = createContext(undefined!); + +export function createCache() { + const cache = proxy(defaultStore()); + return { + cache, + withNodes(data: CacheNode[] | undefined) { + updateNodes(cache, data); + }, + withCache(data: T | undefined): UseCacheResult { + return restore(cache, new Map(), data) 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 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 + console.debug('Removing Cache Key: ', type, id); + delete cache.cache.nodes?.[type]?.[id]; + } + } + + console.debug('Normalised Cache Cleanup', requiredKeys.size, existingKeys.size); + }, 60 * 1000); + return () => clearInterval(interval); + }, [cache.cache, queryClient]); + + return {children}; +} + +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>, item: unknown): 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 result = cache.nodes?.[item.__type]?.[item.__id]; + if (!result) + throw new Error(`Missing node for id '${item.__id}' of type '${item.__type}'`); + + const v = subscribed.get(item.__type); + if (v) { + v.add(item.__id); + } else { + subscribed.set(item.__type, new Set([item.__id])); + } + + return result; + } + + 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) { + 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 copy = { ...item } as any; + delete copy.__type; + delete copy.__id; + + if (!cache.nodes[item.__type]) cache.nodes[item.__type] = {}; + // TODO: This should be a deepmerge but that would break stuff like `size_in_bytes` or `inode` as the arrays are joined. + cache.nodes[item.__type]![item.__id] = copy; + } +} + +export type UseCacheResult = T extends (infer A)[] + ? UseCacheResult
[] + : T extends object + ? T extends { '__type': any; '__id': string; '#type': infer U } + ? UseCacheResult + : { [K in keyof T]: UseCacheResult } + : { [K in keyof T]: UseCacheResult }; + +export function useCache(data: T | undefined) { + const cache = useCacheContext(); + const subscribed = useRef(new Map>()).current; + const [i, setI] = useState(0); // TODO: Remove this + + const state = useMemo( + () => restore(cache.cache, subscribed, data) as UseCacheResult, + // 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 { + set = new Set(); + + 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); + } + } +} diff --git a/packages/client/src/core.ts b/packages/client/src/core.ts index 75b7280e7..cea0b4381 100644 --- a/packages/client/src/core.ts +++ b/packages/client/src/core.ts @@ -8,43 +8,42 @@ export type Procedures = { { key: "buildInfo", input: never, result: BuildInfo } | { key: "cloud.library.get", input: LibraryArgs, result: { uuid: string; name: string; ownerId: string; instances: { id: string; uuid: string; identity: string }[] } | null } | { key: "cloud.library.list", input: never, result: { uuid: string; name: string; ownerId: string; instances: { id: string; uuid: string }[] }[] } | - { key: "ephemeralFiles.getMediaData", input: string, result: MediaMetadata | null } | - { key: "files.get", input: LibraryArgs, 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: FilePath[] } | null } | + { key: "ephemeralFiles.getMediaData", input: string, result: ({ type: "Image" } & ImageMetadata) | ({ type: "Video" } & VideoMetadata) | ({ type: "Audio" } & AudioMetadata) | null } | + { key: "files.get", input: LibraryArgs, result: { item: Reference; nodes: CacheNode[] } | null } | { key: "files.getConvertableImageExtensions", input: never, result: string[] } | { key: "files.getMediaData", input: LibraryArgs, result: MediaMetadata } | { key: "files.getPath", input: LibraryArgs, result: string | null } | { key: "invalidation.test-invalidate", input: never, result: number } | { key: "jobs.isActive", input: LibraryArgs, result: boolean } | { key: "jobs.reports", input: LibraryArgs, result: JobGroup[] } | - { key: "library.list", input: never, result: LibraryConfigWrapped[] } | + { key: "library.list", input: never, result: NormalisedResults } | { key: "library.statistics", input: LibraryArgs, result: Statistics } | - { key: "locations.get", input: LibraryArgs, result: Location | null } | - { key: "locations.getWithRules", input: LibraryArgs, result: LocationWithIndexerRules | null } | - { key: "locations.indexer_rules.get", input: LibraryArgs, result: IndexerRule } | - { key: "locations.indexer_rules.list", input: LibraryArgs, result: IndexerRule[] } | - { key: "locations.indexer_rules.listForLocation", input: LibraryArgs, result: IndexerRule[] } | - { key: "locations.list", input: LibraryArgs, result: Location[] } | + { key: "locations.get", input: LibraryArgs, result: { item: Reference; nodes: CacheNode[] } | null } | + { key: "locations.getWithRules", input: LibraryArgs, result: { item: Reference; nodes: CacheNode[] } | null } | + { key: "locations.indexer_rules.get", input: LibraryArgs, result: NormalisedResult } | + { key: "locations.indexer_rules.list", input: LibraryArgs, result: NormalisedResults } | + { key: "locations.indexer_rules.listForLocation", input: LibraryArgs, result: NormalisedResults } | + { key: "locations.list", input: LibraryArgs, result: NormalisedResults } | { key: "locations.systemLocations", input: never, result: SystemLocations } | { key: "nodeState", input: never, result: NodeState } | { key: "nodes.listLocations", input: LibraryArgs, result: ExplorerItem[] } | { key: "notifications.dismiss", input: NotificationId, result: null } | { key: "notifications.dismissAll", input: never, result: null } | { key: "notifications.get", input: never, result: Notification[] } | - { key: "p2p.state", input: never, result: P2PState } | { key: "preferences.get", input: LibraryArgs, result: LibraryPreferences } | - { key: "search.ephemeralPaths", input: LibraryArgs, result: NonIndexedFileSystemEntries } | + { key: "search.ephemeralPaths", input: LibraryArgs, result: EphemeralPathsResult } | { key: "search.objects", input: LibraryArgs, result: SearchData } | { key: "search.objectsCount", input: LibraryArgs<{ filters?: SearchFilterArgs[] }>, result: number } | { key: "search.paths", input: LibraryArgs, result: SearchData } | { key: "search.pathsCount", input: LibraryArgs<{ filters?: SearchFilterArgs[] }>, result: number } | - { key: "search.saved.get", input: LibraryArgs, result: SavedSearch | null } | + { key: "search.saved.get", input: LibraryArgs, result: { id: number; pub_id: number[]; search: string | null; filters: string | null; name: string | null; icon: string | null; description: string | null; date_created: string | null; date_modified: string | null } | null } | { key: "search.saved.list", input: LibraryArgs, result: SavedSearch[] } | { key: "sync.messages", input: LibraryArgs, result: CRDTOperation[] } | - { key: "tags.get", input: LibraryArgs, result: Tag | null } | - { key: "tags.getForObject", input: LibraryArgs, result: Tag[] } | - { key: "tags.getWithObjects", input: LibraryArgs, result: { [key: number]: { date_created: string | null; object: { id: number } }[] } } | - { key: "tags.list", input: LibraryArgs, result: Tag[] } | - { key: "volumes.list", input: never, result: Volume[] }, + { key: "tags.get", input: LibraryArgs, result: { item: Reference; nodes: CacheNode[] } | null } | + { key: "tags.getForObject", input: LibraryArgs, result: NormalisedResults } | + { key: "tags.getWithObjects", input: LibraryArgs, result: { [key in number]: ({ date_created: string | null; object: { id: number } })[] } } | + { key: "tags.list", input: LibraryArgs, result: NormalisedResults } | + { key: "volumes.list", input: never, result: NormalisedResults }, mutations: { key: "api.sendFeedback", input: Feedback, result: null } | { key: "auth.logout", input: never, result: null } | @@ -78,7 +77,7 @@ export type Procedures = { { key: "jobs.objectValidator", input: LibraryArgs, result: null } | { key: "jobs.pause", input: LibraryArgs, result: null } | { key: "jobs.resume", input: LibraryArgs, result: null } | - { key: "library.create", input: CreateLibraryArgs, result: LibraryConfigWrapped } | + { key: "library.create", input: CreateLibraryArgs, result: NormalisedResult } | { key: "library.delete", input: string, result: null } | { key: "library.edit", input: EditLibraryArgs, result: null } | { key: "locations.addLibrary", input: LibraryArgs, result: number | null } | @@ -96,7 +95,7 @@ export type Procedures = { { key: "notifications.testLibrary", input: LibraryArgs, result: null } | { key: "p2p.acceptSpacedrop", input: [string, string | null], result: null } | { key: "p2p.cancelSpacedrop", input: string, result: null } | - { key: "p2p.pair", input: string, result: number } | + { key: "p2p.pair", input: RemoteIdentity, result: number } | { key: "p2p.pairingResponse", input: [number, PairingDecision], result: null } | { key: "p2p.spacedrop", input: SpacedropArgs, result: string } | { key: "preferences.update", input: LibraryArgs, result: null } | @@ -139,13 +138,31 @@ export type CRDTOperation = { instance: string; timestamp: number; id: string; t export type CRDTOperationType = SharedOperation | RelationOperation +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_enabled: boolean | null; p2p_port: MaybeUndefined } export type ColorProfile = "Normal" | "Custom" | "HDRNoOriginal" | "HDRWithOriginal" | "OriginalForHDR" | "Panorama" | "PortraitHDR" | "Portrait" -export type Composite = "Unknown" | "False" | "General" | "Live" +export type Composite = +/** + * The data is present, but we're unable to determine what they mean + */ +"Unknown" | +/** + * Not a composite image + */ +"False" | +/** + * A general composite image + */ +"General" | +/** + * The composite image was captured while shooting + */ +"Live" export type ConvertImageArgs = { location_id: number; file_path_id: number; delete_src: boolean; desired_extension: ConvertableExtension; quality_percentage: number | null } @@ -173,6 +190,8 @@ export type EphemeralPathOrder = { field: "name"; value: SortOrder } | { field: export type EphemeralPathSearchArgs = { path: string; withHiddenFiles: boolean; order?: EphemeralPathOrder | null } +export type EphemeralPathsResult = { entries: Reference[]; errors: Error[]; nodes: CacheNode[] } + export type EphemeralRenameFileArgs = { kind: EphemeralRenameKind } export type EphemeralRenameKind = { One: EphemeralRenameOne } | { Many: EphemeralRenameMany } @@ -192,7 +211,7 @@ export type ExplorerItem = { type: "Path"; has_local_thumbnail: boolean; thumbna export type ExplorerLayout = "grid" | "list" | "media" -export type ExplorerSettings = { layoutMode: ExplorerLayout | null; gridItemSize: number | null; gridGap: number | null; mediaColumns: number | null; mediaAspectSquare: boolean | null; mediaViewWithDescendants: boolean | null; openOnDoubleClick: DoubleClickAction | null; showBytesInGridView: boolean | null; colVisibility: { [key: string]: boolean } | null; colSizes: { [key: string]: number } | null; order?: TOrder | null; showHiddenFiles?: boolean } +export type ExplorerSettings = { layoutMode: ExplorerLayout | null; gridItemSize: number | null; gridGap: number | null; mediaColumns: number | null; mediaAspectSquare: boolean | null; mediaViewWithDescendants: boolean | null; openOnDoubleClick: DoubleClickAction | null; showBytesInGridView: boolean | null; colVisibility: { [key in string]: boolean } | null; colSizes: { [key in string]: number } | null; order?: TOrder | null; showHiddenFiles?: boolean } export type Feedback = { message: string; emoji: number } @@ -218,11 +237,52 @@ export type FilePathOrder = { field: "name"; value: SortOrder } | { field: "size export type FilePathSearchArgs = { take?: number | null; orderAndPagination?: OrderAndPagination | null; filters?: SearchFilterArgs[]; groupDirectories?: boolean } -export type FilePathWithObject = { 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: Object | null } +export type FilePathWithObject = { 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 } | null } -export type Flash = { mode: FlashMode; fired: boolean | null; returned: boolean | null; red_eye_reduction: boolean | null } +export type Flash = { +/** + * Specifies how flash was used (on, auto, off, forced, onvalid) + * + * [`FlashMode::Unknown`] isn't a valid EXIF state, but it's included as the default, + * just in case we're unable to correctly match it to a known (valid) state. + * + * This type should only ever be evaluated if flash EXIF data is present, so having this as a non-option shouldn't be an issue. + */ +mode: FlashMode; +/** + * Did the flash actually fire? + */ +fired: boolean | null; +/** + * Did flash return to the camera? (Unsure of the meaning) + */ +returned: boolean | null; +/** + * Was red eye reduction used? + */ +red_eye_reduction: boolean | null } -export type FlashMode = "Unknown" | "On" | "Off" | "Auto" | "Forced" +export type FlashMode = +/** + * The data is present, but we're unable to determine what they mean + */ +"Unknown" | +/** + * FLash was on + */ +"On" | +/** + * Flash was off + */ +"Off" | +/** + * Flash was set to automatically fire in certain conditions + */ +"Auto" | +/** + * Flash was forcefully fired + */ +"Forced" export type FromPattern = { pattern: string; replace_all: boolean } @@ -232,10 +292,6 @@ export type GenerateThumbsForLocationArgs = { id: number; path: string; regenera export type GetAll = { backups: Backup[]; directory: string } -export type GetArgs = { id: number } - -export type Header = { id: string; timestamp: string; library_id: string; library_name: string } - export type IdentifyUniqueFilesArgs = { id: number; path: string } export type ImageMetadata = { resolution: Resolution; date_taken: MediaDate | null; location: MediaLocation | null; camera_data: CameraData; artist: string | null; description: string | null; copyright: string | null; exif_version: string | null } @@ -262,10 +318,12 @@ export type JobGroup = { id: string; action: string | null; status: JobStatus; c export type JobProgressEvent = { id: string; library_id: string; task_count: number; completed_task_count: number; phase: string; message: string; estimated_completion: string } -export type JobReport = { id: string; name: string; action: string | null; data: number[] | null; metadata: { [key: string]: any } | null; is_background: boolean; errors_text: string[]; created_at: string | null; started_at: string | null; completed_at: string | null; parent_id: string | null; status: JobStatus; task_count: number; completed_task_count: number; phase: string; message: string; estimated_completion: string } +export type JobReport = { id: string; name: string; action: string | null; data: number[] | null; metadata: { [key in string]: JsonValue } | null; is_background: boolean; errors_text: string[]; created_at: string | null; started_at: string | null; completed_at: string | null; parent_id: string | null; status: JobStatus; task_count: number; completed_task_count: number; phase: string; message: string; estimated_completion: string } export type JobStatus = "Queued" | "Running" | "Completed" | "Canceled" | "Failed" | "Paused" | "CompletedWithErrors" +export type JsonValue = null | boolean | number | string | JsonValue[] | { [key in string]: JsonValue } + /** * Can wrap a query argument to require it to contain a `library_id` and provide helpers for working with libraries. */ @@ -274,15 +332,27 @@ export type LibraryArgs = { library_id: string; arg: T } /** * LibraryConfig holds the configuration for a specific library. This is stored as a '{uuid}.sdlibrary' file. */ -export type LibraryConfig = { name: LibraryName; description: string | null; instance_id: number; version: LibraryConfigVersion } +export type LibraryConfig = { +/** + * name is the display name of the library. This is used in the UI and is set by the user. + */ +name: LibraryName; +/** + * description is a user set description of the library. This is used in the UI and is set by the user. + */ +description: string | null; +/** + * id of the current instance so we know who this `.db` is. This can be looked up within the `Instance` table. + */ +instance_id: number; version: LibraryConfigVersion } export type LibraryConfigVersion = "V0" | "V1" | "V2" | "V3" | "V4" | "V5" | "V6" | "V7" | "V8" | "V9" -export type LibraryConfigWrapped = { uuid: string; instance_id: string; instance_public_key: string; config: LibraryConfig } +export type LibraryConfigWrapped = { uuid: string; instance_id: string; instance_public_key: RemoteIdentity; config: LibraryConfig } export type LibraryName = string -export type LibraryPreferences = { location?: { [key: string]: LocationSettings } } +export type LibraryPreferences = { location?: { [key in string]: LocationSettings } } export type LightScanArgs = { location_id: number; sub_path: string } @@ -309,16 +379,9 @@ export type LocationSettings = { explorer: ExplorerSettings } */ 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 LocationWithIndexerRules = { 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: { indexer_rule: IndexerRule }[] } +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[] } -/** - * The configuration for the P2P Manager - * DO NOT MAKE BREAKING CHANGES - This is embedded in the `node_config.json` - * For future me: `Keypair` is not on here cause hot reloading it hard. - */ -export type ManagerConfig = { enabled: boolean; port?: number | null } - -export type MaybeUndefined = null | null | T +export type MaybeUndefined = null | T export type MediaDataOrder = { field: "epochTime"; value: SortOrder } @@ -326,7 +389,7 @@ export type MediaDataOrder = { field: "epochTime"; value: SortOrder } * This can be either naive with no TZ (`YYYY-MM-DD HH-MM-SS`) or UTC (`YYYY-MM-DD HH-MM-SS ±HHMM`), * where `±HHMM` is the timezone data. It may be negative if West of the Prime Meridian, or positive if East. */ -export type MediaDate = string | string +export type MediaDate = string export type MediaLocation = { latitude: number; longitude: number; pluscode: PlusCode; altitude: number | null; direction: number | null } @@ -334,12 +397,32 @@ export type MediaMetadata = ({ type: "Image" } & ImageMetadata) | ({ type: "Vide export type NodePreferences = { thumbnailer: ThumbnailerPreferences } -export type NodeState = ({ id: string; name: string; p2p_enabled: boolean; p2p_port: number | null; features: BackendFeature[]; preferences: NodePreferences }) & { data_path: string; p2p: P2PStatus } - -export type NonIndexedFileSystemEntries = { entries: ExplorerItem[]; errors: Error[] } +export type NodeState = ({ +/** + * id is a unique identifier for the current node. Each node has a public identifier (this one) and is given a local id for each library (done within the library code). + */ +id: string; +/** + * name is the display name of the current node. This is set by the user and is shown in the UI. // TODO: Length validation so it can fit in DNS record + */ +name: string; p2p_enabled: boolean; p2p_port: number | null; features: BackendFeature[]; preferences: NodePreferences }) & { data_path: string; p2p: P2PStatus } 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` and `CacheNode`'s + * + * You don't need to use this, it's just a shortcut to avoid having to write out the full type everytime. + */ +export type NormalisedResult = { item: Reference; nodes: CacheNode[] } + +/** + * A type that can be used to return a group of `Reference` and `CacheNode`'s + * + * You don't need to use this, it's just a shortcut to avoid having to write out the full type everytime. + */ +export type NormalisedResults = { items: Reference[]; nodes: CacheNode[] } + /** * Represents a single notification. */ @@ -369,6 +452,8 @@ 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: FilePath[] } +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[] } + /** * Represents the operating system which the remote peer is running. * This is not used internally and predominantly is designed to be used for display purposes by the embedding application. @@ -382,9 +467,7 @@ export type Orientation = "Normal" | "CW90" | "CW180" | "CW270" | "MirroredVerti /** * TODO: P2P event for the frontend */ -export type P2PEvent = { type: "DiscoveredPeer"; identity: string; metadata: PeerMetadata } | { type: "ExpiredPeer"; identity: string } | { type: "ConnectedPeer"; identity: string } | { type: "DisconnectedPeer"; identity: string } | { type: "SpacedropRequest"; id: string; identity: string; peer_name: string; files: string[] } | { type: "SpacedropProgress"; id: string; percent: number } | { type: "SpacedropTimedout"; id: string } | { type: "SpacedropRejected"; id: string } | { type: "PairingRequest"; id: number; name: string; os: OperatingSystem } | { type: "PairingProgress"; id: number; status: PairingStatus } - -export type P2PState = { node: { [key: string]: PeerStatus }; libraries: ([string, { [key: string]: PeerStatus }])[]; self_peer_id: PeerId; self_identity: string; config: ManagerConfig; manager_connected: { [key: PeerId]: string }; manager_connections: PeerId[]; dicovery_services: { [key: string]: { [key: string]: string } | null }; discovery_discovered: { [key: string]: { [key: string]: [PeerId, { [key: string]: string }, string[]] } }; discovery_known: { [key: string]: string[] } } +export type P2PEvent = { type: "DiscoveredPeer"; identity: RemoteIdentity; metadata: PeerMetadata } | { type: "ExpiredPeer"; identity: RemoteIdentity } | { type: "ConnectedPeer"; identity: RemoteIdentity } | { type: "DisconnectedPeer"; identity: RemoteIdentity } | { type: "SpacedropRequest"; id: string; identity: RemoteIdentity; peer_name: string; files: string[] } | { type: "SpacedropProgress"; id: string; percent: number } | { type: "SpacedropTimedout"; id: string } | { type: "SpacedropRejected"; id: string } | { type: "PairingRequest"; id: number; name: string; os: OperatingSystem } | { type: "PairingProgress"; id: number; status: PairingStatus } export type P2PStatus = { ipv4: ListenerStatus; ipv6: ListenerStatus } @@ -392,19 +475,27 @@ export type PairingDecision = { decision: "accept"; libraryId: string } | { deci export type PairingStatus = { type: "EstablishingConnection" } | { type: "PairingRequested" } | { type: "LibraryAlreadyExists" } | { type: "PairingDecisionRequest" } | { type: "PairingInProgress"; data: { library_name: string; library_description: string | null } } | { type: "InitialSyncProgress"; data: number } | { type: "PairingComplete"; data: string } | { type: "PairingRejected" } -export type PeerId = string - export type PeerMetadata = { name: string; operating_system: OperatingSystem | null; version: string | null } -export type PeerStatus = "Unavailable" | "Discovered" | "Connected" - export type PlusCode = string export type Range = { from: T } | { to: T } -export type RelationOperation = { relation_item: any; relation_group: any; relation: string; data: RelationOperationData } +/** + * 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 = { __type: string; __id: string; "#type": T } -export type RelationOperationData = "c" | { u: { field: string; value: any } } | "d" +export type RelationOperation = { relation_item: JsonValue; relation_group: JsonValue; relation: string; data: RelationOperationData } + +export type RelationOperationData = "c" | { u: { field: string; value: JsonValue } } | "d" + +export type RemoteIdentity = string export type RenameFileArgs = { location_id: number; kind: RenameKind } @@ -422,11 +513,9 @@ export type Response = { Start: { user_code: string; verification_url: string; v export type RuleKind = "AcceptFilesByGlob" | "RejectFilesByGlob" | "AcceptIfChildrenDirectoriesArePresent" | "RejectIfChildrenDirectoriesArePresent" -export type SanitisedNodeConfig = { id: string; name: string; p2p_enabled: boolean; p2p_port: number | null; features: BackendFeature[]; preferences: NodePreferences } - export type SavedSearch = { id: number; pub_id: number[]; 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 = { cursor: number[] | null; items: T[] } +export type SearchData = { cursor: number[] | null; items: Reference[]; nodes: CacheNode[] } export type SearchFilterArgs = { filePath: FilePathFilterArgs } | { object: ObjectFilterArgs } @@ -434,15 +523,19 @@ export type SetFavoriteArgs = { id: number; favorite: boolean } export type SetNoteArgs = { id: number; note: string | null } -export type SharedOperation = { record_id: any; model: string; data: SharedOperationData } +export type SharedOperation = { record_id: JsonValue; model: string; data: SharedOperationData } -export type SharedOperationData = "c" | { u: { field: string; value: any } } | "d" +export type SharedOperationData = "c" | { u: { field: string; value: JsonValue } } | "d" -export type SingleInvalidateOperationEvent = { key: string; arg: any; result: any | null } +export type SingleInvalidateOperationEvent = { +/** + * This fields are intentionally private. + */ +key: string; arg: JsonValue; result: JsonValue | null } export type SortOrder = "Asc" | "Desc" -export type SpacedropArgs = { identity: string; file_path: string[] } +export type SpacedropArgs = { identity: RemoteIdentity; file_path: string[] } export type Statistics = { id: number; date_captured: string; total_object_count: number; library_db_size: string; total_bytes_used: string; total_bytes_capacity: string; total_unique_bytes: string; total_bytes_free: string; preview_media_bytes: string } diff --git a/packages/client/src/hooks/useClientContext.tsx b/packages/client/src/hooks/useClientContext.tsx index f28d7976c..0eb9f064f 100644 --- a/packages/client/src/hooks/useClientContext.tsx +++ b/packages/client/src/hooks/useClientContext.tsx @@ -1,14 +1,15 @@ -import { createContext, PropsWithChildren, useContext, useMemo } from 'react'; +import { createContext, PropsWithChildren, useContext, useEffect, useMemo } from 'react'; +import { NormalisedCache, useCache, useNodes } from '../cache'; import { LibraryConfigWrapped } from '../core'; import { valtioPersist } from '../lib'; import { nonLibraryClient, useBridgeQuery } from '../rspc'; // The name of the localStorage key for caching library data -const libraryCacheLocalStorageKey = 'sd-library-list'; +const libraryCacheLocalStorageKey = 'sd-library-list2'; // `2` is because the format of this underwent a breaking change when introducing normalised caching -export const useCachedLibraries = () => - useBridgeQuery(['library.list'], { +export const useCachedLibraries = () => { + const result = useBridgeQuery(['library.list'], { keepPreviousData: true, initialData: () => { const cachedData = localStorage.getItem(libraryCacheLocalStorageKey); @@ -26,22 +27,33 @@ export const useCachedLibraries = () => }, onSuccess: (data) => localStorage.setItem(libraryCacheLocalStorageKey, JSON.stringify(data)) }); + useNodes(result.data?.nodes); -export async function getCachedLibraries() { + return { + ...result, + data: useCache(result.data?.items) + }; +}; + +export async function getCachedLibraries(cache: NormalisedCache) { const cachedData = localStorage.getItem(libraryCacheLocalStorageKey); if (cachedData) { // If we fail to load cached data, it's fine try { - return JSON.parse(cachedData) as LibraryConfigWrapped[]; + const data = JSON.parse(cachedData); + cache.withNodes(data.nodes); + return cache.withCache(data.items) as LibraryConfigWrapped[]; } catch (e) { console.error("Error loading cached 'sd-library-list' data", e); } } - const libraries = await nonLibraryClient.query(['library.list']); + const result = await nonLibraryClient.query(['library.list']); + cache.withNodes(result.nodes); + const libraries = cache.withCache(result.items); - localStorage.setItem(libraryCacheLocalStorageKey, JSON.stringify(libraries)); + localStorage.setItem(libraryCacheLocalStorageKey, JSON.stringify(result)); return libraries; } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 22b9eadee..888d3e8cc 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -27,3 +27,4 @@ export * from './core'; export * from './utils'; export * from './lib'; export * from './form'; +export * from './cache'; diff --git a/packages/client/src/rspc.tsx b/packages/client/src/rspc.tsx index 269da036f..a3574eedc 100644 --- a/packages/client/src/rspc.tsx +++ b/packages/client/src/rspc.tsx @@ -100,7 +100,7 @@ export function useInvalidateQuery() { for (const op of ops) { match(op) .with({ type: 'single', data: P.select() }, (op) => { - let key = [op.key]; + let key: any[] = [op.key]; if (op.arg !== null) { key = key.concat(op.arg); } diff --git a/packages/client/src/utils/explorerItem.ts b/packages/client/src/utils/explorerItem.ts index 35b3d10c5..eb2ab9d85 100644 --- a/packages/client/src/utils/explorerItem.ts +++ b/packages/client/src/utils/explorerItem.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; -import type { ExplorerItem, FilePath, NonIndexedPathItem, Object } from '../core'; +import type { Object } from '..'; +import type { ExplorerItem, FilePath, NonIndexedPathItem } from '../core'; import { byteSize } from '../lib'; import { ObjectKind } from './objectKind'; diff --git a/packages/client/src/utils/index.ts b/packages/client/src/utils/index.ts index e97cd758d..d5a8f2503 100644 --- a/packages/client/src/utils/index.ts +++ b/packages/client/src/utils/index.ts @@ -1,4 +1,6 @@ -import { ExplorerItem } from '../core'; +import { QueryClient } from '@tanstack/react-query'; + +import { ExplorerItem, LibraryConfigWrapped } from '../core'; export * from './objectKind'; export * from './explorerItem'; @@ -39,3 +41,28 @@ export function formatNumber(n: number) { if (!n) return '0'; return Intl.NumberFormat().format(n); } + +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; + + return { + items: [ + ...(libraries.items || []), + { + __type: 'LibraryConfigWrapped', + __id: library.uuid + } + ], + nodes: [ + ...(libraries.nodes || []), + { + __type: 'LibraryConfigWrapped', + __id: library.uuid, + ...library + } + ] + }; + }); +} diff --git a/packages/client/src/utils/jobs/index.ts b/packages/client/src/utils/jobs/index.ts index b55ef0c9a..b5429dbdf 100644 --- a/packages/client/src/utils/jobs/index.ts +++ b/packages/client/src/utils/jobs/index.ts @@ -34,7 +34,7 @@ export function getTotalTasks(jobs: JobReport[]) { } export function getJobNiceActionName(action: string, completed: boolean, job?: JobReport) { - const name = job?.metadata?.location?.name || 'Unknown'; + const name = (job?.metadata?.location as any)?.name || 'Unknown'; switch (action) { case 'scan_location': return completed ? `Added location "${name}"` : `Adding location "${name}"`; diff --git a/packages/client/src/utils/jobs/useJobInfo.tsx b/packages/client/src/utils/jobs/useJobInfo.tsx index 4a716c9c2..c0e9fc9ff 100644 --- a/packages/client/src/utils/jobs/useJobInfo.tsx +++ b/packages/client/src/utils/jobs/useJobInfo.tsx @@ -20,12 +20,12 @@ export function useJobInfo(job: JobReport, realtimeUpdate: JobProgressEvent | nu const isRunning = job.status === 'Running', isQueued = job.status === 'Queued', isPaused = job.status === 'Paused', - indexedPath = job.metadata?.data?.location.path, + indexedPath = (job.metadata?.data as any)?.location.path, taskCount = realtimeUpdate?.task_count || job.task_count, completedTaskCount = realtimeUpdate?.completed_task_count || job.completed_task_count, phase = realtimeUpdate?.phase, meta = job.metadata, - output = meta?.output?.run_metadata; + output = (meta?.output as any)?.run_metadata; const data = { isRunning,