fix(search): drop a dropdown search whose filters target the previous index

The header autocomplete and the quick-search dropdown render
`<InstantSearch indexName={...}>` with no `key={indexName}`.
react-instantsearch-core calls `helper.setIndex(indexName).search()` in its
RENDER body, and `<InstantSearch>` renders before its children, so a target
switch fires a search while the helper still carries the previous target's
`<Configure filters>`. The models filter set then lands on another index and
the search backend answers 400 `invalid_search_filter`, which the resilient
client swallows into an empty dropdown. Measured in production RUM: hundreds
of such rejections a day, dominated by models-only attributes arriving at the
images index and by `poi` arriving at indexes no code path ever intends it for.

`SearchLayout` fixes this with `key={indexName}` and says so in a comment. The
dropdowns cannot copy that: remounting clears the query the user is typing.

So the request is rejected in the client instead. `withSearchFilterGuard`
validates each request's filter, facet and numeric-filter attributes against
what its target index declares in `src/server/search-index/filterable-attributes.ts`
and resolves a doomed request to the ordinary empty-result shape without
sending it. Valid requests in the same batch still go to the backend and keep
their position in the response. No UX change: a rejected request already
rendered empty.

It is not silent. A rejection still pushes a Faro RUM error, under its own type
(`SearchFilterAttributeError`) so a locally-rejected request stays tellable
apart from a backend-rejected one (`MeiliSearchQueryError`). Note that a
population which used to beacon under the old type now beacons under the new
one, since it no longer reaches the backend at all.

The guard covers exactly the leaks that name an attribute the new index cannot
filter on — the ones that produce a 400. When the previous target's attributes
are all declared on the new index the stale request is valid there and is sent,
missing whatever clauses that target never built; that direction returns 200,
appears in no error signal, and no attribute check can see it. The module
documents this rather than reading as though it closes the class.

Wiring: the three browser search clients were three hand-rolled compositions
with the same 18-line empty-query short-circuit copied into two of them and
absent from the third. They now come from one `createSearchClient` factory, and
each is exported from its own module so the wiring is reachable from a node
test. That is load-bearing for the tests, not tidying — while the clients were
built inside the `.tsx` files the only possible check was a grep of the
component source, and a source check cannot tell a guarded client that is USED
from one that is merely constructed.

Tests: every shipped client is exercised through its own export, asserting the
negative — the doomed request must not be SENT — plus a positive control that a
filter set built for the index it targets still is. With the guard bypassed at
the factory, those three tests fail on that assertion. A derived ledger
enumerates every module constructing a search client and requires each to be
either the guarded factory or an exclusion whose stated reason is asserted, so
it fails when the population grows and when an exclusion stops holding.
This commit is contained in:
ZacxDev
2026-09-18 15:06:54 -05:00
parent 0340f692bf
commit bbeffcf71e
11 changed files with 1040 additions and 156 deletions
@@ -22,19 +22,13 @@ import React, {
useState,
Fragment,
} from 'react';
import type { InstantSearchProps, SearchBoxProps } from 'react-instantsearch';
import type { SearchBoxProps } from 'react-instantsearch';
import { InstantSearch, useInstantSearch, useSearchBox } from 'react-instantsearch';
import { ClearableAutoComplete } from '~/components/ClearableAutoComplete/ClearableAutoComplete';
import { slugit } from '~/utils/string-helpers';
import { instantMeiliSearch } from '@meilisearch/instant-meilisearch';
import { withUserHydration } from '~/components/Search/userHydration';
import { env } from '~/env/client';
import { createResilientSearchClient } from '~/components/Search/resilientSearchClient';
import { autocompleteSearchClient } from '~/components/Search/autocomplete.client';
import { quoteMeiliValue } from '~/components/Search/meili-filter';
import {
autocompleteAvailability,
useAutocompleteAvailabilityStore,
} from '~/components/Search/search-availability.store';
import { useAutocompleteAvailabilityStore } from '~/components/Search/search-availability.store';
import { ModelSearchItem } from '~/components/AutocompleteSearch/renderItems/models';
import { ArticlesSearchItem } from '~/components/AutocompleteSearch/renderItems/articles';
import { UserSearchItem } from '~/components/AutocompleteSearch/renderItems/users';
@@ -71,56 +65,12 @@ import { useDomainColor } from '~/hooks/useDomainColor';
import { useCheckProfanity } from '~/hooks/useCheckProfanity';
import { useBenignPhrases } from '~/hooks/useBenignPhrases';
const meilisearch = instantMeiliSearch(
env.NEXT_PUBLIC_SEARCH_HOST as string,
env.NEXT_PUBLIC_SEARCH_CLIENT_KEY,
{ primaryKey: 'id' }
);
type Props = Omit<AutocompleteProps, 'data' | 'onSubmit'> & {
onClear?: VoidFunction;
onSubmit?: VoidFunction;
searchBoxProps?: SearchBoxProps;
};
// Wrapped so a Meili outage degrades to empty results instead of an uncaught
// `MeiliSearchCommunicationError`. On fallback it flips the autocomplete
// availability flag so the dropdown shows its "Error" item (the swallowed error
// never reaches `useInstantSearch().status`, so we can't key off that).
const searchClient: InstantSearchProps['searchClient'] = withUserHydration(
createResilientSearchClient(
{
...meilisearch,
search(requests) {
// Prevent making a request if there is no query
// @see https://www.algolia.com/doc/guides/building-search-ui/going-further/conditional-requests/react/#detecting-empty-search-requests
// @see https://github.com/algolia/react-instantsearch/issues/1111#issuecomment-496132977
if (requests.every(({ params }) => !params?.query)) {
return Promise.resolve({
results: requests.map(() => ({
hits: [],
nbHits: 0,
nbPages: 0,
page: 0,
processingTimeMS: 0,
hitsPerPage: 0,
exhaustiveNbHits: false,
query: '',
params: '',
})),
});
}
return meilisearch.search(requests);
},
},
{
onError: () => autocompleteAvailability.setUnavailable(true),
onSuccess: () => autocompleteAvailability.setUnavailable(false),
}
)
);
const DEFAULT_DROPDOWN_ITEM_LIMIT = 6;
const targetData = [
@@ -167,7 +117,7 @@ export const AutocompleteSearch = forwardRef<{ focus: () => void }, Props>(({ ..
return (
<InstantSearch
searchClient={searchClient}
searchClient={autocompleteSearchClient}
indexName={searchIndexMap[targetIndex as keyof typeof searchIndexMap]}
future={{ preserveSharedStateOnUnmount: false }}
>
@@ -700,4 +650,3 @@ const IndexRenderItem: Record<SearchIndexKey, React.ComponentType<any>> = {
tools: ToolSearchItem,
comics: ComicsSearchItem,
};
+2 -16
View File
@@ -1,8 +1,6 @@
import type { AutocompleteProps } from '@mantine/core';
import { Group, Select } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { instantMeiliSearch } from '@meilisearch/instant-meilisearch';
import { withUserHydration } from '~/components/Search/userHydration';
import { IconChevronDown } from '@tabler/icons-react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { InstantSearch, useSearchBox } from 'react-instantsearch';
@@ -21,30 +19,18 @@ import { reverseSearchIndexMap, searchIndexMap } from '~/components/Search/searc
import type { SearchIndexDataMap } from '~/components/Search/search.utils2';
import { useHitsTransformed } from '~/components/Search/search.utils2';
import { IndexToLabel } from '~/components/Search/useSearchState';
import { env } from '~/env/client';
import { useFeatureFlags } from '~/providers/FeatureFlagsProvider';
import { IMAGES_SEARCH_INDEX, TOOLS_SEARCH_INDEX } from '~/server/common/constants';
import type { ShowcaseItemSchema } from '~/server/schema/user-profile.schema';
import { paired } from '~/utils/type-guards';
import { searchClient } from '~/components/Search/search.client';
import { createResilientSearchClient } from '~/components/Search/resilientSearchClient';
import { quickSearchClient } from '~/components/Search/quick-search.client';
import { BrowsingLevelFilter } from './CustomSearchComponents';
import { ToolSearchItem } from '~/components/AutocompleteSearch/renderItems/tools';
import { ComicsSearchItem } from '~/components/AutocompleteSearch/renderItems/comics';
import classes from './QuickSearchDropdown.module.scss';
import { truncate } from 'lodash-es';
// Wrapped so a Meili outage degrades this dropdown to an empty result set
// instead of an uncaught `MeiliSearchCommunicationError`. Fails quietly (no
// banner) — the header quick-search just shows nothing during a blip.
const meilisearch = withUserHydration(
createResilientSearchClient(
instantMeiliSearch(env.NEXT_PUBLIC_SEARCH_HOST as string, env.NEXT_PUBLIC_SEARCH_CLIENT_KEY, {
primaryKey: 'id',
})
)
);
// TODO: These styles were taken from the original SearchBar component. We should probably migrate that searchbar to use this component.
// const useStyles = createStyles((theme) => ({
// root: {
@@ -162,7 +148,7 @@ export const QuickSearchDropdown = ({
return (
<InstantSearch
searchClient={disableInitialSearch ? searchClient : meilisearch}
searchClient={disableInitialSearch ? searchClient : quickSearchClient}
indexName={indexName}
future={{ preserveSharedStateOnUnmount: true }}
>
@@ -0,0 +1,207 @@
import { globSync, readFileSync, statSync } from 'fs';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SEARCH_FILTER_GUARD_ERROR_TYPE } from '~/components/Search/searchFilterGuard';
import { IMAGES_SEARCH_INDEX } from '~/server/common/constants';
/**
* The defect this pins, on the SHIPPED clients rather than on the guard in isolation.
*
* `<InstantSearch>` in the dropdown surfaces carries no `key={indexName}` — it cannot, because
* remounting would clear the query the user is typing — so a target switch can issue a search
* with the previous target's `<Configure filters>`. Measured in production as a run of backend
* 400s: a models filter set arriving at the images index (`user.id`, `availability`), and `poi`
* at indexes that never intend it.
*
* The assertion that matters is the NEGATIVE one — the doomed request must not be SENT. A test
* that only checked the response shape would pass on the pre-change code, which already degrades
* a backend 400 to empty results.
*
* 🔴 Every surface is exercised THROUGH ITS OWN EXPORTED CLIENT. An earlier revision checked the
* two `.tsx` surfaces by grepping their source for `withSearchFilterGuard(`, and that check was
* shown to be worthless: constructing a guarded client and then calling the UNGUARDED one
* restores the production defect with the searched-for string still present, and no test went
* red. Source text cannot tell a wrapper that is used from one that is merely built.
*/
const { baseSearch, faro } = vi.hoisted(() => ({
baseSearch: vi.fn(),
faro: {} as Record<string, unknown>,
}));
vi.mock('@grafana/faro-web-sdk', () => ({ faro }));
vi.mock('@meilisearch/instant-meilisearch', () => ({
instantMeiliSearch: () => ({
search: baseSearch,
searchForFacetValues: vi.fn(),
clearCache: vi.fn(),
}),
}));
const pushError = vi.fn();
let consoleError: ReturnType<typeof vi.spyOn>;
/** Hits with no `user.id`, so `withUserHydration` short-circuits and never reaches tRPC. */
const passthrough = {
results: [
{
hits: [{ id: 42 }],
nbHits: 1,
nbPages: 1,
page: 0,
hitsPerPage: 1,
processingTimeMS: 2,
query: 'cat',
params: '',
},
],
};
beforeEach(() => {
// Each client is a module singleton holding its own report budget. Without this, the second
// test to send a given filter set would be deduplicated and read as a missing beacon.
vi.resetModules();
baseSearch.mockReset();
baseSearch.mockResolvedValue(passthrough);
pushError.mockClear();
for (const key of Object.keys(faro)) delete faro[key];
Object.assign(faro, { api: { pushError } });
consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
});
afterEach(() => consoleError.mockRestore());
// The models filter set, verbatim in shape. `user.id`/`availability` are models-only; `poi`
// and `minor` exist on images too, so only two of the four may be reported.
const MODELS_FILTER_SET = `(poi != true OR user.id = 4711) AND (minor != true) AND (availability != Private OR user.id = 4711)`;
// The set the same component builds when its target really is images.
const IMAGES_FILTER_SET = `(poi != true OR user.username = 'someone') AND (minor != true) AND (nsfwLevel=1 OR nsfwLevel=2)`;
const request = (filters: string) => [
{ indexName: IMAGES_SEARCH_INDEX, params: { query: 'cat', filters } },
];
/**
* Every browser search client whose `<InstantSearch>` takes its `indexName` from component
* state and has no `key={indexName}` — i.e. every surface the swap can leak on.
*/
const GUARDED_CLIENTS: [label: string, load: () => Promise<{ client: unknown }>][] = [
[
'autocompleteSearchClient (app-wide header search)',
async () => ({
client: (await import('~/components/Search/autocomplete.client')).autocompleteSearchClient,
}),
],
[
'quickSearchClient (QuickSearchDropdown, default branch)',
async () => ({
client: (await import('~/components/Search/quick-search.client')).quickSearchClient,
}),
],
[
'searchClient (QuickSearchDropdown, disableInitialSearch branch)',
async () => ({ client: (await import('~/components/Search/search.client')).searchClient }),
],
];
describe.each(GUARDED_CLIENTS)('%s', (_label, load) => {
it('does not send a search whose filters name attributes the target index lacks, and beacons it', async () => {
const { client } = await load();
const response = (await (client as any).search(request(MODELS_FILTER_SET))) as any;
expect(baseSearch).not.toHaveBeenCalled();
expect(response.results).toHaveLength(1);
expect(response.results[0].hits).toEqual([]);
expect(pushError).toHaveBeenCalled();
const payload = pushError.mock.calls[0][1] as any;
expect(payload.type).toBe(SEARCH_FILTER_GUARD_ERROR_TYPE);
expect(payload.context.indexes).toBe(IMAGES_SEARCH_INDEX);
expect(payload.context.attributes.split(',').sort()).toEqual(['availability', 'user.id']);
});
it('POSITIVE CONTROL: a filter set built for the index it targets is sent as before', async () => {
const { client } = await load();
const response = await (client as any).search(request(IMAGES_FILTER_SET));
expect(baseSearch).toHaveBeenCalledTimes(1);
expect(response).toBe(passthrough);
expect(pushError).not.toHaveBeenCalled();
});
});
/**
* A DERIVED ledger, not a list: it enumerates every module that builds a Meilisearch client and
* requires each to be classified. It fails when the population GROWS (a new surface nobody
* guarded) and when an exclusion's stated REASON stops holding — an earlier hardcoded version
* could do neither, so it read as coverage while asserting almost nothing.
*/
const GUARDED_CLIENT_FACTORY = 'src/components/Search/search-client-factory.ts';
/** Construction sites that do not need the guard, each with the reason asserted below. */
const EXCLUDED_CLIENT_SITES: Record<string, { reason: string; mustMatch: RegExp }> = {
// Remounts on every index change, so the helper can never carry the previous index's filters.
'src/components/Search/SearchLayout.tsx': {
reason: 'carries key={indexName}',
mustMatch: /key=\{indexName\}/,
},
// Targets one index for the lifetime of the modal — there is no switch to leak across.
'src/components/CollectionSelectModal/CollectionSelectModal.tsx': {
reason: 'targets a constant index',
mustMatch: /indexName=\{searchIndexMap\.collections\}/,
},
};
describe('search-client ledger', () => {
const repoRoot = path.resolve(__dirname, '../../../..');
// A full-suite run can create directories under `src/` while this walk is happening, and one
// whose name matches the glob reaches `readFileSync` as EISDIR — a COLLECTION failure, which
// contributes zero tests and moves no failure count. Skip anything that is not a regular file.
const isFile = (file: string) => {
try {
return statSync(path.join(repoRoot, file)).isFile();
} catch {
return false;
}
};
const constructionSites = globSync('src/**/*.{ts,tsx}', { cwd: repoRoot })
.map((file) => file.replace(/\\/g, '/'))
.filter((file) => !file.includes('.test.') && isFile(file))
.filter((file) =>
readFileSync(path.join(repoRoot, file), 'utf8').includes('instantMeiliSearch(')
);
it('finds the construction sites at all (positive control for the scan)', () => {
expect(constructionSites).toContain(GUARDED_CLIENT_FACTORY);
});
it('classifies every Meilisearch client construction site', () => {
const unclassified = constructionSites.filter(
(file) => file !== GUARDED_CLIENT_FACTORY && !(file in EXCLUDED_CLIENT_SITES)
);
expect(
unclassified,
`these modules build a Meilisearch client without going through ${GUARDED_CLIENT_FACTORY}. Either use the factory (so the filter guard applies), or add the file to EXCLUDED_CLIENT_SITES with the reason it cannot leak a filter set across an index switch — and assert that reason`
).toEqual([]);
});
it('holds each exclusion to the reason it claims', () => {
for (const [file, { reason, mustMatch }] of Object.entries(EXCLUDED_CLIENT_SITES)) {
const source = readFileSync(path.join(repoRoot, file), 'utf8');
expect(
mustMatch.test(source),
`${file} is excluded from the filter guard because it ${reason}; that is no longer true, so it can now leak a filter set across an index switch`
).toBe(true);
}
});
it('the factory applies the guard', () => {
const source = readFileSync(path.join(repoRoot, GUARDED_CLIENT_FACTORY), 'utf8');
expect(source).toContain('withSearchFilterGuard(');
});
});
@@ -0,0 +1,21 @@
import type { InstantSearchProps } from 'react-instantsearch';
import { autocompleteAvailability } from '~/components/Search/search-availability.store';
import { createSearchClient } from '~/components/Search/search-client-factory';
/**
* Backs the app-wide header search (`AutocompleteSearch`).
*
* Unlike the other dropdown clients this one reports availability: on fallback it flips the
* autocomplete availability flag so the dropdown can show its "Error" item. The swallowed error
* never reaches `useInstantSearch().status`, so that store is the only channel for it.
*
* Lives in its own module rather than inside `AutocompleteSearch.tsx` so the wiring is
* reachable from a node test. While it sat in the `.tsx` it was verifiable only by grepping the
* component's source, and a source check cannot tell a guarded client that is USED from one
* that is merely constructed — which is the whole defect.
*/
export const autocompleteSearchClient: InstantSearchProps['searchClient'] = createSearchClient({
skipEmptyQuery: true,
onError: () => autocompleteAvailability.setUnavailable(true),
onSuccess: () => autocompleteAvailability.setUnavailable(false),
});
+12
View File
@@ -3,3 +3,15 @@
export function quoteMeiliValue(value: string) {
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
}
/**
* Blank every quoted value in a filter expression, keeping the quotes so the surrounding
* grammar still parses. The read half of the quoting grammar `quoteMeiliValue` writes — they
* live together on purpose: a change to how values are quoted that is not mirrored here makes
* a reader treat a value as syntax.
*
* A value is free text, so `user.username = 'a = b'` must not be read as a filter on `b`.
*/
export function stripQuotedMeiliValues(expression: string) {
return expression.replace(/'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"/g, "''");
}
@@ -0,0 +1,10 @@
import type { InstantSearchProps } from 'react-instantsearch';
import { createSearchClient } from '~/components/Search/search-client-factory';
/**
* Backs `QuickSearchDropdown`'s default branch.
*
* No empty-query short-circuit: this dropdown is used with a `startingIndex` and a caller
* `filters` prop to offer candidates before anything is typed. Fails quietly (no banner).
*/
export const quickSearchClient: InstantSearchProps['searchClient'] = createSearchClient();
+77 -40
View File
@@ -29,9 +29,9 @@ import type { InstantSearchProps } from 'react-instantsearch';
* is returned verbatim (only `onSuccess` is invoked to clear any prior banner).
*/
type SearchClient = NonNullable<InstantSearchProps['searchClient']>;
type SearchMethod = SearchClient['search'];
type SearchRequests = Parameters<SearchMethod>[0];
export type SearchClient = NonNullable<InstantSearchProps['searchClient']>;
export type SearchMethod = SearchClient['search'];
export type SearchRequests = Parameters<SearchMethod>[0];
type FacetRequests = Parameters<NonNullable<SearchClient['searchForFacetValues']>>[0];
// Lower-cased substrings that identify a Meili communication / network / connectivity
@@ -78,6 +78,52 @@ export const MEILI_QUERY_ERROR_TYPE = 'MeiliSearchQueryError';
/** Distinct query errors reported per client instance, so a bad filter can't beacon per keystroke. */
const MAX_REPORTED_QUERY_ERRORS = 10;
/**
* Per-client-instance report budget, shared by every search-client wrapper that beacons.
*
* Returns a predicate that is true the FIRST time it sees a signature and false afterwards,
* and false once `max` distinct signatures have been admitted. One home for the policy, so a
* later change to it (sampling, a lower cap, a redaction rule) cannot land on one wrapper and
* miss the other `searchFilterGuard.ts` is the second caller.
*/
export function createErrorReportCap(max = MAX_REPORTED_QUERY_ERRORS) {
const seen = new Set<string>();
return (signature: string) => {
if (seen.has(signature) || seen.size >= max) return false;
seen.add(signature);
return true;
};
}
/**
* Push one exception to Faro RUM under an explicit `type`, and mirror it to the console.
*
* 🔴 The `type` REPLACES the exception's class in Faro, and `~/utils/faro/classifyException`
* keys its `chunkload` / `meili` rules off that field a type matching no rule is kept and
* tagged `error_category: real`. Both search types do that deliberately; check
* `classifyException.ts` before introducing a third.
*
* Faro only runs in production for a sampled session, so the console line is the only signal a
* developer building a malformed query locally ever gets. Reporting must never break a render,
* so everything here is best-effort.
*/
export function pushSearchClientError(
error: unknown,
type: string,
context: Record<string, string>,
consoleMessage: string,
consoleDetail?: Record<string, unknown>
) {
try {
const pushError = faro?.api?.pushError?.bind(faro.api);
pushError?.(error instanceof Error ? error : new Error(String(error)), { type, context });
} catch {
// Reporting must never break the search render.
}
console.error(`[${type}] ${consoleMessage}`, consoleDetail ?? {});
}
function toLowerString(value: unknown): string {
if (typeof value === 'string') return value.toLowerCase();
if (value == null) return '';
@@ -146,9 +192,8 @@ function indexNamesOf(requests: readonly unknown[] | undefined): string {
}
/**
* Report to Faro RUM (where it lands untagged, i.e. `error_category: real`) AND the
* console: Faro only runs in production for a sampled session, so the console line is
* the only signal a developer building a malformed filter locally ever gets.
* Report a backend-rejected query to Faro RUM (where it lands untagged, i.e.
* `error_category: real`) AND the console.
*
* Carries index names but never the user's query.
*/
@@ -157,24 +202,17 @@ function reportQueryError(error: unknown, requests: readonly unknown[] | undefin
const code = (error as { code?: unknown })?.code;
const httpStatus = (error as { httpStatus?: unknown })?.httpStatus;
try {
const pushError = faro?.api?.pushError?.bind(faro.api);
pushError?.(error instanceof Error ? error : new Error(String(error)), {
type: MEILI_QUERY_ERROR_TYPE,
context: {
...(typeof code === 'string' && code ? { code } : {}),
...(typeof httpStatus === 'number' ? { httpStatus: String(httpStatus) } : {}),
...(indexes ? { indexes } : {}),
},
});
} catch {
// Reporting must never break the search render.
}
console.error(`[${MEILI_QUERY_ERROR_TYPE}] Meilisearch rejected our query`, {
indexes,
pushSearchClientError(
error,
});
MEILI_QUERY_ERROR_TYPE,
{
...(typeof code === 'string' && code ? { code } : {}),
...(typeof httpStatus === 'number' ? { httpStatus: String(httpStatus) } : {}),
...(indexes ? { indexes } : {}),
},
'Meilisearch rejected our query',
{ indexes, error }
);
}
/**
@@ -182,22 +220,24 @@ function reportQueryError(error: unknown, requests: readonly unknown[] | undefin
* incoming requests. Matches the shape react-instantsearch expects (mirrors the
* empty-query short-circuit already used in `search.client.ts`).
*/
function emptySearchResults(requests: SearchRequests) {
export function emptySearchResult() {
return {
results: (requests ?? []).map(() => ({
hits: [],
nbHits: 0,
nbPages: 0,
page: 0,
processingTimeMS: 0,
hitsPerPage: 0,
exhaustiveNbHits: false,
query: '',
params: '',
})),
hits: [],
nbHits: 0,
nbPages: 0,
page: 0,
processingTimeMS: 0,
hitsPerPage: 0,
exhaustiveNbHits: false,
query: '',
params: '',
};
}
export function emptySearchResults(requests: SearchRequests) {
return { results: (requests ?? []).map(() => emptySearchResult()) };
}
function emptyFacetResults(requests: readonly unknown[]) {
return (requests ?? []).map(() => ({
facetHits: [],
@@ -232,17 +272,14 @@ export function createResilientSearchClient<T extends SearchClient>(
options: ResilientSearchClientOptions = {}
): T {
const { retries = 1, retryDelayMs = 250, onError, onSuccess } = options;
const reportedSignatures = new Set<string>();
const shouldReport = createErrorReportCap();
const handleFailure = (error: unknown, requests: readonly unknown[] | undefined) => {
if (!isMeiliApiError(error)) {
onError?.(error);
return;
}
const signature = errorSignature(error);
if (reportedSignatures.has(signature) || reportedSignatures.size >= MAX_REPORTED_QUERY_ERRORS)
return;
reportedSignatures.add(signature);
if (!shouldReport(errorSignature(error))) return;
reportQueryError(error, requests);
};
@@ -0,0 +1,76 @@
import { instantMeiliSearch } from '@meilisearch/instant-meilisearch';
import type { InstantSearchProps } from 'react-instantsearch';
import type { SearchRequests } from '~/components/Search/resilientSearchClient';
import {
createResilientSearchClient,
emptySearchResults,
} from '~/components/Search/resilientSearchClient';
import { withSearchFilterGuard } from '~/components/Search/searchFilterGuard';
import { withUserHydration } from '~/components/Search/userHydration';
import { env } from '~/env/client';
/**
* One place that assembles a browser search client, so the wrapper stack is a single decision
* rather than one re-made per surface.
*
* It used to be three hand-rolled compositions. They had already drifted the same 18-line
* empty-query short-circuit copied byte-for-byte into two of them and absent from the third
* and the drift that matters is the one nobody would notice: a wrapper added to two of three
* call sites leaves the third silently unprotected, which is exactly the shape of the defect
* `withSearchFilterGuard` exists to fix.
*
* Order, outermost first, and each layer depends on the one under it:
* `withUserHydration` replaces stale denormalized avatars; needs a settled response.
* `createResilientSearchClient` turns a backend blip into empty results + the availability
* callbacks. Sits OUTSIDE the guard so a locally-rejected
* request counts as a success and never raises the "search is
* unavailable" banner: search was reachable, our filter was wrong.
* empty-query short-circuit optional; suppresses the round trip when nothing was typed.
* `withSearchFilterGuard` drops a request whose filters cannot apply to its index.
*
* 🔴 `SearchLayout` and `CollectionSelectModal` deliberately do NOT use this factory and build
* their own clients: the first carries `key={indexName}`, the second targets a constant index,
* so neither can leak a filter set across an index switch and neither needs the guard. That
* exclusion is asserted in `__tests__/search-client-filter-guard.test.ts`, which fails if a new
* `instantMeiliSearch(` call site appears without being classified.
*/
export function createSearchClient({
keepZeroFacets = false,
skipEmptyQuery = false,
onError,
onSuccess,
}: {
/** Meili option — keep facet entries whose count is 0. */
keepZeroFacets?: boolean;
/** Resolve to empty results without a round trip when no request in the batch has a query. */
skipEmptyQuery?: boolean;
/** Raised when a search falls back for anything other than a locally-rejected filter. */
onError?: (error: unknown) => void;
/** Raised on every successful search — lets a caller clear a prior "unavailable" state. */
onSuccess?: () => void;
} = {}): InstantSearchProps['searchClient'] {
const meilisearch = instantMeiliSearch(
env.NEXT_PUBLIC_SEARCH_HOST as string,
env.NEXT_PUBLIC_SEARCH_CLIENT_KEY,
{ primaryKey: 'id', ...(keepZeroFacets ? { keepZeroFacets: true } : {}) }
);
const guarded = withSearchFilterGuard(meilisearch);
const base = skipEmptyQuery
? {
...meilisearch,
search(requests: SearchRequests) {
// Prevent making a request if there is no query
// @see https://www.algolia.com/doc/guides/building-search-ui/going-further/conditional-requests/react/#detecting-empty-search-requests
// @see https://github.com/algolia/react-instantsearch/issues/1111#issuecomment-496132977
if (requests.every(({ params }) => !params?.query)) {
return Promise.resolve(emptySearchResults(requests));
}
return guarded.search(requests);
},
}
: guarded;
return withUserHydration(createResilientSearchClient(base, { onError, onSuccess }));
}
+10 -45
View File
@@ -1,47 +1,12 @@
import { instantMeiliSearch } from '@meilisearch/instant-meilisearch';
import type { InstantSearchProps } from 'react-instantsearch';
import { env } from '~/env/client';
import { createResilientSearchClient } from '~/components/Search/resilientSearchClient';
import { withUserHydration } from '~/components/Search/userHydration';
import { createSearchClient } from '~/components/Search/search-client-factory';
const meilisearch = instantMeiliSearch(
env.NEXT_PUBLIC_SEARCH_HOST as string,
env.NEXT_PUBLIC_SEARCH_CLIENT_KEY,
{ primaryKey: 'id', keepZeroFacets: true }
);
const baseSearchClient: InstantSearchProps['searchClient'] = {
...meilisearch,
search(requests) {
// Prevent making a request if there is no query
// @see https://www.algolia.com/doc/guides/building-search-ui/going-further/conditional-requests/react/#detecting-empty-search-requests
// @see https://github.com/algolia/react-instantsearch/issues/1111#issuecomment-496132977
if (
requests.every(({ params }) => !params?.query)
// && !location.pathname.startsWith('/search')
) {
return Promise.resolve({
results: requests.map(() => ({
hits: [],
nbHits: 0,
nbPages: 0,
page: 0,
processingTimeMS: 0,
hitsPerPage: 0,
exhaustiveNbHits: false,
query: '',
params: '',
})),
});
}
return meilisearch.search(requests);
},
};
// Wrap so a Meili outage degrades to empty results instead of an uncaught
// `MeiliSearchCommunicationError`. This client backs the header quick-search
// dropdown (and other autocomplete surfaces), so it fails quietly — no banner.
export const searchClient: InstantSearchProps['searchClient'] = withUserHydration(
createResilientSearchClient(baseSearchClient)
);
/**
* Backs `QuickSearchDropdown`'s `disableInitialSearch` branch its only consumer.
*
* Fails quietly (no availability banner): this is a small dropdown, not the search page.
*/
export const searchClient: InstantSearchProps['searchClient'] = createSearchClient({
keepZeroFacets: true,
skipEmptyQuery: true,
});
@@ -0,0 +1,361 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MEILI_QUERY_ERROR_TYPE } from '~/components/Search/resilientSearchClient';
import {
collectFilterAttributes,
findUnsupportedFilterAttributes,
SEARCH_FILTER_GUARD_ERROR_TYPE,
unsupportedAttributes,
withSearchFilterGuard,
} from '~/components/Search/searchFilterGuard';
import {
COLLECTIONS_SEARCH_INDEX,
COMICS_SEARCH_INDEX,
IMAGES_SEARCH_INDEX,
MODELS_SEARCH_INDEX,
} from '~/server/common/constants';
// The SDK's `faro` export is a bare `{}` until `initializeFaro` runs, which never happens
// in test — so the reporting path has to be driven by standing an `api` on it.
const { faro } = vi.hoisted(() => ({ faro: {} as Record<string, unknown> }));
vi.mock('@grafana/faro-web-sdk', () => ({ faro }));
const pushError = vi.fn();
let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
pushError.mockClear();
for (const key of Object.keys(faro)) delete faro[key];
Object.assign(faro, { api: { pushError } });
consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
});
afterEach(() => consoleError.mockRestore());
/**
* The exact filter set `AutocompleteSearch` builds while its target is `models`. Measured in
* production landing on four other indexes after a target switch. `4711` is a user id that
* cannot collide with anything the guard looks at the guard never reads values.
*/
const MODELS_FILTER_SET = [
`(poi != true OR user.id = 4711)`,
`(minor != true)`,
`(availability != Private OR user.id = 4711)`,
].join(' AND ');
/** What the same component builds once its target really is `images`. */
const IMAGES_FILTER_SET = [
`(poi != true OR user.username = 'someone')`,
`(minor != true)`,
`(nsfwLevel=1 OR nsfwLevel=2)`,
].join(' AND ');
function makeClient(overrides: Record<string, unknown> = {}) {
return {
search: vi.fn(),
searchForFacetValues: vi.fn(),
clearCache: vi.fn(),
...overrides,
} as any;
}
const okResponse = (count: number) => ({
results: Array.from({ length: count }, (_, i) => ({
hits: [{ id: 100 + i }],
nbHits: 1,
nbPages: 1,
page: 0,
hitsPerPage: 1,
processingTimeMS: 3,
query: 'cat',
params: '',
})),
});
describe('collectFilterAttributes', () => {
it('reads the attribute out of every comparison form', () => {
expect(
collectFilterAttributes({
filters: `a = 1 AND b != 2 AND c > 3 AND d >= 4 AND e < 5 AND f <= 6`,
}).sort()
).toEqual(['a', 'b', 'c', 'd', 'e', 'f']);
});
it('reads the keyword and range forms too', () => {
expect(
collectFilterAttributes({
filters: `g IN [1, 2] AND h NOT IN [3] AND i EXISTS AND j IS EMPTY AND k 1 TO 10`,
}).sort()
).toEqual(['g', 'h', 'i', 'j', 'k']);
});
it('does not mistake grammar words or a NOT prefix for an attribute', () => {
expect(collectFilterAttributes({ filters: `NOT tags.name = 'cat'` })).toEqual(['tags.name']);
});
it('drops a grammar word that lands in attribute position in a malformed expression', () => {
// Reachable, not defensive: in each of these a keyword really does sit where an attribute
// sits, and without the reserved-word filter it would be reported as an unfilterable
// attribute of its own — a local rejection of a query for a reason that is not true.
expect(collectFilterAttributes({ filters: `NOT tagNames = 1 AND NOT EXISTS` })).toEqual([
'tagNames',
]);
expect(collectFilterAttributes({ filters: `a = 1 AND NOT IN [2]` })).toEqual(['a']);
});
it('does not read inside a quoted value', () => {
// A username is free text and can contain anything a filter expression can.
expect(
collectFilterAttributes({ filters: `user.username = 'sneaky = value AND other EXISTS'` })
).toEqual(['user.username']);
});
it('covers facetFilters, facets and numericFilters', () => {
expect(
collectFilterAttributes({
facetFilters: [['type:Checkpoint'], 'category.name:anime'],
facets: ['tags.name', '*'],
numericFilters: ['versions.id >= 12345'],
}).sort()
).toEqual(['category.name', 'tags.name', 'type', 'versions.id']);
});
it('returns nothing for params with no filters at all', () => {
expect(collectFilterAttributes({ query: 'cat', hitsPerPage: 6 })).toEqual([]);
expect(collectFilterAttributes(undefined)).toEqual([]);
});
it('never yields an empty attribute name from malformed input', () => {
// No index declares `''`, so letting one through would reject the whole request — this
// guard must fail open on garbage, never closed.
expect(collectFilterAttributes({ facetFilters: [':orphaned-value'] })).toEqual([]);
expect(collectFilterAttributes({ facets: [''] })).toEqual([]);
});
});
describe('unsupportedAttributes', () => {
it('lets a declared parent cover its sub-fields, on the dot and not on the prefix', () => {
expect(unsupportedAttributes(['user'], { filters: `user.id = 4711` })).toEqual([]);
// `username` is a different attribute that merely starts with the same letters.
expect(unsupportedAttributes(['user'], { filters: `username = 'someone'` })).toEqual([
'username',
]);
});
});
describe('findUnsupportedFilterAttributes', () => {
it('names exactly the models-only attributes when that filter set lands on images', () => {
// The production signature: `images_v6` rejecting `user.id` and `availability`, while
// `poi` and `minor` — which images DOES declare — are not flagged.
expect(
findUnsupportedFilterAttributes(IMAGES_SEARCH_INDEX, { filters: MODELS_FILTER_SET }).sort()
).toEqual(['availability', 'user.id']);
});
it('flags poi on an index that never intends it', () => {
expect(
findUnsupportedFilterAttributes(COLLECTIONS_SEARCH_INDEX, {
filters: MODELS_FILTER_SET,
}).sort()
).toEqual(['availability', 'minor', 'poi', 'user.id']);
expect(
findUnsupportedFilterAttributes(COMICS_SEARCH_INDEX, { filters: `poi != true` })
).toEqual(['poi']);
});
it('POSITIVE CONTROL: a filter set built for the index it is sent to is not flagged', () => {
expect(
findUnsupportedFilterAttributes(IMAGES_SEARCH_INDEX, { filters: IMAGES_FILTER_SET })
).toEqual([]);
expect(
findUnsupportedFilterAttributes(MODELS_SEARCH_INDEX, { filters: MODELS_FILTER_SET })
).toEqual([]);
});
it('fails open on an index it holds no declaration for', () => {
expect(
findUnsupportedFilterAttributes('some_other_index_v1', { filters: MODELS_FILTER_SET })
).toEqual([]);
expect(findUnsupportedFilterAttributes(undefined, { filters: MODELS_FILTER_SET })).toEqual([]);
});
});
describe('withSearchFilterGuard', () => {
it('does not send a doomed request, and answers the empty-result shape', async () => {
const base = makeClient({ search: vi.fn().mockResolvedValue(okResponse(1)) });
const client = withSearchFilterGuard(base);
const response = (await client.search([
{ indexName: IMAGES_SEARCH_INDEX, params: { query: 'cat', filters: MODELS_FILTER_SET } },
] as any)) as any;
expect(base.search).not.toHaveBeenCalled();
expect(response.results).toHaveLength(1);
expect(response.results[0].hits).toEqual([]);
expect(response.results[0].nbHits).toBe(0);
});
it('POSITIVE CONTROL: a legitimate same-index query reaches the base client untouched', async () => {
const passthrough = okResponse(1);
const base = makeClient({ search: vi.fn().mockResolvedValue(passthrough) });
const client = withSearchFilterGuard(base);
const requests = [
{ indexName: IMAGES_SEARCH_INDEX, params: { query: 'cat', filters: IMAGES_FILTER_SET } },
] as any;
const response = await client.search(requests);
expect(base.search).toHaveBeenCalledTimes(1);
expect(base.search).toHaveBeenCalledWith(requests);
expect(response).toBe(passthrough);
expect(pushError).not.toHaveBeenCalled();
});
it('beacons a rejection under its OWN type, carrying the index and attributes', async () => {
const client = withSearchFilterGuard(makeClient());
await client.search([
{ indexName: COLLECTIONS_SEARCH_INDEX, params: { query: 'cat', filters: `poi != true` } },
] as any);
expect(pushError).toHaveBeenCalledTimes(1);
const [error, payload] = pushError.mock.calls[0] as [Error, any];
expect(error).toBeInstanceOf(Error);
expect(payload.type).toBe(SEARCH_FILTER_GUARD_ERROR_TYPE);
expect(payload.context.indexes).toBe(COLLECTIONS_SEARCH_INDEX);
expect(payload.context.attributes).toBe('poi');
expect(consoleError).toHaveBeenCalled();
});
// INVARIANT GUARD, not regression coverage: the bug never merged the two error types. It is
// here because the operator's condition for this change was that a locally-rejected request
// stay tellable apart from a backend-rejected one.
it('INVARIANT: is distinguishable from a rejection the backend issued', () => {
expect(SEARCH_FILTER_GUARD_ERROR_TYPE).not.toBe(MEILI_QUERY_ERROR_TYPE);
});
// INVARIANT GUARD, not regression coverage: no revision ever beaconed the query.
it('INVARIANT: never beacons the user query', async () => {
const client = withSearchFilterGuard(makeClient());
await client.search([
{
indexName: COLLECTIONS_SEARCH_INDEX,
params: { query: 'a-very-private-search', filters: `poi != true` },
},
] as any);
const serialized = JSON.stringify(pushError.mock.calls) + String(pushError.mock.calls[0][0]);
expect(serialized).not.toContain('a-very-private-search');
});
it('rejects only the doomed request in a batch and keeps the valid one in place', async () => {
const base = makeClient({
search: vi.fn().mockResolvedValue({
results: [
{
hits: [{ id: 77 }],
nbHits: 1,
nbPages: 1,
page: 0,
hitsPerPage: 1,
processingTimeMS: 1,
query: 'cat',
params: '',
},
],
}),
});
const client = withSearchFilterGuard(base);
const response = (await client.search([
{ indexName: IMAGES_SEARCH_INDEX, params: { query: 'cat', filters: MODELS_FILTER_SET } },
{ indexName: MODELS_SEARCH_INDEX, params: { query: 'cat', filters: MODELS_FILTER_SET } },
] as any)) as any;
expect(base.search).toHaveBeenCalledTimes(1);
expect(base.search.mock.calls[0][0]).toHaveLength(1);
expect(base.search.mock.calls[0][0][0].indexName).toBe(MODELS_SEARCH_INDEX);
expect(response.results).toHaveLength(2);
expect(response.results[0].hits).toEqual([]);
expect(response.results[1].hits).toEqual([{ id: 77 }]);
});
it('reports a repeated rejection once, and caps distinct ones at ten per client', async () => {
const client = withSearchFilterGuard(makeClient());
const send = (filters: string) =>
client.search([{ indexName: MODELS_SEARCH_INDEX, params: { query: 'cat', filters } }] as any);
await send(`nope0 = 1`);
await send(`nope0 = 1`);
expect(pushError).toHaveBeenCalledTimes(1);
// 13 distinct signatures, a count the cap cannot equal in either direction: uncapped
// would report 13, and a cap of 1 would leave this at 1.
for (let i = 1; i < 13; i++) await send(`nope${i} = 1`);
expect(pushError).toHaveBeenCalledTimes(10);
});
it('keeps reporting per client instance, so a fresh page is not pre-silenced', async () => {
const first = withSearchFilterGuard(makeClient());
await first.search([
{ indexName: MODELS_SEARCH_INDEX, params: { query: 'cat', filters: `nope = 1` } },
] as any);
expect(pushError).toHaveBeenCalledTimes(1);
const second = withSearchFilterGuard(makeClient());
await second.search([
{ indexName: MODELS_SEARCH_INDEX, params: { query: 'cat', filters: `nope = 1` } },
] as any);
expect(pushError).toHaveBeenCalledTimes(2);
});
it('keeps the response fields that sit beside `results` on the partial-rejection path', async () => {
// The re-interleaved response is rebuilt, so anything the base client returned alongside
// `results` has to be carried over rather than dropped.
const base = makeClient({
search: vi.fn().mockResolvedValue({ ...okResponse(1), processingTimeMS: 37 }),
});
const client = withSearchFilterGuard(base);
const response = (await client.search([
{ indexName: IMAGES_SEARCH_INDEX, params: { query: 'cat', filters: MODELS_FILTER_SET } },
{ indexName: MODELS_SEARCH_INDEX, params: { query: 'cat', filters: MODELS_FILTER_SET } },
] as any)) as any;
expect(response.processingTimeMS).toBe(37);
});
it('propagates a base-client rejection rather than swallowing it', async () => {
// The resilient wrapper outside this one owns the fallback; swallowing here would hide a
// real outage behind an empty dropdown with no beacon at all.
const failure = new Error('NetworkError when attempting to fetch resource');
const client = withSearchFilterGuard(
makeClient({ search: vi.fn().mockRejectedValue(failure) })
);
await expect(
client.search([
{ indexName: IMAGES_SEARCH_INDEX, params: { query: 'cat', filters: MODELS_FILTER_SET } },
{ indexName: MODELS_SEARCH_INDEX, params: { query: 'cat', filters: MODELS_FILTER_SET } },
] as any)
).rejects.toThrow(failure);
});
it('a rejection is not a backend failure, so it never raises the availability banner', async () => {
const { createResilientSearchClient } = await import(
'~/components/Search/resilientSearchClient'
);
const onError = vi.fn();
const onSuccess = vi.fn();
const client = createResilientSearchClient(withSearchFilterGuard(makeClient()), {
onError,
onSuccess,
});
await client.search([
{ indexName: IMAGES_SEARCH_INDEX, params: { query: 'cat', filters: MODELS_FILTER_SET } },
] as any);
expect(onError).not.toHaveBeenCalled();
expect(onSuccess).toHaveBeenCalledTimes(1);
});
});
+260
View File
@@ -0,0 +1,260 @@
import { stripQuotedMeiliValues } from '~/components/Search/meili-filter';
import type { SearchClient, SearchRequests } from '~/components/Search/resilientSearchClient';
import {
createErrorReportCap,
emptySearchResult,
pushSearchClientError,
} from '~/components/Search/resilientSearchClient';
import { filterableAttributesByIndex } from '~/server/search-index/filterable-attributes';
/**
* Drops a search request whose filter names an attribute the TARGET INDEX cannot filter on,
* before it reaches the network.
*
* Why it is needed: `<InstantSearch>` only re-creates its search helper when it is given a
* `key`. Without one, `react-instantsearch-core` calls `helper.setIndex(indexName).search()`
* in its RENDER body, and `<InstantSearch>` renders before its children so a state change
* that swaps the index fires a search while the helper still carries the PREVIOUS index's
* `<Configure filters>`. The models filter set then lands on, say, the images index, and the
* backend answers 400 `invalid_search_filter`. `SearchLayout` avoids this with `key={indexName}`;
* the dropdown surfaces cannot use that remedy because remounting clears the user's typed query.
*
* Behaviour on rejection: the request is NOT sent and resolves to the ordinary empty-result
* shape which is exactly what the user already saw, since the backend's 400 is swallowed into
* an empty dropdown by `createResilientSearchClient`. So there is no UX change.
*
* 🔴 It is NOT silent. A rejected request still pushes a Faro RUM error, under a type of its
* own (`SEARCH_FILTER_GUARD_ERROR_TYPE`) so a locally-rejected request can be told apart from a
* backend-rejected one (`MEILI_QUERY_ERROR_TYPE`). Some of these events are the only signal a
* separate index-configuration ticket has; swallowing them would take that ticket dark.
*
* 🔴 WHAT IT DOES NOT COVER, because the write-up above would otherwise read as closing the
* class. This catches exactly the leaks that name an attribute the NEW index cannot filter on
* the ones the backend answers with a 400. When the previous target's attributes are all
* declared on the new index, the stale request is VALID there and is sent: it succeeds, missing
* whatever clauses the previous target never had to build. That direction produces a 200, so it
* appears in no error signal at all, and no attribute check can see it the filters are
* well-formed, just not the ones this target asked for. `key={indexName}` is what closes both
* directions; it is not used here because remounting clears the user's typed query.
*
* The allow-list is the DESIRED index configuration, not the live one. Meilisearch settings
* are applied out of band (see `src/pages/api/admin/temp/apply-models-index-filterable-attributes.ts`),
* so the two can drift either way. `code ⊃ live` is harmless the guard passes and the backend
* rejects, exactly as today. `live ⊃ code` is the one to watch: removing an attribute from
* `filterable-attributes.ts` that the live index still declares turns a working query into a
* silently empty result set.
*
* Scope: `search` only. `searchForFacetValues` can 400 the same way, but no surface wired to
* this guard uses it add a `facetName` check here if one ever does.
*/
type SearchRequest = SearchRequests[number];
/**
* Faro exception `type` and console prefix for a request this guard refused to send. One token
* so a log query and a `grep` of a browser console find the same thing, and deliberately NOT
* `MEILI_QUERY_ERROR_TYPE` that one means the backend answered and rejected us.
*
* 🔴 A population that used to beacon as `MEILI_QUERY_ERROR_TYPE` now beacons as this instead:
* a filter aimed at the wrong index no longer reaches the backend, so it no longer produces a
* backend rejection. Any saved query watching the old token for THAT population will read zero
* and look fixed. See `~/utils/faro/classifyException` for how the type is handled it matches
* no rule there, so the beacon is kept and tagged `error_category: real`, same as its sibling.
*/
export const SEARCH_FILTER_GUARD_ERROR_TYPE = 'SearchFilterAttributeError';
/**
* Filter-grammar words that can sit where an attribute sits. `true`/`false` are values, the rest
* are operators/connectives; none is a real attribute on any index.
*/
const RESERVED_WORDS = new Set([
'and',
'or',
'not',
'to',
'in',
'exists',
'is',
'null',
'empty',
'contains',
'starts',
'with',
'true',
'false',
]);
// An attribute is whatever sits immediately left of an operator. Each pattern opens with
// `(^|[^\w.])` rather than a lookbehind, for two reasons: lookbehind is unsupported on older
// Safari and a SyntaxError here would break search outright there; and requiring a non-word,
// non-dot character makes every start position inside a long identifier run fail in O(1), which
// is what keeps the scan linear on a pasted multi-kilobyte query rather than quadratic.
//
// 🔴 These are module-scope `/g` objects, so `lastIndex` is shared state. That is safe only
// while every consumer of `collectFromExpression` is SYNCHRONOUS — there is no `await` and no
// callback between the reset and the terminating `null`, and `withSearchFilterGuard` classifies
// a whole batch before its first `await`. Do not make this collection async.
const COMPARISON_OPERAND = /(^|[^\w.])([A-Za-z_][A-Za-z0-9_.]*)\s*(?:>=|<=|!=|=|>|<)/g;
const KEYWORD_OPERAND =
/(^|[^\w.])([A-Za-z_][A-Za-z0-9_.]*)\s+(?:NOT\s+IN|IN|EXISTS|IS\s+(?:NULL|EMPTY)|CONTAINS|STARTS\s+WITH)\b/gi;
// `attr 1 TO 10` — the only form where the attribute is not adjacent to its operator.
const RANGE_OPERAND = /(^|[^\w.])([A-Za-z_][A-Za-z0-9_.]*)\s+-?\d+(?:\.\d+)?\s+TO\s/gi;
function collectFromExpression(value: unknown, out: string[]): void {
if (Array.isArray(value)) {
for (const entry of value) collectFromExpression(entry, out);
return;
}
if (typeof value !== 'string' || !value.trim()) return;
const expression = stripQuotedMeiliValues(value);
for (const pattern of [COMPARISON_OPERAND, KEYWORD_OPERAND, RANGE_OPERAND]) {
pattern.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(expression)) !== null) {
const attribute = match[2];
if (attribute && !RESERVED_WORDS.has(attribute.toLowerCase())) out.push(attribute);
}
}
}
/** `facetFilters` entries are `attribute:value` (or `attribute:-value`), nested up to 2 deep. */
function collectFromFacetFilters(value: unknown, out: string[]): void {
if (Array.isArray(value)) {
for (const entry of value) collectFromFacetFilters(entry, out);
return;
}
if (typeof value !== 'string') return;
const separator = value.indexOf(':');
if (separator > 0) out.push(value.slice(0, separator));
}
/** `facets` are plain attribute names; `*` asks for all of them and names none. */
function collectFromFacets(value: unknown, out: string[]): void {
if (Array.isArray(value)) {
for (const entry of value) collectFromFacets(entry, out);
return;
}
if (typeof value === 'string' && value && value !== '*') out.push(value);
}
/**
* Every attribute a request's params ask the backend to filter or facet on.
*
* 🔴 No collector may ever emit the empty string. No index declares `''`, so one would reject
* the whole request and this guard has to fail OPEN on malformed input, never closed. Each
* collector holds that itself; a second `.filter(Boolean)` here was tried and removed, because
* a redundant outer guard makes the real ones untestable (every mutation of them dies to the
* outer filter instead, so the suite stays green with the actual protection deleted).
*/
export function collectFilterAttributes(params: unknown): string[] {
const record = (params ?? {}) as Record<string, unknown>;
const out: string[] = [];
collectFromExpression(record.filters, out);
collectFromExpression(record.numericFilters, out);
collectFromFacetFilters(record.facetFilters, out);
collectFromFacets(record.facets, out);
return [...new Set(out)];
}
/**
* The attributes a request asks for that a given declaration does not cover.
*
* Declaring a parent attribute makes its sub-fields filterable in the engine, so `user`
* covers `user.id` the dot matters: `user` must not cover `username`. No index declares a
* bare parent today; the rule is here so that adding one does not start rejecting valid
* queries, and it is exercised directly rather than through `filterableAttributesByIndex`,
* which cannot currently reach it.
*/
export function unsupportedAttributes(declared: readonly string[], params: unknown): string[] {
const exact = new Set(declared);
return collectFilterAttributes(params).filter(
(attribute) =>
!exact.has(attribute) && !declared.some((parent) => attribute.startsWith(`${parent}.`))
);
}
/**
* The attributes this request asks for that its index does not declare filterable.
*
* Deliberately fails OPEN: an index we hold no declaration for returns `[]`, so a new or
* externally-configured index is never blocked by a stale list.
*/
export function findUnsupportedFilterAttributes(indexName: unknown, params: unknown): string[] {
if (typeof indexName !== 'string' || !indexName) return [];
const filterable = filterableAttributesByIndex[
indexName as keyof typeof filterableAttributesByIndex
] as readonly string[] | undefined;
if (!filterable) return [];
return unsupportedAttributes(filterable, params);
}
/** Carries the index and the offending attribute names, never the user's query. */
function reportRejection(indexName: string, attributes: string[]) {
const message = `Search filter attributes not filterable on "${indexName}": ${attributes.join(
', '
)}`;
pushSearchClientError(
new Error(message),
SEARCH_FILTER_GUARD_ERROR_TYPE,
{ indexes: indexName, attributes: attributes.join(',') },
message,
{ indexName, attributes }
);
}
/**
* Wrap a search client so a request whose filters cannot apply to its index resolves to empty
* results instead of being sent. Requests in the same batch that ARE valid still go to the
* backend, and their responses are returned in their original positions, with every sibling
* field of the response preserved.
*/
export function withSearchFilterGuard<T extends SearchClient>(client: T): T {
const shouldReport = createErrorReportCap();
const guardedSearch = async (requests: SearchRequests) => {
const list = (requests ?? []) as readonly SearchRequest[];
const rejected = new Map<number, string[]>();
list.forEach((request, index) => {
const indexName = (request as { indexName?: unknown })?.indexName;
const attributes = findUnsupportedFilterAttributes(
indexName,
(request as { params?: unknown })?.params
);
if (attributes.length) rejected.set(index, attributes);
});
if (rejected.size === 0) return client.search(requests);
for (const [index, attributes] of rejected) {
const indexName = String((list[index] as { indexName?: unknown })?.indexName ?? '');
if (!shouldReport(`${indexName}|${[...attributes].sort().join(',')}`)) continue;
reportRejection(indexName, attributes);
}
const survivors = list.filter((_, index) => !rejected.has(index));
if (survivors.length === 0) {
return { results: list.map(() => emptySearchResult()) };
}
const response = (await client.search(survivors as SearchRequests)) as {
results?: unknown[];
};
let cursor = 0;
const results = list.map((_, index) =>
rejected.has(index)
? emptySearchResult()
: response?.results?.[cursor++] ?? emptySearchResult()
);
return { ...response, results };
};
// Same cast-at-the-boundary reason as `createResilientSearchClient`: the library's `search`
// is generic over the hit type and a concrete wrapper that awaits the result cannot preserve
// that variance. Runtime behaviour is generic-transparent — either the base client's response
// verbatim, or a valid empty response of the same arity.
return { ...client, search: guardedSearch } as unknown as T;
}