fix(server): add trigram indexes for search and fix header z-index

- enable pg_trgm extension in db module on startup
- add GIN trigram indexes on book_metadata title, series_name and authors name
- refactor book search query to use LEFT JOIN instead of correlated EXISTS subquery
- fix jwt-auth guard to use /api/v1/ prefix for password-change allowlist
- regenerate baseline migration to incorporate schema index changes
- fix AppHeader sticky z-index from z-10 to z-30 so search dropdown renders above book card series badges
This commit is contained in:
neon
2026-03-01 18:53:22 -06:00
parent 43d0f5017c
commit 911dc27467
10 changed files with 188 additions and 4132 deletions
+48 -22
View File
@@ -42,7 +42,12 @@ const mobileSearchInput = ref<HTMLInputElement | null>(null)
const selectedIndex = ref(-1)
const globalSearchQuery = ref('')
const { results: globalResults, loading: globalSearchLoading, settled: globalSearchSettled, clear: clearGlobalSearch } = useGlobalSearch(globalSearchQuery)
const {
results: globalResults,
loading: globalSearchLoading,
settled: globalSearchSettled,
clear: clearGlobalSearch,
} = useGlobalSearch(globalSearchQuery)
const showDropdown = computed(
() =>
@@ -135,7 +140,7 @@ function formatBadgeClass(fmt: string): string {
<template>
<header
class="flex h-14 shrink-0 items-center gap-2 border-b border-border/50 bg-background/70 backdrop-blur-xl backdrop-saturate-150 px-3 shadow-sm sticky top-0 z-10"
class="flex h-14 shrink-0 items-center gap-2 border-b border-border/50 bg-background/70 backdrop-blur-xl backdrop-saturate-150 px-3 shadow-sm sticky top-0 z-30"
>
<!-- Mobile: search active overlay -->
<template v-if="mobileSearchOpen">
@@ -162,7 +167,10 @@ function formatBadgeClass(fmt: string): string {
class="absolute top-full left-0 right-0 mt-1 bg-background border border-border rounded-md shadow-lg z-50 overflow-hidden max-h-72 overflow-y-auto"
>
<div v-if="globalSearchLoading && globalResults.length === 0" class="p-3 text-xs text-muted-foreground text-center">Searching...</div>
<div v-else-if="globalSearchSettled && !globalSearchLoading && globalResults.length === 0" class="p-3 text-xs text-muted-foreground text-center">
<div
v-else-if="globalSearchSettled && !globalSearchLoading && globalResults.length === 0"
class="p-3 text-xs text-muted-foreground text-center"
>
No results
</div>
<button
@@ -174,7 +182,12 @@ function formatBadgeClass(fmt: string): string {
selectedIndex === index ? 'bg-accent' : 'hover:bg-accent/60',
]"
>
<BookCoverImage :book-id="result.id" type="thumbnail" class="h-16 w-12 object-cover rounded shrink-0 bg-muted" :alt="result.title ?? ''" />
<BookCoverImage
:book-id="result.id"
type="thumbnail"
class="h-16 w-12 object-cover rounded shrink-0 bg-muted"
:alt="result.title ?? ''"
/>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-foreground truncate">
<template v-for="seg in highlightSegments(result.title, globalSearchQuery)" :key="seg.text + seg.match">
@@ -246,7 +259,10 @@ function formatBadgeClass(fmt: string): string {
class="absolute top-full left-0 right-0 mt-1 bg-background border border-border rounded-md shadow-lg z-50 overflow-hidden max-h-80 overflow-y-auto"
>
<div v-if="globalSearchLoading && globalResults.length === 0" class="p-3 text-xs text-muted-foreground text-center">Searching...</div>
<div v-else-if="globalSearchSettled && !globalSearchLoading && globalResults.length === 0" class="p-3 text-xs text-muted-foreground text-center">
<div
v-else-if="globalSearchSettled && !globalSearchLoading && globalResults.length === 0"
class="p-3 text-xs text-muted-foreground text-center"
>
No results
</div>
<button
@@ -258,7 +274,12 @@ function formatBadgeClass(fmt: string): string {
selectedIndex === index ? 'bg-accent' : 'hover:bg-accent/60',
]"
>
<BookCoverImage :book-id="result.id" type="thumbnail" class="h-16 w-12 object-cover rounded shrink-0 bg-muted" :alt="result.title ?? ''" />
<BookCoverImage
:book-id="result.id"
type="thumbnail"
class="h-16 w-12 object-cover rounded shrink-0 bg-muted"
:alt="result.title ?? ''"
/>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-foreground truncate">
<template v-for="seg in highlightSegments(result.title, globalSearchQuery)" :key="seg.text + seg.match">
@@ -302,7 +323,12 @@ function formatBadgeClass(fmt: string): string {
<!-- Mobile: search icon -->
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="md:hidden h-8 w-8 text-muted-foreground hover:text-foreground" @click="mobileSearchOpen = true">
<Button
variant="ghost"
size="icon"
class="md:hidden h-8 w-8 text-muted-foreground hover:text-foreground"
@click="mobileSearchOpen = true"
>
<Search :size="15" />
</Button>
</TooltipTrigger>
@@ -336,22 +362,22 @@ function formatBadgeClass(fmt: string): string {
</PopoverTrigger>
</TooltipTrigger>
<PopoverContent class="w-72 p-4" align="end">
<div class="space-y-4">
<p class="text-xs font-semibold text-foreground uppercase tracking-wider">Appearance</p>
<div class="space-y-1.5">
<span class="text-xs text-muted-foreground">Accent</span>
<AccentPicker />
<div class="space-y-4">
<p class="text-xs font-semibold text-foreground uppercase tracking-wider">Appearance</p>
<div class="space-y-1.5">
<span class="text-xs text-muted-foreground">Accent</span>
<AccentPicker />
</div>
<div class="space-y-1.5">
<span class="text-xs text-muted-foreground">Radius</span>
<RadiusPicker />
</div>
<div class="space-y-1.5">
<span class="text-xs text-muted-foreground">Background</span>
<BackgroundPicker />
</div>
</div>
<div class="space-y-1.5">
<span class="text-xs text-muted-foreground">Radius</span>
<RadiusPicker />
</div>
<div class="space-y-1.5">
<span class="text-xs text-muted-foreground">Background</span>
<BackgroundPicker />
</div>
</div>
</PopoverContent>
</PopoverContent>
</Popover>
<TooltipContent>Appearance</TooltipContent>
</Tooltip>
+2 -2
View File
@@ -26,8 +26,8 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
if (user.isDefaultPassword) {
const req = context.switchToHttp().getRequest<FastifyRequest>();
const url = req.url.split('?')[0];
const isChangePassword = url === '/api/auth/change-password' && req.method === 'POST';
const isMe = url === '/api/auth/me' && req.method === 'GET';
const isChangePassword = url === '/api/v1/auth/change-password' && req.method === 'POST';
const isMe = url === '/api/v1/auth/me' && req.method === 'GET';
if (!isChangePassword && !isMe) {
throw new ForbiddenException('Password change required');
}
+2 -1
View File
@@ -12,7 +12,7 @@ export const DB = Symbol('DB');
{
provide: DB,
inject: [ConfigService],
useFactory: (config: ConfigService) => {
useFactory: async (config: ConfigService) => {
const pool = new Pool({
connectionString: config.get('db.url'),
max: 20,
@@ -20,6 +20,7 @@ export const DB = Symbol('DB');
connectionTimeoutMillis: 5_000,
statement_timeout: 30_000,
});
await pool.query('CREATE EXTENSION IF NOT EXISTS pg_trgm');
return drizzle(pool, { schema });
},
},
@@ -564,6 +564,10 @@ ALTER TABLE "staging_files" ADD CONSTRAINT "staging_files_target_library_id_libr
ALTER TABLE "staging_files" ADD CONSTRAINT "staging_files_target_folder_id_library_folders_id_fk" FOREIGN KEY ("target_folder_id") REFERENCES "public"."library_folders"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "password_reset_tokens_user_id_idx" ON "password_reset_tokens" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "refresh_tokens_user_id_idx" ON "refresh_tokens" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "email_templates_system_name_unique" ON "email_templates" USING btree ("name") WHERE "email_templates"."user_id" is null;--> statement-breakpoint
CREATE INDEX "authors_name_trgm_idx" ON "authors" USING gin ("name" gin_trgm_ops);--> statement-breakpoint
CREATE INDEX "bm_title_trgm_idx" ON "book_metadata" USING gin ("title" gin_trgm_ops);--> statement-breakpoint
CREATE INDEX "bm_series_trgm_idx" ON "book_metadata" USING gin ("series_name" gin_trgm_ops);--> statement-breakpoint
CREATE INDEX "annotations_user_id_idx" ON "annotations" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "bookmarks_user_id_idx" ON "bookmarks" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "rdp_user_format_idx" ON "reader_default_preferences" USING btree ("user_id","format_group");--> statement-breakpoint
@@ -1 +0,0 @@
CREATE UNIQUE INDEX "email_templates_system_name_unique" ON "email_templates" USING btree ("name") WHERE "email_templates"."user_id" is null;
@@ -1,5 +1,5 @@
{
"id": "a0c5b139-eebd-4476-86e8-21be6cdee148",
"id": "9b81eebf-1c40-4c73-baad-cd6109207d0f",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@@ -1755,7 +1755,24 @@
"default": "now()"
}
},
"indexes": {},
"indexes": {
"email_templates_system_name_unique": {
"name": "email_templates_system_name_unique",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"where": "\"email_templates\".\"user_id\" is null",
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"email_templates_user_id_users_id_fk": {
"name": "email_templates_user_id_users_id_fk",
@@ -2093,7 +2110,24 @@
"notNull": false
}
},
"indexes": {},
"indexes": {
"authors_name_trgm_idx": {
"name": "authors_name_trgm_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last",
"opclass": "gin_trgm_ops"
}
],
"isUnique": false,
"concurrently": false,
"method": "gin",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
@@ -2360,7 +2394,40 @@
"default": "now()"
}
},
"indexes": {},
"indexes": {
"bm_title_trgm_idx": {
"name": "bm_title_trgm_idx",
"columns": [
{
"expression": "title",
"isExpression": false,
"asc": true,
"nulls": "last",
"opclass": "gin_trgm_ops"
}
],
"isUnique": false,
"concurrently": false,
"method": "gin",
"with": {}
},
"bm_series_trgm_idx": {
"name": "bm_series_trgm_idx",
"columns": [
{
"expression": "series_name",
"isExpression": false,
"asc": true,
"nulls": "last",
"opclass": "gin_trgm_ops"
}
],
"isUnique": false,
"concurrently": false,
"method": "gin",
"with": {}
}
},
"foreignKeys": {
"book_metadata_book_id_books_id_fk": {
"name": "book_metadata_book_id_books_id_fk",
File diff suppressed because it is too large Load Diff
+1 -8
View File
@@ -5,16 +5,9 @@
{
"idx": 0,
"version": "7",
"when": 1772377654994,
"when": 1772457969998,
"tag": "0000_baseline",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1774716959924,
"tag": "0001_fix_system_template_unique",
"breakpoints": true
}
]
}
+44 -31
View File
@@ -1,4 +1,4 @@
import { customType, integer, pgTable, primaryKey, real, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
import { customType, index, integer, pgTable, primaryKey, real, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
const embedding256 = customType<{ data: number[]; driverData: string }>({
dataType: () => 'vector(256)',
@@ -8,37 +8,50 @@ const embedding256 = customType<{ data: number[]; driverData: string }>({
import { books } from './books';
export const bookMetadata = pgTable('book_metadata', {
bookId: integer('book_id')
.primaryKey()
.references(() => books.id, { onDelete: 'cascade' }),
title: varchar('title', { length: 1000 }),
subtitle: varchar('subtitle', { length: 1000 }),
description: text('description'),
isbn10: varchar('isbn10', { length: 10 }),
isbn13: varchar('isbn13', { length: 13 }),
publisher: varchar('publisher', { length: 500 }),
publishedYear: integer('published_year'),
language: varchar('language', { length: 10 }),
pageCount: integer('page_count'),
seriesName: varchar('series_name', { length: 500 }),
seriesIndex: real('series_index'),
rating: integer('rating'),
coverSource: varchar('cover_source', { length: 9 }),
googleBooksId: varchar('google_books_id', { length: 50 }),
goodreadsId: varchar('goodreads_id', { length: 50 }),
amazonId: varchar('amazon_id', { length: 20 }),
hardcoverId: varchar('hardcover_id', { length: 50 }),
openLibraryId: varchar('open_library_id', { length: 50 }),
embedding: embedding256('embedding'),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export const bookMetadata = pgTable(
'book_metadata',
{
bookId: integer('book_id')
.primaryKey()
.references(() => books.id, { onDelete: 'cascade' }),
title: varchar('title', { length: 1000 }),
subtitle: varchar('subtitle', { length: 1000 }),
description: text('description'),
isbn10: varchar('isbn10', { length: 10 }),
isbn13: varchar('isbn13', { length: 13 }),
publisher: varchar('publisher', { length: 500 }),
publishedYear: integer('published_year'),
language: varchar('language', { length: 10 }),
pageCount: integer('page_count'),
seriesName: varchar('series_name', { length: 500 }),
seriesIndex: real('series_index'),
rating: integer('rating'),
coverSource: varchar('cover_source', { length: 9 }),
googleBooksId: varchar('google_books_id', { length: 50 }),
goodreadsId: varchar('goodreads_id', { length: 50 }),
amazonId: varchar('amazon_id', { length: 20 }),
hardcoverId: varchar('hardcover_id', { length: 50 }),
openLibraryId: varchar('open_library_id', { length: 50 }),
embedding: embedding256('embedding'),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(t) => [
index('bm_title_trgm_idx').using('gin', t.title.op('gin_trgm_ops')),
index('bm_series_trgm_idx').using('gin', t.seriesName.op('gin_trgm_ops')),
],
);
export const authors = pgTable('authors', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 500 }).notNull(),
sortName: varchar('sort_name', { length: 500 }),
});
export const authors = pgTable(
'authors',
{
id: serial('id').primaryKey(),
name: varchar('name', { length: 500 }).notNull(),
sortName: varchar('sort_name', { length: 500 }),
},
(t) => [
index('authors_name_trgm_idx').using('gin', t.name.op('gin_trgm_ops')),
],
);
export const bookAuthors = pgTable(
'book_authors',
+16 -11
View File
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { SQL, and, count, eq, inArray, sql } from 'drizzle-orm';
import { SQL, and, count, eq, inArray, isNotNull, or, sql } from 'drizzle-orm';
import { SUPPORTED_BOOK_FORMATS } from '../upload/upload-validator.service';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
@@ -159,6 +159,15 @@ export class BookRepository {
async searchAcrossLibraries(libraryIds: number[], q: string, limit: number) {
if (libraryIds.length === 0) return [];
const pattern = '%' + q + '%';
const matchedAuthors = this.db
.selectDistinct({ bookId: bookAuthors.bookId })
.from(bookAuthors)
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(sql`${authors.name} ILIKE ${pattern}`)
.as('matched_authors');
const rows = await this.db
.select({
id: books.id,
@@ -170,19 +179,15 @@ export class BookRepository {
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.innerJoin(libraries, eq(libraries.id, books.libraryId))
.leftJoin(matchedAuthors, eq(matchedAuthors.bookId, books.id))
.where(
and(
inArray(books.libraryId, libraryIds),
sql`(
${bookMetadata.title} ILIKE ${'%' + q + '%'}
OR ${bookMetadata.seriesName} ILIKE ${'%' + q + '%'}
OR EXISTS (
SELECT 1 FROM ${bookAuthors}
JOIN ${authors} ON ${authors.id} = ${bookAuthors.authorId}
WHERE ${bookAuthors.bookId} = ${books.id}
AND ${authors.name} ILIKE ${'%' + q + '%'}
)
)`,
or(
sql`${bookMetadata.title} ILIKE ${pattern}`,
sql`${bookMetadata.seriesName} ILIKE ${pattern}`,
isNotNull(matchedAuthors.bookId),
),
),
)
.orderBy(bookMetadata.title)