fix(search): move scope→item-type taxonomy into Rust (UR-049, DR-063)

Stage 1 of scoped-search-boundary-implementation.md — the query side.

scoped-search-boundary.md diagnosed this leak, specified the fix in
detail, and became the justification for the boundary rule in CLAUDE.md,
the check:boundary tripwire, and the spec-review checklist. The fix was
never built: SCOPE_ITEM_TYPES was still live in searchScope.ts, called by
library.ts, and no SearchScope existed anywhere in src-tauri/. The rule's
own founding violation was still shipping.

Rust now owns the taxonomy:

  pub enum SearchScope { All, Music, Movies, Tv }
  impl SearchScope { pub fn item_types(self) -> Option<Vec<String>> }

- SearchOptions gains `scope`, resolved by resolve_scope(). Scope wins
  over include_item_types, which stays for the non-search get_items
  callers that legitimately request one concrete type.
- repository_search resolves the scope ONCE, before the cache/server
  paths diverge, so online and offline filter identically — the failure
  mode most likely to go unnoticed.
- All expands to None (no filter), not the union of the other scopes:
  an explicit includeItemTypes list would silently drop People, folders,
  and any type nobody enumerated.
- searchScope.ts re-exports SearchScope from generated bindings instead
  of a hand-written union, and no longer names an item type for search.
- library.ts sends { scope }.

8 Rust tests written first, confirmed failing on "use of undeclared type
SearchScope" before the implementation existed.

The frontend tests that asserted includeItemTypes contents were rewritten
to assert the opaque scope is sent and includeItemTypes is absent —
keeping the old assertions would require the frontend to know the
taxonomy again, defeating the fix. The expansion is now asserted in Rust.

Verified the spec's headline criterion by hashing every src/ file, adding
"AudioBook" to the Music scope in Rust, and re-hashing: zero frontend
files change. That criterion failed before this commit.

Stage 2 (result-side grouping: GROUP_ITEM_TYPES, GroupedSearchResult on
both search payloads) remains open.
This commit is contained in:
2026-07-30 10:30:38 +02:00
parent 0a3ee0791f
commit 105cc082ea
8 changed files with 288 additions and 70 deletions
+25 -2
View File
@@ -1290,7 +1290,12 @@ async repositoryGetGenres(handle: string, parentId: string | null) : Promise<Gen
return await TAURI_INVOKE("repository_get_genres", { handle, parentId });
},
/**
* Search for items
* Search for items.
*
* Resolves `SearchOptions::scope` into concrete Jellyfin item types before
* dispatching, so scope taxonomy stays in Rust.
*
* TRACES: UR-049, UR-050 | DR-063
*/
async repositorySearch(handle: string, query: string, options: SearchOptions | null, requestId: number) : Promise<SearchResult> {
return await TAURI_INVOKE("repository_search", { handle, query, options, requestId });
@@ -2517,11 +2522,29 @@ failed: number }
/**
* Options for search queries
*/
export type SearchOptions = { limit?: number | null; includeItemTypes?: string[] | null; searchTerm?: string | null }
export type SearchOptions = { limit?: number | null; includeItemTypes?: string[] | null; searchTerm?: string | null;
/**
* Opaque scope selected by the UI. When set it **wins** over
* `include_item_types`, which remains for the non-search `get_items`
* callers that legitimately request a single concrete type.
*/
scope?: SearchScope | null }
/**
* Search result with pagination
*/
export type SearchResult = { items: MediaItem[]; totalRecordCount: number }
/**
* An opaque search scope the frontend selects; Rust owns what it *means*.
*
* The expansion table below is Jellyfin domain vocabulary: it changes when
* Jellyfin adds or renames an item type, never when the UI is redesigned. It
* previously lived in the frontend (`searchScope.ts`), which is the boundary
* leak documented in docs/specs/scoped-search-boundary.md. The frontend now
* sends the enum and never names an item type in connection with search.
*
* TRACES: UR-049 | DR-063
*/
export type SearchScope = "all" | "music" | "movies" | "tv"
/**
* Security status info
*/
+10 -10
View File
@@ -5,7 +5,7 @@ import { writable, derived } from "svelte/store";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type { Library, MediaItem, SearchResult, Genre } from "$lib/api/types";
import type { SearchOptions } from "$lib/api/bindings";
import { scopeItemTypes, type SearchScope } from "$lib/utils/searchScope";
import type { SearchScope } from "$lib/utils/searchScope";
import { auth } from "./auth";
/**
@@ -227,12 +227,13 @@ function createLibraryStore() {
/**
* Search the library, optionally narrowed to a scope.
*
* `scope` is additive and defaults to `all`, which sends no
* `includeItemTypes` at all — see scopeItemTypes() for why that differs from
* listing every type. Both the online and offline repository paths already
* honour the filter.
* The scope is sent **opaque**; Rust expands it into Jellyfin item types
* (`SearchScope::item_types()`) before the cache and server paths diverge, so
* online and offline results filter identically. `all` resolves to no filter
* at all — not the union of the other scopes, which would drop People and
* folders.
*
* TRACES: UR-049 | DR-065
* TRACES: UR-049 | DR-063, DR-065
*/
async function search(query: string, scope: SearchScope = "all") {
// Bump the request id for every call (including clears) so any in-flight
@@ -259,10 +260,9 @@ function createLibraryStore() {
// Phase 1: the command resolves with instant local-cache results. The
// merged (cache + server) union arrives later via the `search-event`
// listener above, tagged with this same requestId.
const itemTypes = scopeItemTypes(scope);
const options: SearchOptions = { limit: 10000 };
// Omit the key entirely for the `all` scope rather than sending null.
if (itemTypes) options.includeItemTypes = itemTypes;
// Send the opaque scope; Rust expands it to item types. The frontend
// never names a Jellyfin item type in connection with search.
const options: SearchOptions = { limit: 10000, scope };
const result = await Promise.race([
repo.search(query, options, requestId),
+20 -12
View File
@@ -32,35 +32,43 @@ describe("library.search scoping", () => {
library.clearSearch();
});
it("omits includeItemTypes entirely for the default (all) scope", async () => {
// The frontend sends the OPAQUE scope and never names a Jellyfin item type.
// Expansion (music → MusicAlbum/MusicArtist/Audio/Playlist) is asserted in
// Rust — see `search_scope_tests` in src-tauri/src/repository/types.rs.
// Asserting item types here would mean the frontend knows the taxonomy again,
// which is the leak docs/specs/scoped-search-boundary.md exists to prevent.
it("sends the default (all) scope and never an item-type list", async () => {
await library.search("office");
const options = searchMock.mock.calls[0][1];
expect(options.scope).toBe("all");
expect(options).not.toHaveProperty("includeItemTypes");
expect(options.limit).toBe(10000);
});
it("forwards music item types when scoped to music", async () => {
it("sends the opaque scope when scoped to music", async () => {
await library.search("office", "music");
expect(searchMock.mock.calls[0][1].includeItemTypes).toEqual([
"MusicAlbum",
"MusicArtist",
"Audio",
"Playlist",
]);
const options = searchMock.mock.calls[0][1];
expect(options.scope).toBe("music");
expect(options).not.toHaveProperty("includeItemTypes");
});
it("forwards tv item types when scoped to tv", async () => {
it("sends the opaque scope when scoped to tv", async () => {
await library.search("office", "tv");
expect(searchMock.mock.calls[0][1].includeItemTypes).toEqual(["Series", "Episode"]);
const options = searchMock.mock.calls[0][1];
expect(options.scope).toBe("tv");
expect(options).not.toHaveProperty("includeItemTypes");
});
it("forwards movie item types when scoped to movies", async () => {
it("sends the opaque scope when scoped to movies", async () => {
await library.search("office", "movies");
expect(searchMock.mock.calls[0][1].includeItemTypes).toEqual(["Movie"]);
const options = searchMock.mock.calls[0][1];
expect(options.scope).toBe("movies");
expect(options).not.toHaveProperty("includeItemTypes");
});
it("stores results and the query on success", async () => {
+6 -20
View File
@@ -9,7 +9,6 @@ import {
resolveSearchScope,
searchRouteUrl,
shouldNavigateToSearch,
scopeItemTypes,
type SearchGroupId,
} from "./searchScope";
@@ -62,25 +61,12 @@ describe("resolveSearchScope", () => {
});
});
describe("scopeItemTypes", () => {
it("omits the key entirely for the all scope", () => {
// `all` must send no includeItemTypes — an explicit union would silently
// drop types nobody enumerated (Person, folders).
expect(scopeItemTypes("all")).toBeUndefined();
});
it("maps each narrow scope to its item types", () => {
expect(scopeItemTypes("music")).toEqual(["MusicAlbum", "MusicArtist", "Audio", "Playlist"]);
expect(scopeItemTypes("movies")).toEqual(["Movie"]);
expect(scopeItemTypes("tv")).toEqual(["Series", "Episode"]);
});
it("returns a fresh array callers cannot mutate into the table", () => {
const first = scopeItemTypes("movies")!;
first.push("Series");
expect(scopeItemTypes("movies")).toEqual(["Movie"]);
});
});
// NOTE: the former `scopeItemTypes` suite moved to Rust — see
// `search_scope_tests` in src-tauri/src/repository/types.rs. The scope →
// item-type expansion is domain vocabulary and is no longer reachable from the
// frontend, so testing it here would mean re-introducing the leak to test it.
// The "fresh array" test is gone because `item_types()` returns an owned Vec,
// making the aliasing bug it guarded structurally impossible.
describe("normalizeGroupOrder", () => {
it("returns the default for missing or non-array input", () => {
+10 -24
View File
@@ -8,7 +8,11 @@
//
// TRACES: UR-049, UR-050 | DR-063, DR-066, DR-067
export type SearchScope = "all" | "music" | "movies" | "tv";
// Sourced from Rust via the generated bindings — the backend owns what a scope
// *means* (which Jellyfin item types it covers). Naming an opaque variant is
// presentation; knowing its expansion is domain vocabulary and stays in Rust.
export type { SearchScope } from "$lib/api/bindings";
import type { SearchScope } from "$lib/api/bindings";
export const SEARCH_SCOPES: readonly SearchScope[] = ["all", "music", "movies", "tv"];
@@ -19,29 +23,11 @@ export const SCOPE_LABELS: Record<SearchScope, string> = {
tv: "TV",
};
/**
* Jellyfin item types requested for each scope.
*
* `all` is deliberately absent: sending no `includeItemTypes` is *not* the same
* as sending the union of the lists below — types nobody enumerated here
* (Person, folders, …) would be filtered out by an explicit list.
*/
const SCOPE_ITEM_TYPES: Record<Exclude<SearchScope, "all">, string[]> = {
music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
movies: ["Movie"],
tv: ["Series", "Episode"],
};
/**
* Item types to send with a scoped search, or `undefined` for the `all` scope
* so the caller omits the key entirely.
*
* TRACES: UR-049 | DR-063
*/
export function scopeItemTypes(scope: SearchScope): string[] | undefined {
if (scope === "all") return undefined;
return [...SCOPE_ITEM_TYPES[scope]];
}
// NOTE: the scope → Jellyfin item-type mapping deliberately does NOT live here.
// It is domain vocabulary and lives in Rust (`SearchScope::item_types()` in
// repository/types.rs); the frontend sends the opaque scope and the backend
// expands it. Re-introducing a `{ music: ["MusicAlbum", …] }` table in this file
// is the boundary leak documented in docs/specs/scoped-search-boundary.md.
/**
* Resolve the scope a search started from a given route should default to.