Write the catalog search function that the `searchCatalog` resolver will use. It follows "Search without raw SQL" in `docs/API.md`, and reads the `CatalogEntry` and `CatalogWord` tables. The tables and their indexes already exist (#22). The seed script (#23) fills them, so this issue depends on #23 for testing with real data. **Done when:** - [ ] The search uses Prisma only, with no raw SQL. A lint rule (`no-restricted-properties`) fails the build if someone adds `$queryRaw`, `$executeRaw` or their `Unsafe` versions - [ ] The search text is split into words, and every word must match: an entry is found if, for every search word, it has a word in `CatalogWord` that starts with it (`startsWith`) - [ ] The search text is split into words with the same function as the stored words (lowercase, accents removed, letters and digits, codes like `3.304` kept whole), and a trailing "s" is removed from each word. `%` and `_` are always removed, which is the only protection, since Prisma does not escape them in `startsWith`. Empty words are dropped, and an empty search means no text filter - [ ] The query never uses `mode: 'insensitive'`, because `ILIKE` can't use the index on `word` - [ ] A code prefix such as "3.3" finds codes, since codes are stored as words - [ ] Filters, sorting and cursor pagination match `searchCatalog` in `backend/src/schema.graphql` - [ ] The group filter only accepts groups from the chosen apparatus, and groups can be listed per apparatus - [ ] The difficulty filter takes a list of values (0.1–1.0 in steps of 0.1, e.g. `[0.1, 0.2]` when the user chooses A and B). Each value is rounded to one decimal and compared as a `Decimal`. Elements with any of the values are returned, vaults are left out, and the filter together with vault, or an invalid value, gives `BAD_USER_INPUT` - [ ] Sorting by difficulty is only allowed together with an apparatus filter. Without it the search gives `BAD_USER_INPUT` - [ ] Sorting only accepts the values in `CatalogSortField`, and `id` is always the last sort key, so the order is stable - [ ] Sorting by name puts named items first, using `hasName` and `nameSort`, so the cursor never meets `null` - [ ] The cursor is opaque and checked before use. A cursor that can't be decoded, or that was made for another sort field or direction, gives `BAD_USER_INPUT` - [ ] `totalCount` uses `count` with the same `where`, and is only calculated when the client asks for it - [ ] The queries are written so they can use the indexes on a large catalog: prefix `LIKE` on `word`, no `ILIKE`, and keyset pagination instead of offset. This is measured in #<sub-issue 5> - [ ] Tests cover: - several words - the trailing "s" - a word that is only in variant `-b` - abbreviations: searching "handstand" finds an element whose description only says "hstd" - `%`, `_` and uppercase input ("SAL" finds the same items as "sal") - the difficulty filter, including vaults left out and invalid values - the difficulty sort with and without an apparatus filter - following the cursors to the end without skipping or repeating items, also with many equal values and items without a name - filters over the whole catalog - SQL in the search text and the cursor being treated as plain text - [ ] The README links to the search description in `docs/API.md`, and `API.md` is updated if the implementation differ