Vellum/internal/search/search.go
mithral 09474b0e08 Wave 3: search depth, transfer size, and the Home screen's tests
Authors are searchable (internal/search, migrations/0009)
- The FTS index covered only title and path, so typing an artist or author
  found nothing even though people/item_people already held the credits.
- Rather than a second index, the names are denormalised into a real
  media_items.authors column, so the existing external-content FTS and its
  triggers keep doing the work; item_people/people triggers recompute it, and
  the media_items UPDATE trigger re-indexes automatically.
- Verified: searching "daft punk" now returns Random Access Memories, and so
  does the partial "punk".
- The agent found and worked around a real FTS5 external-content bug: on an
  UPGRADE the backfill UPDATE fires the delete trigger for rowids not yet in
  the fresh index, raising "database disk image is malformed". A rebuild before
  the backfill fixes it. This would have broken existing databases while a
  fresh install looked fine.

gzip for the SPA (internal/httpapi/static.go)
- 456 KB of JS and 31 KB of CSS were served uncompressed on every cold load.
- Scoped to the embedded SPA assets only, never API or media: compressing a
  Range response would break seeking, and wrapping every response would undo
  Wave 2's Flusher/ReaderFrom forwarding.
- Compresses only when the client accepts gzip, the response is a compressible
  type, there is no Range header, and nothing already set Content-Encoding.
  Sets Vary: Accept-Encoding and drops the now-wrong Content-Length.
- Verified by sha256 round-trip (identity vs gzip-decompressed are identical),
  and by confirming a Range request still returns 206 with no Content-Encoding.
  JS 351,835 -> 108,794 bytes (69% smaller); CSS 80% smaller.

epub.js is code-split (Reader.svelte)
- It was a static import, so ~350 KB was downloaded by everyone on first load
  even though most never open an ebook. Now loaded on demand.
- Main bundle 458 KB -> 112 KB (gzipped 149 KB -> 39.5 KB).
- Verified at runtime, which pnpm check cannot: only the main bundle loads
  initially, the epubjs chunk is fetched when a book is opened, and the reader
  still renders.

Home screen tests (internal/media)
- Coverage 23.4% -> 50.2%. (The 17.9% quoted in the brief was a stale reading;
  the agent measured the real baseline by moving its own files aside.)
- Covers the five shelves, counts, Explore's invariants (visibility, no
  duplicates, every kind represented, varies across calls, wrap-around),
  the list offset/limit/total contract, MostViewed ordering, and — most
  importantly — grant scoping, which is the security chokepoint.

Fixed a bug the tests found: Suggestions seeded from revoked libraries
- topEngagement selected the user's most recent progress row with no library
  filter, so progress from a library whose grant had been revoked still steered
  the home screen — a visible item was labelled "More books" based on content
  the user could no longer read. Returned items were always correctly scoped, so
  this was an information-flow gap rather than a content leak.
- Now scoped to visible libraries, and the test that pinned the old behaviour
  flipped to assert the correct reason.

gofmt/vet clean, 14 packages pass, media tests run 3x for flakiness.
2026-10-11 13:46:18 -04:00

91 lines
2.7 KiB
Go

// Package search queries the FTS5 index over media item titles, paths, and
// credited people (authors/artists).
package search
import (
"context"
"database/sql"
"fmt"
"strings"
)
// Result is one matching item.
type Result struct {
ID int64 `json:"id"`
LibraryID int64 `json:"library_id"`
Kind string `json:"kind"`
Path string `json:"path"`
Title string `json:"title"`
// Authors is the space-joined credit text denormalised onto media_items by
// migration 0009. It is exposed so a caller can show why a result matched.
Authors string `json:"authors"`
}
// Query searches visible libraries for a free-text string.
//
// Each whitespace-separated term becomes a quoted FTS5 token joined by
// implicit AND. Quoting is not cosmetic: raw user input containing a quote, a
// hyphen, or the word OR would otherwise be parsed as FTS5 query syntax and
// raise an error, or silently change the meaning of the search.
func Query(ctx context.Context, db *sql.DB, q string, libraryIDs []int64, limit int) ([]Result, error) {
terms := ftsTerms(q)
if len(terms) == 0 {
return []Result{}, nil
}
if len(libraryIDs) == 0 {
return []Result{}, nil
}
if limit <= 0 || limit > 200 {
limit = 50
}
placeholders := make([]string, len(libraryIDs))
args := make([]any, 0, len(libraryIDs)+2)
args = append(args, strings.Join(terms, " "))
for i, id := range libraryIDs {
placeholders[i] = "?"
args = append(args, id)
}
args = append(args, limit)
// The MATCH predicate is unchanged: media_search now indexes `authors` too
// (migration 0009), so author terms match with no query-side change. The
// column is only selected so callers can display it.
query := `
SELECT i.id, i.library_id, i.kind, i.path, i.title, i.authors
FROM media_search s
JOIN media_items i ON i.id = s.rowid
WHERE media_search MATCH ?
AND i.missing = 0
AND i.library_id IN (` + strings.Join(placeholders, ",") + `)
ORDER BY bm25(media_search)
LIMIT ?`
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("search: query: %w", err)
}
defer rows.Close()
out := []Result{}
for rows.Next() {
var r Result
if err := rows.Scan(&r.ID, &r.LibraryID, &r.Kind, &r.Path, &r.Title, &r.Authors); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// ftsTerms turns free text into a safe FTS5 expression: every term quoted, so
// the result is always valid FTS5 and always means "all these words".
func ftsTerms(q string) []string {
var terms []string
for _, field := range strings.Fields(q) {
// A double quote inside a quoted FTS5 string is escaped by doubling it.
escaped := strings.ReplaceAll(field, `"`, `""`)
terms = append(terms, `"`+escaped+`"`)
}
return terms
}