refactor: resolve() internal links instead of plain hrefs

Type-checks every link against the real route tree. Caught a dead one: the
book page linked tags to /tag/{id}, a route that has never existed.
This commit is contained in:
2026-08-12 11:08:09 -04:00
parent 51c31e6bf6
commit d6207b5743
19 changed files with 588 additions and 487 deletions
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button';
@@ -36,8 +37,8 @@
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
// Rename files to use webkitRelativePath so directory structure is preserved through form submission
const renamedFiles = uploadedFiles.map(f =>
new File([f], f.webkitRelativePath || f.name, { type: f.type })
const renamedFiles = uploadedFiles.map(
(f) => new File([f], f.webkitRelativePath || f.name, { type: f.type })
);
uploadBooks.fields.files.set([...Array.from(files), ...renamedFiles]);
if (autoUploadOnDrop && files.length > 0) {
@@ -55,9 +56,12 @@
let libraryId = books.items[0].library_id;
libraryState.setActive(libraryId);
if (books.items.length === 1) {
goto(`/book/${books.items[0].id}`);
goto(resolve('/(root)/(library)/book/[bookId]', { bookId: String(books.items[0].id) }));
} else {
goto(`/library/${libraryId}/view?orderBy=created_at&sortOrder=desc`);
// The path is resolved; the query string is what the rule cannot see past.
const view = resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(libraryId) });
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(`${view}?orderBy=created_at&sortOrder=desc`);
}
}
</script>
@@ -88,8 +92,10 @@
}
// Update library book count
const count = uploadBooks.result.total
libraryState.libraries.find(lib => uploadBooks.fields.library_id.value() == lib.id.toString())!.total += count
const count = uploadBooks.result.total;
libraryState.libraries.find(
(lib) => uploadBooks.fields.library_id.value() == lib.id.toString()
)!.total += count;
// Reset the files field
uploadBooks.fields.files.set([]);
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { Separator } from '$lib/components/ui/separator/index.js';
import ChitaiMark from '$lib/components/icons/chitai-mark.svelte';
@@ -27,7 +28,7 @@
// directly in the markup keeps it static.
const header = $derived({
title: 'chitai',
url: `/library/${libraryState.activeLibrary!.id}`
url: resolve('/(root)/(library)/library/[libraryId]', { libraryId: String(libraryState.activeLibrary!.id) })
});
</script>
@@ -40,6 +41,8 @@
-->
<Sidebar.MenuButton class="mt-1 -ml-1.5">
{#snippet child({ props })}
<!-- header.url is built with resolve(); the rule cannot trace the variable. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={header.url} {...props}>
<ChitaiMark class="mr-3 size-7!" />
<span class="font-serif text-xl tracking-tight">{header.title}</span>
+123 -104
View File
@@ -1,118 +1,137 @@
<script lang="ts">
import { page } from '$app/state';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { Badge } from "$lib/components/ui/badge/index.js";
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
import { getBookshelfState } from '$lib/state/bookshelf.svelte';
import { getLibraryState } from '$lib/state/library.svelte';
import { House, LibraryBig, Rows3, ChevronRightIcon } from '@lucide/svelte';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
import { getBookshelfState } from '$lib/state/bookshelf.svelte';
import { getLibraryState } from '$lib/state/library.svelte';
import { House, LibraryBig, Rows3, ChevronRightIcon } from '@lucide/svelte';
const libraryState = getLibraryState();
const bookshelfState = getBookshelfState();
const sidebar = useSidebar();
const libraryState = getLibraryState();
const bookshelfState = getBookshelfState();
const sidebar = useSidebar();
// Owned here so the collapsed rail can force it open when it expands the
// sidebar. Previously open={isActive}, which was always false — the Shelves
// item has no route of its own, so pathname never matched.
let shelvesOpen = $state(false);
// Owned here so the collapsed rail can force it open when it expands the
// sidebar. Previously open={isActive}, which was always false — the Shelves
// item has no route of its own, so pathname never matched.
let shelvesOpen = $state(false);
// Deliberately a plain const, not $derived. These objects hold component
// references, and `<item.icon />` is a dynamic component — if the array were
// rebuilt on every navigation the icons would remount, flashing and shifting
// layout. Only the url and active state need to be reactive, so they are
// computed per-item in the markup instead.
const items = [
{ title: 'Home', icon: House, path: (id?: number) => `/library/${id}` },
{ title: 'Library', icon: LibraryBig, path: (id?: number) => `/library/${id}/view` },
{ title: 'Shelves', icon: Rows3, path: () => '#', shelves: [] }
];
// Deliberately a plain const, not $derived. These objects hold component
// references, and `<item.icon />` is a dynamic component — if the array were
// rebuilt on every navigation the icons would remount, flashing and shifting
// layout. Only the url and active state need to be reactive, so they are
// computed per-item in the markup instead.
//
// Active state is matched on route id rather than pathname. resolve() returns
// an absolute path on the client but a relative one during SSR, so comparing
// it to page.url.pathname would be false on the server and true after
// hydration — the highlight would flash in.
const items = [
{
title: 'Home',
icon: House,
routeId: '/(root)/(library)/library/[libraryId]',
path: (id?: number) => resolve('/(root)/(library)/library/[libraryId]', { libraryId: String(id ?? '') })
},
{
title: 'Library',
icon: LibraryBig,
routeId: '/(root)/(library)/library/[libraryId]/view',
path: (id?: number) => resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(id ?? '') })
},
{ title: 'Shelves', icon: Rows3, routeId: null, path: () => '#', shelves: [] }
];
</script>
{#snippet shelvesTrigger(item: { title: string; icon: typeof House })}
<item.icon class="scale-125" />
<span class="text-md ml-2">{item.title}</span>
<ChevronRightIcon
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
<item.icon class="scale-125" />
<span class="text-md ml-2">{item.title}</span>
<ChevronRightIcon
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
{/snippet}
<Sidebar.Group>
<Sidebar.Menu>
{#each items as item (item.title)}
{@const url = item.path(libraryState.activeLibrary?.id)}
{@const isActive = page.url.pathname === url}
{#if 'shelves' in item}
<Collapsible.Root bind:open={shelvesOpen} class="group/collapsible">
{#snippet child({ props })}
<Sidebar.MenuItem {...props}>
{#if sidebar.state === 'collapsed'}
<!--
<Sidebar.Menu>
{#each items as item (item.title)}
{@const url = item.path(libraryState.activeLibrary?.id)}
{@const isActive = item.routeId !== null && page.route.id === item.routeId}
{#if 'shelves' in item}
<Collapsible.Root bind:open={shelvesOpen} class="group/collapsible">
{#snippet child({ props })}
<Sidebar.MenuItem {...props}>
{#if sidebar.state === 'collapsed'}
<!--
Sidebar.MenuSub is group-data-[collapsible=icon]:hidden, so
toggling in the rail opens a list nobody can see. Expand the
sidebar and open the section instead of toggling.
-->
<Sidebar.MenuButton
tooltipContent={item.title}
class="h-10"
onclick={() => {
sidebar.setOpen(true);
shelvesOpen = true;
}}
>
{@render shelvesTrigger(item)}
</Sidebar.MenuButton>
{:else}
<Collapsible.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton {...props} tooltipContent={item.title} class="h-10">
{@render shelvesTrigger(item)}
</Sidebar.MenuButton>
{/snippet}
</Collapsible.Trigger>
{/if}
<Collapsible.Content>
<Sidebar.MenuSub class="w-full">
{#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) ?? [] as shelf (shelf.id)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton>
{#snippet child({ props })}
<a
href={`/library/${libraryState.activeLibrary!.id}/view?shelves=${shelf.id}`}
{...props}
>
<Badge
variant="outline"
class="scale-90 font-semibold bg-sidebar-primary text-sidebar-primary-foreground mr-1">
{shelf.total}
</Badge>
<span>{shelf.title}</span>
</a>
{/snippet}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
</Sidebar.MenuItem>
{/snippet}
</Collapsible.Root>
{:else}
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={isActive} tooltipContent={item.title} class="h-10">
{#snippet child({ props })}
<a href={url} {...props}>
{#if item.icon}
<item.icon class="scale-125" />
{/if}
<span class="text-md pl-2">{item.title}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/if}
{/each}
</Sidebar.Menu>
</Sidebar.Group>
<Sidebar.MenuButton
tooltipContent={item.title}
class="h-10"
onclick={() => {
sidebar.setOpen(true);
shelvesOpen = true;
}}
>
{@render shelvesTrigger(item)}
</Sidebar.MenuButton>
{:else}
<Collapsible.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton {...props} tooltipContent={item.title} class="h-10">
{@render shelvesTrigger(item)}
</Sidebar.MenuButton>
{/snippet}
</Collapsible.Trigger>
{/if}
<Collapsible.Content>
<Sidebar.MenuSub class="w-full">
{#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) ?? [] as shelf (shelf.id)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton>
{#snippet child({ props })}
<a
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
libraryId: String(libraryState.activeLibrary!.id)
})}?shelves={shelf.id}"
{...props}
>
<Badge
variant="outline"
class="mr-1 scale-90 bg-sidebar-primary font-semibold text-sidebar-primary-foreground"
>
{shelf.total}
</Badge>
<span>{shelf.title}</span>
</a>
{/snippet}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
</Sidebar.MenuItem>
{/snippet}
</Collapsible.Root>
{:else}
<Sidebar.MenuItem>
<Sidebar.MenuButton {isActive} tooltipContent={item.title} class="h-10">
{#snippet child({ props })}
<!-- item.path() calls resolve(); the rule cannot trace the variable. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={url} {...props}>
{#if item.icon}
<item.icon class="scale-125" />
{/if}
<span class="text-md pl-2">{item.title}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/if}
{/each}
</Sidebar.Menu>
</Sidebar.Group>
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import * as Avatar from '$lib/components/ui/avatar/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
@@ -31,13 +32,16 @@
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar.Root class="size-8 rounded-lg">
<Avatar.Fallback class="rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<Avatar.Fallback
class="rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
>
{initials}
</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{handle}</span>
<span class="truncate font-mono text-[11px] text-muted-foreground">{user?.email}</span>
<span class="truncate font-mono text-[11px] text-muted-foreground">{user?.email}</span
>
</div>
<ChevronsUpDownIcon class="ml-auto size-4" />
</Sidebar.MenuButton>
@@ -53,13 +57,16 @@
<DropdownMenu.Label class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar.Root class="size-8 rounded-lg">
<Avatar.Fallback class="rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<Avatar.Fallback
class="rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
>
{initials}
</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{handle}</span>
<span class="truncate font-mono text-[11px] text-muted-foreground">{user?.email}</span>
<span class="truncate font-mono text-[11px] text-muted-foreground">{user?.email}</span
>
</div>
</div>
</DropdownMenu.Label>
@@ -67,11 +74,11 @@
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item onSelect={() => goto('/settings/account')}>
<DropdownMenu.Item onSelect={() => goto(resolve('/settings/account'))}>
<SettingsIcon class="size-4" />
Settings
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => goto('/settings/appearance')}>
<DropdownMenu.Item onSelect={() => goto(resolve('/settings/appearance'))}>
<PaletteIcon class="size-4" />
Appearance
</DropdownMenu.Item>
@@ -82,7 +89,7 @@
<DropdownMenu.Item
onSelect={async () => {
await logout();
await goto('/login');
await goto(resolve('/login'));
}}
>
<LogOutIcon class="size-4" />
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import * as Command from '$lib/components/ui/command/index';
import * as Kbd from '$lib/components/ui/kbd/index.js';
import * as InputGroup from '$lib/components/ui/input-group/index.js';
@@ -97,7 +98,7 @@
<Command.Item
value={String(book.id)}
onSelect={() => {
goto(`/book/${book.id}`);
goto(resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) }));
open = false;
}}
class="cursor-pointer"
@@ -115,7 +116,9 @@
by
{#each book.authors as author}
<a
href="/library/{libraryState.activeLibrary!.id}/view?authors={author.id}"
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
libraryId: String(libraryState.activeLibrary!.id)
})}?authors={author.id}"
class="cs-list hover:underline">{author.name}</a
> &thinsp;
{/each}
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { onMount, untrack } from 'svelte';
import {
@@ -25,14 +26,14 @@
import ReaderToc from './reader-toc.svelte';
let {
bookUrl,
fileUrl,
bookId,
filename,
title = '',
initialProgress = 0,
initialEpubLoc = null
}: {
bookUrl: string;
fileUrl: string;
bookId: string | number;
filename: string;
title?: string;
@@ -78,7 +79,7 @@
file = undefined;
try {
const response = await fetch(bookUrl);
const response = await fetch(fileUrl);
if (!response.ok) throw new Error(`The server returned ${response.status} for this file.`);
file = new File([await response.blob()], filename, { type: 'application/epub+zip' });
@@ -135,7 +136,7 @@
</Tooltip.Provider>
<a
href="/book/{bookId}"
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(bookId) })}
class="min-w-0 flex-1 truncate font-serif text-sm hover:underline"
{title}
>
@@ -178,7 +179,10 @@
<RotateCcw class="size-4" />
Try again
</Button>
<a href="/book/{bookId}" class={buttonVariants({ variant: 'outline' })}>
<a
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(bookId) })}
class={buttonVariants({ variant: 'outline' })}
>
Back to book
</a>
</div>
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import BookCover from './book-cover.svelte';
import BookActionsMenu from './book-actions-menu.svelte';
import * as Tooltip from '$lib/components/ui/tooltip/index';
@@ -120,7 +121,7 @@
? 'cursor-pointer'
: ''}"
>
<a href="/book/{book.id}" class="shrink-0">
<a href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })} class="shrink-0">
<BookCover {book} height={110} />
</a>
@@ -128,7 +129,7 @@
<div class="flex items-start gap-2">
<div class="min-w-0 flex-1">
<a
href="/book/{book.id}"
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
class="line-clamp-2 font-serif text-sm leading-snug hover:underline"
>
{book.title}
@@ -166,7 +167,9 @@
{#each book.tags.slice(0, TAG_LIMIT) as tag (tag.id)}
<a
data-row-control
href="/library/{libraryState.activeLibrary?.id}/view?tags={tag.id}"
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
libraryId: String(libraryState.activeLibrary?.id ?? '')
})}?tags={tag.id}"
class={badgeVariants({ variant: 'secondary' })}>{tag.name}</a
>
{/each}
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import * as Table from '$lib/components/ui/table/index';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
import * as Tooltip from '$lib/components/ui/tooltip/index';
@@ -52,7 +53,9 @@
const visible = $derived(columns.filter((c) => c.on));
const allSelected = $derived(books.length > 0 && books.every((b) => selectionState.isSelected(b.id)));
const allSelected = $derived(
books.length > 0 && books.every((b) => selectionState.isSelected(b.id))
);
/** What a record is lacking — the reason this view exists. */
function missing(book: Book) {
@@ -208,19 +211,26 @@
/>
</Table.Cell>
<Table.Cell>
<a href="/book/{book.id}"><BookCover {book} height={36} /></a>
<a href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
><BookCover {book} height={36} /></a
>
</Table.Cell>
{#each visible as column (column.key)}
{#if column.key === 'title'}
<Table.Cell class="max-w-[280px] truncate font-serif">
<a href="/book/{book.id}" class="hover:underline">{book.title}</a>
<a
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
class="hover:underline">{book.title}</a
>
</Table.Cell>
{:else if column.key === 'authors'}
<Table.Cell class="max-w-[180px] truncate">
{#each book.authors as author (author.id)}
<a
href="/library/{libraryState.activeLibrary?.id}/view?authors={author.id}"
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
libraryId: String(libraryState.activeLibrary?.id ?? '')
})}?authors={author.id}"
class="cs-list hover:underline">{author.name}</a
> &thinsp;
{/each}
@@ -248,7 +258,7 @@
{formatFileSize(totalSize(book))}
</Table.Cell>
{:else if column.key === 'added'}
<Table.Cell class="font-mono text-xs tabular-nums text-muted-foreground">
<Table.Cell class="font-mono text-xs text-muted-foreground tabular-nums">
{addedOn(book)}
</Table.Cell>
{:else if column.key === 'progress'}
@@ -263,7 +273,7 @@
style="width: {Math.round(book.progress.percentage * 100)}%;"
></span>
</span>
<span class="font-mono text-xs tabular-nums text-muted-foreground">
<span class="font-mono text-xs text-muted-foreground tabular-nums">
{Math.round(book.progress.percentage * 100)}%
</span>
</span>
@@ -1,11 +1,12 @@
<script lang="ts">
import { resolve } from '$app/paths';
import BookImage from './book-image.svelte';
import { Progress } from '$lib/components/ui/progress/index';
import { getLibraryState } from '$lib/state/library.svelte';
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
import type { Book } from '$lib/schema';
let { book, class: className = '', ...rest }: { book: Book, class?: string} = $props();
let { book, class: className = '', ...rest }: { book: Book; class?: string } = $props();
const selectionState = getBookSelectionState();
const libraryState = getLibraryState();
@@ -24,7 +25,7 @@
<div class="flex w-full flex-shrink-0 flex-col gap-1 {className}">
<!-- Book Cover -->
<a
href="/book/{book.id}"
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
class="group relative aspect-9/12 w-full overflow-hidden rounded-sm shadow-lg drop-shadow-lg transition-all duration-200 {selected
? 'ring-2 ring-star'
: ''}"
@@ -51,7 +52,9 @@
</a>
<!-- Book Title -->
<a href="/book/{book.id}" class="text-base-content mt-1 line-clamp-2 w-full font-serif text-sm hover:underline"
<a
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
class="text-base-content mt-1 line-clamp-2 w-full font-serif text-sm hover:underline"
>{book.title}</a
>
@@ -59,7 +62,9 @@
<p class="line-clamp-1 w-full text-xs text-muted-foreground">
{#each book.authors as author}
<a
href="/library/{libraryState.activeLibrary?.id}/view?authors={author.id}"
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
libraryId: String(libraryState.activeLibrary?.id ?? '')
})}?authors={author.id}"
class="cs-list hover:underline">{author.name}</a
> &thinsp;
{/each}
@@ -99,6 +99,9 @@ export class BookCollectionState {
const url = new URL(page.url);
url.searchParams.set('view', next);
// Not a route to resolve — this is the current URL with one query param
// changed, so it is already fully qualified.
// eslint-disable-next-line svelte/no-navigation-without-resolve
replaceState(url, page.state);
}
@@ -137,7 +140,8 @@ export class BookCollectionState {
url.searchParams.set('orderBy', this.orderBy);
url.searchParams.set('sortOrder', this.sortOrder);
// pushState(url.toString(), {})
// Same as setView: the current URL with sort params rewritten, not a route.
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(url.toString());
this.loadNewBooks();
@@ -285,9 +289,7 @@ export class BookCollectionState {
return wanted.every(([key, values]) => {
const current = this.filters[key] ?? [];
return (
current.length === values.length && values.every((value) => current.includes(value))
);
return current.length === values.length && values.every((value) => current.includes(value));
});
}
+4 -1
View File
@@ -1,3 +1,4 @@
import { resolve } from '$app/paths';
import { browser } from '$app/environment';
import { goto, invalidate } from '$app/navigation';
import { page } from '$app/state';
@@ -31,7 +32,9 @@ export class LibraryState {
this.activeLibrary = this.libraries.find((lib) => lib.id === libraryId) || this.libraries[0];
if (browser) {
localStorage.setItem('previousLibraryId', this.activeLibrary.id.toString());
await goto(`/library/${libraryId}/view`, { invalidate: ['app:libraries'] });
await goto(resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(libraryId) }), {
invalidate: ['app:libraries']
});
}
}