Skip to content

Commit 6fd87b7

Browse files
fix(objectql,metadata-protocol)!: an in-process search-field override that names no searchable field is refused, never widened to a default set (#28120) (#28175)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DN1vFk8uepTZEvr1omkwvCG)_ domain:server#3 · os-elon-musk · session_016WV4cyi29iG8J3UUXr68AZ Fixes #28120 (family card; folds #28121, closed as folded into it) Clause-②: no (narrowing) — two in-process tolerances the Data API doors already refuse are closed: the stored-metadata reader seam stops narrowing a search-field override that names no field and hands it to the engine's refusal, and the engine refuses an override naming none of the fields its search scans. No export, answer member, status or error code is added; the refusals are the doors' `VALIDATION_ERROR` / 400 and `INVALID_FIELD` / 400. Metadata-protocol: none ## 维护者速读 - 改了什么:进程内调用方(host handler、hook、flow)的 `searchFields` 覆盖,凡是 REST 门会拒的两种值,进程内现在也拒:(a) 经存储元数据读缝的空列表,不再被改写成默认集合,交给引擎按 400 `VALIDATION_ERROR` 拒;(b) 全部字段名都不可搜(拼错、改名、内部字段)的覆盖,引擎按 400 `INVALID_FIELD` 拒,不再退回整个默认集合去搜。 - 为什么改:#28108 合同复审的 S1、S3 两条。调用方写覆盖是为了收窄,静默放宽成默认集合是错的。 - 风险与回滚:只拒原本被放宽的值;部分字段名可识别的覆盖、未给覆盖、合法覆盖都照旧。回滚即 revert。 - 先看:`packages/objectql/src/engine.ts` 的 `refuseSearchOverrideNamingNoSearchableField`,`packages/metadata-protocol/src/protocol.ts` 的 `narrowStoredMetadataSearch`。 - 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run src/engine-empty-search-fields-refusal.test.ts`,预期 47 passed。 - 需要你做的:无;席位安排契约复审后落地。 ## What changes **(a) The stored-metadata reader seam (`@objectstack/metadata-protocol`, `narrowStoredMetadataSearch`).** An explicit search-field list that names no field (an empty array, a blank or comma-only string, an array with no string entry) is now passed through untouched, so the engine's own refusal answers it. Before, it was read as "names none", narrowed, and the narrowed default set was written over the caller's list, so `searchFields: []` from a host handler on `sys_metadata` / `sys_metadata_history` was served. The door never reaches this with such a list: its ingress gate (`assertSearchFieldsAreSearchable`) refuses it first, unchanged. The docblock now says what is true: an empty override is refused by the engine, not read as absent. `refuseOrNarrowStoredMetadataEvaluate` in `packages/runtime` calls this function and needed no change. A non-string, non-array value is still narrowed, as before. **(b) The engine (`@objectstack/objectql`, `expandSearchOnAst`).** A `searchFields` or object-form `search.fields` override that names none of the fields the expansion scans is refused `400 INVALID_FIELD` (`param`, `field` / `fields` naming what the caller wrote, `object`) before any driver read, on `find`, `findOne`, `count` and `aggregate`. "Scans" is judged on the set the expansion resolves over (`resolveSearchFieldResolution` over the fields left after the `internal: true` and credential withholds), so this refuses exactly the overrides `resolveSearchFields` used to drop and widen back to the whole set, including an override naming only unknown fields on the stored-metadata family, which is now refused rather than resolved to the default set. Kept as it was: - an override naming SOME searchable field: the other names are still intersected away in process (the doors refuse them); - an object whose allowed set is empty: its own #24284 refusal still answers, naming that cause; - an absent or `null` override, and a non-array, non-string value. **`@objectstack/spec`, `resolveSearchFields`.** Docblock only: it records that the engine now refuses the fallback's input before this runs. The function itself is unchanged (it is spec's public helper and stays non-throwing). **Docs.** `content/docs/protocol/objectql/query-syntax.mdx`: the sentence saying direct engine callers keep the tolerant intersection now says one naming no searchable field is refused. ## Why Both halves come from the contract review of PR #28108 (record 6523659371, S1 and S3) and share one defect: an in-process override that the doors refuse was widened to a default set instead of refused. Triage 6524748197 graded it one family card, `Clause-②: no (narrowing)`. ## Verification Premises measured first on `main` `277275aa` (fix files reset to main and rebuilt), in-process, through the seam over a real `ObjectQL` with the real `SysMetadataObject` and a driver double recording each read: (a) `searchFields: []`, `' , '` and `search.fields: []` through the seam were served (narrowed set handed to the driver); (b) an all-unknown override widened to the default set on an ordinary object and on the family. REST callers: unchanged, refused by `assertSearchFieldsAreSearchable` (the door-parity cases below). - `packages/runtime/src/stored-metadata-reader-seam-search-override.test.ts` (new, 15, seam over a real engine, `find` / `findOne` / `count`): the empty array, comma-only string and empty object-form list are refused `400 VALIDATION_ERROR` before any driver read; an all-unknown override is refused `400 INVALID_FIELD` before any driver read; controls: a named override scans only what it names, an absent override is still narrowed off the body and hash columns. - `packages/objectql/src/engine-empty-search-fields-refusal.test.ts` (+32, all four verbs): all-unknown array, comma string, object form and a list with no string entry are refused `400 INVALID_FIELD` before any driver read; the refusal names the field; the REST door answers the same status and code; controls: partly-known override narrows to its known names, valid and absent overrides answer as before, an object with no searchable field keeps the #24284 refusal. - Two existing engine tests pinned the old fallback and now pin the refusal: `engine-search-empty-set.test.ts` (an override intersected to nothing) and `internal-field-positions.test.ts` (an override naming only an `internal: true` column; a partly-known one is still intersected away, never scanning it). - Ablations (fix files reset to `main` and rebuilt): seam fix out, engine fix in ⇒ 6 seam cases red (the empty-list cases), engine pins green; engine fix out, seam fix in ⇒ 14 engine cases red and 3 seam cases red (the all-unknown cases); both in ⇒ all green. Each half's pin bites on its own. - Package suites at head: objectql, metadata-protocol, runtime green; `pnpm --filter <pkg> typecheck` green for objectql, metadata-protocol, runtime, spec. Dogfood battery, every `dispatch-gates --commands` family and `pnpm lint`: results posted on the card with the os-dev report. Skills-docs: `git grep -n -i searchFields -- skills content/docs` read for wording on the engine's tolerance: `content/docs/protocol/objectql/query-syntax.mdx` said direct engine callers keep the tolerant intersection, updated here. `skills/objectstack-query/SKILL.md` describes the REST refusal only, still true; `content/docs/api/data-api.mdx` describes the door's history, still true. Generated `content/docs/references/**` untouched. Out of the order's file surface, left as is: the `assertSearchFieldsAreSearchable` docblock in `protocol.ts` (~L12546) explains the door's gate by the engine's old fallback; it stays true of `resolveSearchFields` itself and is outside `narrowStoredMetadataSearch`. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [ ] 3. No formatter ran; `pnpm lint` passes. (No formatter ran; the lint reading is posted on the card with the gate battery.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [ ] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Docblock only; the spec artifact gates' reading is posted on the card with the gate battery.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the lane's maintainer summary). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4cyi29iG8J3UUXr68AZ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 70209b8 commit 6fd87b7

10 files changed

Lines changed: 347 additions & 27 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/objectql': patch
3+
'@objectstack/metadata-protocol': patch
4+
'@objectstack/spec': patch
5+
---
6+
7+
fix(objectql,metadata-protocol)!: an in-process search-field override that names no searchable field is refused, never widened to a default set (#28120)
8+
9+
Clause-②: no (narrowing) — two in-process tolerances the Data API doors already refuse are closed. The stored-metadata reader seam (`narrowStoredMetadataSearch`) no longer narrows an override that names no field (an empty list, a comma string with no name, a list with no string entry): it passes it to the engine, which refuses it. And the engine (`find`, `findOne`, `count`, `aggregate`) refuses a `searchFields` or object-form `search.fields` that names none of the fields the search scans, where `resolveSearchFields` used to drop the names and scan the whole default set. No export, answer member, status or error code is added: the refusals are the doors' `VALIDATION_ERROR` / 400 and `INVALID_FIELD` / 400. Folds #28121.
10+
Metadata-protocol: none
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: an engine option value the doors already refuse is now refused in process too, with the doors' codes. There is nothing for a migration to rewrite and no tombstone. -->
13+
14+
**BREAKING** accept-set narrowing at the in-process search override (`@objectstack/objectql`, `@objectstack/metadata-protocol`).
15+
16+
- **Before.** A host handler, hook or flow reading `sys_metadata` / `sys_metadata_history` through the reader seam with `searchFields: []` was served: the seam read the empty list as "names none" and wrote the narrowed default set over it. On any object, a search whose override named no searchable field (every name unknown, unsearchable or withheld) was resolved to the object's default search set.
17+
- **Now.** The empty override reaches the engine's refusal through the seam: `400 VALIDATION_ERROR`, before any driver read. An override naming no searchable field is refused `400 INVALID_FIELD` (`param: 'searchFields'` or `'search.fields'`, `field` / `fields` naming what the caller wrote) before any driver read, the code the REST door answers for the same value. `mapDataError` serves both with the door's status and code.
18+
- **Unchanged.** An absent (or `null`) override searches the default set. An override naming no searchable field sent without a `search` is still not judged in process, since nothing is scanned (the door refuses it with or without a search term). A named override narrows as before. An override naming SOME searchable field still has its other names dropped in process (the doors refuse them). An object with no searchable field at all keeps its own refusal (#24284).
19+
20+
**The remedy.** Name at least one field the object searches (its `searchableFields`, or the default set), or leave the key out to search the default fields.

‎content/docs/data-modeling/queries.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -545,7 +545,9 @@ override that names no field at all (`?$searchFields=`, `?$searchFields=,`, an e
545545
array) is `400 VALIDATION_ERROR`, as `$select=` is; leave the parameter out to scan the
546546
default set. The engine used to drop unknown names and fall back to the full searchable
547547
set — a parameter that exists to narrow a search, silently widening it. Internal callers
548-
reaching `engine.find()` directly are unaffected.
548+
reaching `engine.find()` directly get the same two refusals for an override naming no field
549+
(`400 VALIDATION_ERROR`) and one naming none of the searchable fields (`400 INVALID_FIELD`);
550+
an override naming at least one searchable field there still has its other names dropped.
549551
</Callout>
550552

551553
### Searching by a related record's title — mirror the value

‎content/docs/protocol/objectql/query-syntax.mdx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1190,7 +1190,10 @@ plus short-text/enum fields), so naming a field outside that set can never widen
11901190
search — and over the REST/protocol ingress it is `400 INVALID_FIELD` outright (#4254),
11911191
because the engine-side intersection alone used to drop the unknown name and fall back to
11921192
scanning the full searchable set. Internal callers reaching `engine.find()` directly keep
1193-
the tolerant intersection, but not a search with nothing to scan: when no
1193+
the tolerant intersection for an override naming at least one searchable field (the other
1194+
names are dropped), but an override naming none of them is refused there too,
1195+
`400 INVALID_FIELD`, rather than widened to the full set. Nor is a search with nothing to
1196+
scan answered: when no
11941197
`searchableFields` entry names a stored field and the auto-default finds none
11951198
(`internal: true` fields never count), the engine itself refuses a non-blank `search`
11961199
with `400 INVALID_FIELD` (`param: 'search'`) on `find`, `findOne`, `count` and

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5186,18 +5186,28 @@ export interface StoredMetadataSearchSchema {
51865186
* string or an array) — naming one is refused, `INVALID_FIELD` / 400, the
51875187
* evaluate refusals' envelope, under the caller's wire spelling of the slot
51885188
* (`wireSpelling`, the door's; the bare canonical name when absent);
5189+
* - [#28120] an explicit list that names NO field — an empty array, a blank or
5190+
* comma-only string, an array with no string entry — is not "names none": it
5191+
* is passed through untouched, for the engine's own refusal to answer
5192+
* (`400 VALIDATION_ERROR` for an empty one, #27732; `400 INVALID_FIELD` for
5193+
* one naming no searchable field). Narrowing it here wrote a non-empty set
5194+
* back over the caller's list, so an override the door and the engine both
5195+
* refuse was served. The door never reaches this with one: its ingress gate
5196+
* refuses it first;
51895197
* - a search that names none is NARROWED: the object's resolved searchable set
51905198
* ({@link resolveSearchFieldResolution}) minus those columns is returned, for
51915199
* the caller to run as `searchFields` — the engine intersects an override
51925200
* with that set and never widens it;
51935201
* - a set that narrows to nothing is refused rather than returned empty: an
5194-
* empty override is ABSENT to the engine, which would then scan the whole
5195-
* default set, these columns included.
5202+
* empty override is refused by the engine as one naming no field, a
5203+
* caller's mistake this caller did not make, so the family's own refusal
5204+
* names the cause instead.
51965205
*
51975206
* Returns `undefined` when the query runs as it is — an object outside the
5198-
* family, no search, an explicit list that passes, or no readable field map
5199-
* (the engine has none to expand a search over either; the same rule the door's
5200-
* field gates apply: a non-object, array or empty `fields`). Throws the refusal.
5207+
* family, no search, an explicit list that passes or names no field, or no
5208+
* readable field map (the engine has none to expand a search over either; the
5209+
* same rule the door's field gates apply: a non-object, array or empty
5210+
* `fields`). Throws the refusal.
52015211
*/
52025212
export function narrowStoredMetadataSearch(
52035213
object: string,
@@ -5222,6 +5232,8 @@ export function narrowStoredMetadataSearch(
52225232
if (refusal) throw refusal;
52235233
return undefined;
52245234
}
5235+
// [#28120] A list naming no field is the engine's to refuse, never narrowed.
5236+
if (typeof explicit === 'string' || Array.isArray(explicit)) return undefined;
52255237
if (search == null) return undefined;
52265238
const fields = schema?.fields;
52275239
if (!fields || typeof fields !== 'object' || Array.isArray(fields) || Object.keys(fields).length === 0) {

‎packages/objectql/src/engine-empty-search-fields-refusal.test.ts‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,84 @@ describe('[#27732] the in-process engine refuses a search-field override naming
221221
expect(await answered(engine, 'find', { search: 'harbour', searchFields: null })).toEqual(DEFAULT_ANSWER.find);
222222
});
223223
});
224+
225+
/**
226+
* [#28120] …and an override that names no SEARCHABLE field — every name
227+
* unknown, or no entry a field name at all — is refused `400 INVALID_FIELD`,
228+
* the code the door answers the same value with (`assertSearchFieldsAreSearchable`,
229+
* #4254). `resolveSearchFields` dropped the unknown names and fell back to the
230+
* whole default set, so a typo or a renamed field searched every default
231+
* column instead of the one the caller meant. An override naming at least one
232+
* searchable field keeps the engine's tolerance: the other names are dropped.
233+
*/
234+
describe('[#28120] the in-process engine refuses a search-field override naming no searchable field', () => {
235+
let engine: ObjectQL;
236+
let reads: Array<{ via: string; where: unknown }>;
237+
238+
beforeEach(async () => {
239+
engine = new ObjectQL();
240+
const made = makeDriver();
241+
reads = made.reads;
242+
engine.registerDriver(made.driver, true);
243+
await engine.init();
244+
engine.registry.registerObject(account, 'test-package');
245+
});
246+
247+
describe.each(VERBS)('%s', (verb) => {
248+
it('an all-unknown searchFields list is refused 400 INVALID_FIELD, before any driver read', async () => {
249+
const answer = await wireAnswer(run(engine, verb, { search: 'harbour', searchFields: ['nmae', 'town'] }));
250+
expect(answer).toMatchObject({ status: 400, code: 'INVALID_FIELD', param: 'searchFields' });
251+
expect(reads).toEqual([]);
252+
});
253+
254+
it('the comma-string and object-form spellings are refused the same way', async () => {
255+
expect(await wireAnswer(run(engine, verb, { search: 'harbour', searchFields: 'nmae' })))
256+
.toMatchObject({ status: 400, code: 'INVALID_FIELD', param: 'searchFields' });
257+
expect(await wireAnswer(run(engine, verb, { search: { query: 'harbour', fields: ['nmae'] } })))
258+
.toMatchObject({ status: 400, code: 'INVALID_FIELD', param: 'search.fields' });
259+
expect(reads).toEqual([]);
260+
});
261+
262+
it('a list with no field-name entry at all is refused too', async () => {
263+
expect(await wireAnswer(run(engine, verb, { search: 'harbour', searchFields: [42] })))
264+
.toMatchObject({ status: 400, code: 'INVALID_FIELD', param: 'searchFields' });
265+
expect(reads).toEqual([]);
266+
});
267+
268+
it('CONTROL: a partly-known override still narrows to its known names (the tolerance kept)', async () => {
269+
expect(await answered(engine, verb, { search: 'harbour', searchFields: ['name', 'nmae'] })).toEqual(NAME_ONLY_ANSWER[verb]);
270+
});
271+
272+
it('CONTROL: a valid override narrows and an absent one searches the default set, as before', async () => {
273+
expect(await answered(engine, verb, { search: 'harbour', searchFields: ['name'] })).toEqual(NAME_ONLY_ANSWER[verb]);
274+
expect(await answered(engine, verb, { search: 'harbour' })).toEqual(DEFAULT_ANSWER[verb]);
275+
});
276+
});
277+
278+
it('the refusal names the field the caller wrote', async () => {
279+
const err: any = await engine.find(account.name, { search: 'harbour', searchFields: ['nmae'] }).catch((e: unknown) => e);
280+
expect(err).toMatchObject({ field: 'nmae', fields: ['nmae'], object: account.name });
281+
expect(err.message).toContain("names no searchable field in 'searchFields'");
282+
});
283+
284+
it('the REST door answers the same status and code for the same override', async () => {
285+
const protocol = new ObjectStackProtocolImplementation(engine);
286+
const door = await wireAnswer(protocol.findData({ object: account.name, query: { search: 'harbour', searchFields: 'nmae' } as any }));
287+
const inProcess = await wireAnswer(engine.find(account.name, { search: 'harbour', searchFields: ['nmae'] }));
288+
expect({ status: inProcess.status, code: inProcess.code }).toEqual({ status: door.status, code: door.code });
289+
expect(door).toMatchObject({ status: 400, code: 'INVALID_FIELD' });
290+
expect(reads).toEqual([]);
291+
});
292+
293+
it('CONTROL: an object with no searchable field keeps its own refusal (#24284), naming that cause', async () => {
294+
engine.registry.registerObject({
295+
name: 'link_28120',
296+
label: 'Link',
297+
fields: { id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true } },
298+
searchableFields: ['gone'],
299+
} as any, 'test-package');
300+
const err: any = await engine.find('link_28120', { search: 'harbour', searchFields: ['nmae'] }).catch((e: unknown) => e);
301+
expect(err).toMatchObject({ code: 'INVALID_FIELD', param: 'search' });
302+
expect(err.message).toContain('has no searchable field');
303+
});
304+
});

‎packages/objectql/src/engine-search-empty-set.test.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -176,14 +176,14 @@ describe('[#24284] a search over an empty resolved search set is refused', () =>
176176
}
177177
});
178178

179-
it('control: an object with a stored text field still filters, and an override intersected to nothing falls back to its set', async () => {
179+
it('control: an object with a stored text field still filters, and an override intersected to nothing is refused', async () => {
180180
expect((await engine.find('lib_book', { search: 'Dune' })).map((r) => r.id)).toEqual(['b1']);
181181
expect(await engine.find('lib_book', { search: 'zzzz-nothing' })).toEqual([]);
182182
expect(await engine.count('lib_book', { search: 'zzzz-nothing' })).toBe(0);
183-
// A direct engine caller naming only fields outside the allowed set: the
184-
// tolerant intersection falls back to the allowed set — a FILTERED
185-
// answer, never every row.
186-
expect((await engine.find('lib_book', { search: 'emma', searchFields: ['nope'] })).map((r) => r.id)).toEqual(['b2']);
183+
// [#28120] A direct engine caller naming only fields outside the allowed
184+
// set is refused, never answered over the whole allowed set.
185+
await expect(engine.find('lib_book', { search: 'emma', searchFields: ['nope'] }))
186+
.rejects.toMatchObject({ status: 400, code: 'INVALID_FIELD', param: 'searchFields', field: 'nope' });
187187
});
188188
});
189189

‎packages/objectql/src/engine.ts‎

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { QueryAST, QueryInput, HookContext, ServiceObject } from '@objectstack/s
55
// [commit 74155c735] The defaulting node schema `fillQueryAstDefaults` runs author input
66
// through — the declared `.default()` stays in `packages/spec`, the engine
77
// only invokes it.
8-
import { SortNodeSchema, FullTextSearchSchema } from '@objectstack/spec/data';
8+
import { SortNodeSchema, FullTextSearchSchema, resolveSearchFieldResolution } from '@objectstack/spec/data';
99
import {
1010
EngineQueryOptions,
1111
DataEngineInsertOptions,
@@ -769,6 +769,53 @@ function refuseSearchOverrideNamingNoField(object: string, requested: unknown, p
769769
throw err;
770770
}
771771

772+
/**
773+
* [#28120] …and an override that names no field the search can scan is refused
774+
* `400 INVALID_FIELD`, the code the door answers the same value with
775+
* (`assertSearchFieldsAreSearchable`, #4254): every name unknown, unsearchable,
776+
* or withheld from the scan (`internal: true`, credential types), or no entry a
777+
* field name at all.
778+
*
779+
* `resolveSearchFields` drops the names outside the allowed set and, when none
780+
* is left, falls back to the whole set — the typo or the renamed field the
781+
* caller wrote to narrow the search ran over the object's default fields
782+
* instead, untold. Judged on the set the expansion resolves over (`allowed`),
783+
* so this refuses exactly the overrides that fallback would have widened. An
784+
* override naming at least one allowed field is left to the intersection (the
785+
* engine's tolerance for the other names stands), and an empty `allowed` is
786+
* left to the expansion's own refusal (#24284), which names that cause.
787+
*/
788+
function refuseSearchOverrideNamingNoSearchableField(
789+
object: string,
790+
requested: unknown,
791+
param: string,
792+
allowed: readonly string[],
793+
): void {
794+
const entries = typeof requested === 'string'
795+
? requested.split(',').map((s) => s.trim()).filter(Boolean)
796+
: Array.isArray(requested) ? requested : undefined;
797+
if (entries === undefined || entries.length === 0 || allowed.length === 0) return;
798+
const allowSet = new Set(allowed);
799+
if (entries.some((f) => typeof f === 'string' && allowSet.has(f))) return;
800+
const names = entries.filter((f): f is string => typeof f === 'string');
801+
const err: any = new Error(
802+
`ObjectQL search on '${object}' names no searchable field in '${param}': got ${JSON.stringify(requested)}. `
803+
+ 'The query was not run: an override naming none of the fields the search scans was dropped, which '
804+
+ "scans the object's default search fields. Name a searchable field, or leave the override out to "
805+
+ 'search the default fields.',
806+
);
807+
err.status = 400;
808+
err.httpStatus = 400;
809+
err.code = 'INVALID_FIELD';
810+
if (names.length > 0) {
811+
err.field = names[0];
812+
err.fields = names;
813+
}
814+
err.param = param;
815+
err.object = object;
816+
throw err;
817+
}
818+
772819
/**
773820
* Rows per page for the autonumber seeding scan (#6249). This is a PAGE size,
774821
* not a cap: the walk pages until the scope is exhausted. The number is the one
@@ -13453,8 +13500,9 @@ export class ObjectQL implements IObjectQLEngine {
1345313500
// data door instead (`assertSearchFieldsAreSearchable`,
1345413501
// `@objectstack/metadata-protocol`), the #4254 posture — an unhonoured
1345513502
// narrowing must be a 400, not a silent widening. A direct engine caller
13456-
// naming one has it intersected away here, the engine's existing tolerant
13457-
// posture for an unknown requested search field.
13503+
// naming one beside a searchable field has it intersected away here, the
13504+
// engine's existing tolerant posture for an unknown requested search
13505+
// field; naming only such fields is refused below (#28120).
1345813506
// [ADR-0100] …and never a `secret` / `password` column either, by the
1345913507
// collector the credential guards read: the auto-default already skips
1346013508
// those types, and an author-declared `searchableFields` naming one is
@@ -13475,6 +13523,12 @@ export class ObjectQL implements IObjectQLEngine {
1347513523
// `displayNameField` is the deprecated alias (still honored).
1347613524
displayField: (schema as any).nameField ?? (schema as any).displayNameField,
1347713525
};
13526+
// [#28120] An override naming none of the fields the expansion would
13527+
// scan is refused, never widened back to the whole set.
13528+
refuseSearchOverrideNamingNoSearchableField(
13529+
ast.object, requestedFields, (ast as any).searchFields != null ? 'searchFields' : 'search.fields',
13530+
resolveSearchFieldResolution(expandOptions).allowed,
13531+
);
1347813532
// [#25672] …and never a column the CALLER may not query. The field-read
1347913533
// predicate guard (plugin-security) runs after this expansion and judges
1348013534
// the expanded `$or` as the caller's own predicate, so one hidden column
@@ -13503,9 +13557,9 @@ export class ObjectQL implements IObjectQLEngine {
1350313557
// set the expansion itself resolved (no second resolver), so it holds
1350413558
// for every verb that reaches here (`find` / `findOne` / `aggregate` /
1350513559
// the count) and every door (REST, dispatcher, SDK, a hook's
13506-
// `ctx.api`). An explicit override intersected to nothing falls back
13507-
// to the allowed set inside `resolveSearchFields`, so it reaches this
13508-
// only when that set is empty too. [#25672] A set the caller's
13560+
// `ctx.api`). An explicit override intersected to nothing is refused
13561+
// before the expansion (#28120) unless the allowed set is empty too, so
13562+
// it reaches this only then. [#25672] A set the caller's
1350913563
// queryable fields withheld down to nothing gets the same answer: the
1351013564
// declared set expands without the narrowing, so the refusal says so.
1351113565
const withheld = queryableFields !== undefined && expandSearchToFilter(raw, expandOptions) !== null;

0 commit comments

Comments
 (0)