Skip to content

Commit ec749fb

Browse files
fix(objectql)!: a search over an object with no searchable field is refused with 400 INVALID_FIELD, not answered as every row (#24320)
Fixes #24284 Clause-②: no (narrowing) ## What A non-blank `search` over an object whose resolved search set is empty is now refused with `400 INVALID_FIELD` (`param: 'search'`), instead of being dropped and answering every row. The canonical shape is a junction object: two lookups plus a composed formula title. The auto-default scans textual and enum types only, so its set is empty. - **One judgement, one place:** `ObjectQL.expandSearchOnAst` (`packages/objectql/src/engine.ts`). `expandSearchToFilter` answers `null` for exactly two reasons, an empty term or an empty resolved set. Once the term is non-blank, the second reason is the only one left, so the refusal reuses the set the expansion already resolved and adds no second resolver. `find`, `findOne`, `count` (the list's `total`) and `aggregate` all reach this call, so every door gets the same answer: REST list, REST query, grouped read, dispatcher, SDK, and a hook's `ctx.api`. - **The message** names the object and says why its set is empty. If no `searchableFields` is declared, it says none of its fields is a type the default scans, and lists those types. If a declaration exists, it lists the entries that name no stored field. If `internal: true` columns were left out, it says so. It ends with the two remedies: declare `searchableFields` naming a stored field, or mirror the composed title onto a stored text field. No tracker number appears in the runtime text. - **`searchAll` keeps its skip and its `200`** (`packages/metadata-protocol/src/protocol.ts`). Measuring this found one gap: the skip resolved the set over the object's fields INCLUDING `internal: true` ones, which the engine never scans. An object whose only text column is internal therefore passed the skip and reached `find`. Before this PR, that returned that object's first rows as palette hits. With the refusal in place it would have failed the whole sweep. The skip now leaves internal fields out, using the same flag reader the #22646 ingress gate uses. The pin is in `engine-search-empty-set.test.ts`. I did not find a registered object of that shape in the composed todo app: the palette swept 36 objects and answered `200`. - `expandSearchToFilter` stays pure and non-throwing. Its docblock now separates its two `null` cases. **Code: `INVALID_FIELD`, not `INVALID_QUERY`.** The request is well-formed. What cannot be honoured is a fact about the object's FIELDS. That is the axis #4254's explicit-override gate already answers with `INVALID_FIELD`, and the same code a search naming the formula title gets today. `invalidQueryError`'s docblock reserves `INVALID_QUERY` for shape mistakes that are "not about any FIELD". No new code is added. The answer is derived from the declaration, never from the caller's permissions, so it is the same for every caller. **Where it landed.** It landed where the claim expected it: `expandSearchOnAst`, the one call site. The data door's ingress gate (`assertSearchFieldsAreSearchable`) is not touched. It judges only an explicit override, and a refusal there would leave the dispatcher, SDK and hook doors silent. The H1 measurement below shows the engine-side refusal reaches every door with the message and code already shaped. ## Measured over HTTP: before and after The `examples/app-todo` backend ran fresh on SQLite (`pnpm dev -- --fresh --compile`), signed in as the seeded admin. A scratch fixture added `todo_lib_book` (`title: text`), `todo_lib_reader` (`name: text`) and `todo_lib_loan`, which has the `lib_loan` shape: `book` and `reader` lookups plus `title`, a formula `nameField` written `record.book.title + " · " + record.reader.name`. Seed data: 3 books, 1 reader, 5 loans. The fixture was removed afterwards and the tree is clean. The "before" run is the same tree with the refusal ablated in `dist/` (see Ablation), and it reproduces the card's report exactly. | request | before | after | |---|---|---| | `GET /data/todo_lib_loan?search=zzzz-nothing` | 200, 5 rows | **400 `INVALID_FIELD`** | | `GET /data/todo_lib_loan?search=Dune` | 200, 5 rows | **400 `INVALID_FIELD`** | | `GET …?search=Dune&count=true&limit=1` (count / `total`) | 200, `total: 5` | **400 `INVALID_FIELD`** | | `POST /data/todo_lib_loan/query {"search":"zzzz-nothing"}` | 200, 5 rows | **400 `INVALID_FIELD`** | | `POST …/query {"search":{"query":"zzzz"}}` | 200, 5 rows | **400 `INVALID_FIELD`** | | `POST …/query {"search":"zzzz","groupBy":["book"],"aggregations":[count]}` | 200, 3 buckets | **400 `INVALID_FIELD`** | | `GET …?search=Dune&searchFields=` (empty override = absent) | 200, 5 rows | **400 `INVALID_FIELD`** | | `GET …?search=Dune&searchFields=title` (formula) | 400 `INVALID_FIELD` (virtual field) | unchanged | | `GET /data/todo_lib_loan`, `?search=`, `?search=%20%20` | 200, 5 rows | unchanged | | control `GET /data/todo_lib_book?search=Dune` / `zzzz-nothing` | 200, 1 / 0 rows | unchanged | | `GET /search?q=Dune` (palette) | 200, 1 hit (book), `totalObjects: 36` | unchanged | | `GET /search?q=Dune&objects=todo_lib_loan` | — | 200, `hits: []`, `totalObjects: 0` | The after-message, verbatim: "Object 'todo_lib_loan' has no searchable field, so 'search' cannot be honoured on it: it declares no 'searchableFields', and none of its fields is a type the default search scans (text / email / phone / url / autonumber / textarea / markdown / select). Answering every row instead would read as "everything matched". Declare 'searchableFields' on 'todo_lib_loan' naming a stored field, or mirror the composed title onto a stored text field and search that." `GET /data/:object/export?search=` was NOT MEASURED over HTTP: the dev admin holds no export capability, so the route answers `403 EXPORT_NOT_PERMITTED` before it reads the search. The route reads through `findData`, the door pinned below. ## Internal producers of `search` (H3) | producer | verdict | |---|---| | `searchAll` (⌘K palette, `GET /api/v1/search`) | **skips** an empty set before it queries. Now judged on the engine's scan set (internal columns left out). Stays `200`; pinned. | | `GET /data/:object/export` (the `search` param) | **surfaces the refusal**: it reads through `findData` inside its chunk loop. As `csv` it answers the `400 INVALID_FIELD` envelope. As `json` or `xlsx` the route opens its stream before the first read, so a refusal there ends a committed `200` with a truncated stream. That is the route's existing posture for every refusal `findData` throws in the loop. *(Corrected by the seat from record ③.4.)* | | `GET /data/:object/import-template` (template mode) | **unchanged**: `search` is a template-inapplicable parameter, refused `400 VALIDATION_ERROR` before any read (`TEMPLATE_INAPPLICABLE_PARAMS`). It never reaches the engine. *(Corrected by the seat from record ③.4.)* | | `@objectstack/client` `QueryBuilder.search` / `data.find` | **surface the refusal**: a passthrough to the data door. | | MCP (`packages/mcp`), runtime, flows (`service-automation` crud nodes), analytics | **no producer**: no `search` key is emitted (`git grep` over each package's `src`, tests excluded). | | import by-name lookup resolution | **no producer**: it does not use `search`. | | server-side record picker | none in this repo. The console's picker sends `search` to the data door (below). | ## Pins `packages/objectql/src/engine-search-empty-set.test.ts`, run over a real `ObjectQL` and, for the protocol cases, a real `ObjectStackProtocolImplementation`: - junction object × `find` / `findOne` / `count` / `aggregate` × three term shapes → `{ code: 'INVALID_FIELD', status: 400, object, param: 'search' }`, the message's first sentence, the reason and both remedies. The driver is never read. - an explicit `searchFields` (`['title']`, `['book']`, `['nope']`, `[]`) does not rescue it, because the allowed set, and so the fallback, is empty. This is H4's "explicit names all intersected away" case. On an object WITH a searchable set, that case falls back to the allowed set inside `resolveSearchFields`, so it answers filtered rows, never every row (control pin). It reaches the refusal only when the allowed set is empty too. - a declaration whose entries are all unscannable → the refusal lists them. - an object whose only text column is `internal: true` → refused, saying why. - blank / whitespace term → every row, `where` untouched, on find and count. - control object → filters; `zzzz-nothing` → 0 rows and count 0. - `searchAll` over {book, loan, internal-only} → the book's hit only, `totalObjects: 1`, and no read of the other two. Over {loan, internal-only} alone → `200`, `totalObjects: 0`. - `findData` (list / query shapes) over the junction → the refusal; control and blank term unchanged. **Pin sweep.** No existing pin asserted rows for a search over an object with no searchable field. I ran the full `@objectstack/objectql` and `@objectstack/metadata-protocol` suites, plus every `rest` / `runtime` test file that sends `search`, and none flipped. No fixture needed a stored searchable field added. ## Ablation (the before leg) The fix was committed first. `node scripts/ablation-replace.mjs` replaced `throw emptySearchSetError(…)` with `void emptySearchSetError(…)` in `engine.ts`. Injected spelling count 1, removed spelling count 0. The suite ran on the mutated source: **8 failed / 3 passed**. The 8 refusal pins went red, and the 3 preservation pins (blank term, control, palette skip) stayed green, which is the expected direction. The package was rebuilt; `ablation-dist-preflight.mjs @objectstack/objectql 'void emptySearchSetError'` reported the marker present in 4 built files. The HTTP "before" column was read from that build. Restore: `ablation-replace --restore` (blob == HEAD, `git diff HEAD` empty), rebuild, then `ablation-dist-preflight … --absent` reported the marker absent from all 14 built files and the working tree clean. ## Verification record - Tests on merged head `bead2f2a4a`: `@objectstack/objectql` 441 files / 8288 tests passed. `@objectstack/metadata-protocol` 244 files passed and 3 skipped, 20641 tests passed and 19 skipped. On the final head `fd4e073a6c` (after a second `origin/main` merge that touched neither package's source): build of the `metadata-protocol` closure plus `objectql`, both packages' `typecheck`, and the pin file (11/11). - `typecheck`: `@objectstack/objectql` exit 0 (`check:test-typecheck: OK`, the new test file compiles with no ledger entry), `@objectstack/metadata-protocol` exit 0. - Gates: `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derived 71 families on `bead2f2a4a`. All 71 ran, each with its exit code captured before any pipe, and all exited 0. `--ran` reconciliation: "71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN … a DERIVED zero". Three families first answered with a prerequisite, not a verdict: `check-engine-split-ratio` and `check-plugin-teardown-shape --self-test` (shallow clone; deepened), and `check:dual-build-cjs-loads` (8 unbuilt packages; built). Re-run, all three exited 0. The first run also turned up two real findings, both in the new test file, fixed and re-run green: `check:test-source-alias` (a dynamic import of `@objectstack/metadata-protocol` inside a clocked test body), and `check:query-options-erasure` (untyped option bags; the test surface rose from 236 to 245 sites and is back to 236). On `fd4e073a6c` the derived list is identical. The error / changeset / doc / ratchet families were re-run there and exited 0: `check:error-status-conformance`, `check:dispatcher-error-vocabulary`, `check:doc-authoring`, `check:nul-bytes`, `check-adr-0087-registration`, `check-changeset-no-major`, `check-empty-changeset`, `check:query-options-erasure`, `check:test-source-alias`. ## Consumer pointer (objectui, routed by the seat) Read at objectui's pinned `.objectui-sha` `5e15928b`. The console's list quick-search (`plugin-list` `ListView` / `plugin-grid` `ObjectGrid`) and the record picker (`fields` `useRecordQuery`) send `search`, through `data-objectstack` `find`: `GET /api/v1/data/OBJECT?search=TERM`. On a junction object, that request used to get 200 with every row, which the list showed as if everything matched. It now gets `400 INVALID_FIELD` with `param: 'search'` and the message above. `ListView` shows the search box unless `userActions.search === false`. It does not consult the object's resolved search set. What the console should do: hide quick-search and picker type-ahead on an object whose resolved set is empty, judged by the shared `resolveSearchFieldResolution` from `@objectstack/spec/data` over the object schema (internal fields excluded). Where the box is still shown, render the refusal's message rather than an empty or unchanged list. No objectui edit is made here. ## Acceptance notes - The changesets are `@objectstack/objectql` minor (the narrowing; it carries the `Clause-②` line and the ADR-0087 `not-required (no-migration-prescription)` marker) and `@objectstack/metadata-protocol` patch (the palette skip's alignment). - The file surface follows the claim, plus the changesets. `protocol.ts` changed only at `searchAll`'s skip: its docblock, and the input to the resolution the skip judges, as measured above. `packages/spec`, `packages/rest`, objectui and docs are untouched. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_01VaU1aMkDV7udHtCGM1KaRF)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
1 parent 1173017 commit ec749fb

6 files changed

Lines changed: 340 additions & 5 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): global search judges an object's searchable set without its `internal: true` fields, the set the engine scans
6+
7+
Global search (`searchAll`, `GET /api/v1/search`) passes over an object with nothing to scan before it queries. It judged that from the object's fields including `internal: true` ones, which the engine never searches. An object whose only text field is internal therefore passed the skip and reached the engine, which now refuses a search over an empty set, so the whole sweep would have failed. The skip now leaves internal fields out, so such an object is passed over and the sweep answers `200` with the other objects' hits.
8+
9+
What to do. Nothing.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
feat(objectql)!: a `search` over an object with no searchable field is refused with `400 INVALID_FIELD` instead of answering every row
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No spec key, stored shape or authorable spelling is added, removed or renamed. The narrowing is in what a read answers: a non-blank `search` term over an object whose resolved search set is empty used to be dropped (200, every row) and is now a 400 that names the object and the remedy. The remedy is a metadata edit the author makes by hand (declare `searchableFields`, or add a stored text field), so there is nothing stored for `objectstack migrate meta` to rewrite. -->
10+
11+
A search term is matched against the object's resolved search fields: its `searchableFields` declaration (entries that name a stored field), or else the default set (the name/title field and the text, email, phone, url, autonumber, textarea, markdown and select fields). On an object where that set is empty, such as a junction object made of two lookups and a formula title, the term used to add no filter, so `?search=zzzz-nothing` and `?search=Dune` returned the same rows, and a user read every row as a match.
12+
13+
- **What changes.** `find`, `findOne`, `count` and `aggregate` answer a non-blank `search` over an empty resolved set with `400 INVALID_FIELD` (`param: 'search'`). That covers every door that reaches the engine: the REST list and query routes (and the list's `total`), the grouped read, the dispatcher, the SDK and a hook's `ctx.api`. The message names the object, says why the set is empty, and gives the two remedies.
14+
- **Unchanged.** A blank or whitespace term is not a search and still answers every row. An object with a searchable field filters as before. An explicit `searchFields` naming a field outside the set keeps its own `400 INVALID_FIELD` at the data door. Global search (`GET /api/v1/search`) still passes over an object with nothing to scan and answers `200`.
15+
16+
What to do. If a read now answers `400 INVALID_FIELD` with "has no searchable field", declare `searchableFields` on that object naming a stored field, or mirror the composed title onto a stored text field and search that. A client that offers a search box on such an object should hide it, or show the refusal.

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15847,8 +15847,21 @@ export class ObjectStackProtocolImplementation implements
1584715847
// `search` instead of a pre-built `where` routes through it anyway —
1584815848
// and leaves ONE producer of search clauses rather than two callers
1584915849
// of one helper, which is the stronger form of the same fix.
15850+
//
15851+
// [#24284] …and the cliff in (1) is now a REFUSAL one call down:
15852+
// the engine answers a non-blank term over an empty resolved set
15853+
// with `400 INVALID_FIELD` rather than every row. The skip stays,
15854+
// and it is what keeps the palette `200` — the sweep passes over an
15855+
// object with nothing to scan instead of failing on it, since this
15856+
// method chose the object, not the caller. So the set judged here
15857+
// must be the set the engine resolves: the `internal: true` columns
15858+
// it never scans are left out of it too, through the same flag
15859+
// reader the #22646 ingress gate uses. Without that, an object
15860+
// whose only text column is internal would pass this skip, reach
15861+
// `find`, and fail the whole sweep.
15862+
const internalFields = new Set(collectInternalWriteResponseFields(obj));
1585015863
const fieldMetaByName: Record<string, any> = {};
15851-
for (const f of fields) if (f?.name) fieldMetaByName[f.name] = f;
15864+
for (const f of fields) if (f?.name && !internalFields.has(f.name)) fieldMetaByName[f.name] = f;
1585215865
const { allowed: declaredSearchFields } = resolveSearchFieldResolution({
1585315866
fields: fieldMetaByName,
1585415867
searchableFields: obj.searchableFields,
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24284] A non-blank `search` over an object whose RESOLVED search set is
5+
* empty is refused — `400 INVALID_FIELD`, on every verb that reaches
6+
* `expandSearchOnAst` — instead of answering every row.
7+
*
8+
* The shape is the junction object: two lookups and a composed formula title.
9+
* The auto-default scans textual and enum types only, so it resolves no field,
10+
* `expandSearchToFilter` answers `null`, and before this refusal the term was
11+
* dropped with `where` untouched: `search: 'zzzz-nothing'` and `search: 'Dune'`
12+
* returned the same five rows, read by a user as "everything matched".
13+
*
14+
* What stays as it was, pinned beside the refusal so a fix that over-reaches
15+
* goes red here too: a blank term is not a search; a control object with a
16+
* stored text field still filters; an explicit override intersected to nothing
17+
* on an object WITH a searchable set falls back to that set (filtered, never
18+
* every row).
19+
*/
20+
21+
import { describe, it, expect, beforeEach } from 'vitest';
22+
import type { EngineQueryOptions } from '@objectstack/spec/data';
23+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
24+
import { ObjectQL } from './engine.js';
25+
26+
type Search = EngineQueryOptions['search'];
27+
28+
type Row = Record<string, unknown>;
29+
30+
const LOANS: Row[] = [
31+
{ id: 'l1', book: 'b1', reader: 'r1' },
32+
{ id: 'l2', book: 'b2', reader: 'r1' },
33+
{ id: 'l3', book: 'b3', reader: 'r1' },
34+
{ id: 'l4', book: 'b1', reader: 'r1' },
35+
{ id: 'l5', book: 'b2', reader: 'r1' },
36+
];
37+
const BOOKS: Row[] = [
38+
{ id: 'b1', title: 'Dune' },
39+
{ id: 'b2', title: 'Emma' },
40+
{ id: 'b3', title: 'Ulysses' },
41+
];
42+
43+
function matches(row: Row, where: unknown): boolean {
44+
if (!where || typeof where !== 'object') return true;
45+
for (const [k, v] of Object.entries(where as Row)) {
46+
if (k === '$and') { if (!(v as unknown[]).every((w) => matches(row, w))) return false; continue; }
47+
if (k === '$or') { if (!(v as unknown[]).some((w) => matches(row, w))) return false; continue; }
48+
const op = v as Row;
49+
if (op && typeof op === 'object' && '$icontains' in op) {
50+
if (!String(row[k] ?? '').toLowerCase().includes(String(op.$icontains).toLowerCase())) return false;
51+
continue;
52+
}
53+
if (row[k] !== v) return false;
54+
}
55+
return true;
56+
}
57+
58+
function makeDriver() {
59+
const reads: Array<{ object: string; via: string }> = [];
60+
const tables: Record<string, Row[]> = { lib_loan: LOANS, lib_book: BOOKS, lib_token: [{ id: 't1', digest: 'abc' }] };
61+
const read = (object: string, ast: any) => {
62+
const rows = (tables[object] ?? []).filter((r) => matches(r, ast?.where));
63+
return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows;
64+
};
65+
const driver: any = {
66+
name: 'mem', version: '0.0.0', supports: {},
67+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
68+
async find(object: string, ast: any) { reads.push({ object, via: 'find' }); return read(object, ast).map((r) => ({ ...r })); },
69+
async findOne(object: string, ast: any) { reads.push({ object, via: 'findOne' }); return read(object, ast)[0] ?? null; },
70+
async count(object: string, ast: any) { reads.push({ object, via: 'count' }); return read(object, ast).length; },
71+
async create(_o: string, d: any) { return d; },
72+
async update(_o: string, _id: string, d: any) { return d; },
73+
async delete() { return true; },
74+
async bulkCreate(_o: string, r: any[]) { return r; },
75+
async bulkUpdate() { return []; }, async bulkDelete() {},
76+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
77+
async commit() {}, async rollback() {},
78+
};
79+
return { driver, reads };
80+
}
81+
82+
const REFUSAL = {
83+
code: 'INVALID_FIELD',
84+
status: 400,
85+
object: 'lib_loan',
86+
param: 'search',
87+
};
88+
89+
describe('[#24284] a search over an empty resolved search set is refused', () => {
90+
let engine: ObjectQL;
91+
let reads: ReturnType<typeof makeDriver>['reads'];
92+
93+
beforeEach(async () => {
94+
const made = makeDriver();
95+
reads = made.reads;
96+
engine = new ObjectQL();
97+
engine.registerDriver(made.driver, true);
98+
await engine.init();
99+
const registry = engine.registry as any;
100+
registry.registerObject({ name: 'lib_book', fields: { title: { type: 'text' } } }, 'test');
101+
registry.registerObject({
102+
name: 'lib_loan',
103+
nameField: 'title',
104+
fields: {
105+
book: { type: 'lookup', reference: 'lib_book' },
106+
reader: { type: 'lookup', reference: 'lib_reader' },
107+
title: { type: 'formula', expression: "record.book.title + ' · ' + record.reader.name" },
108+
},
109+
}, 'test');
110+
// A textual column that is `internal: true` — excluded from the scan,
111+
// so the resolved set is empty for the same reason by another road.
112+
registry.registerObject({
113+
name: 'lib_token',
114+
fields: { digest: { type: 'text', internal: true } },
115+
}, 'test');
116+
});
117+
118+
const verbs: Array<[string, (e: ObjectQL, search: Search) => Promise<unknown>]> = [
119+
['find', (e, search) => e.find('lib_loan', { search })],
120+
['findOne', (e, search) => e.findOne('lib_loan', { search })],
121+
['count', (e, search) => e.count('lib_loan', { search })],
122+
['aggregate', (e, search) => e.aggregate('lib_loan', {
123+
search, groupBy: ['book'], aggregations: [{ function: 'count', alias: 'n' }],
124+
})],
125+
];
126+
127+
it.each(verbs)('%s answers 400 INVALID_FIELD naming the object and both remedies — and reads nothing', async (_verb, run) => {
128+
for (const term of ['zzzz-nothing', 'Dune', { query: 'Dune' }] as Search[]) {
129+
const err: any = await run(engine, term).then(() => null, (e) => e);
130+
expect(err, `term ${JSON.stringify(term)} was answered`).not.toBeNull();
131+
expect(err).toMatchObject(REFUSAL);
132+
expect(String(err.message)).toMatch(/^Object 'lib_loan' has no searchable field, so 'search' cannot be honoured on it/);
133+
expect(String(err.message)).toContain("it declares no 'searchableFields'");
134+
expect(String(err.message)).toContain("Declare 'searchableFields' on 'lib_loan' naming a stored field");
135+
expect(String(err.message)).toContain('mirror the composed title onto a stored text field');
136+
}
137+
expect(reads.filter((r) => r.object === 'lib_loan')).toEqual([]);
138+
});
139+
140+
it('an explicit searchFields narrowing does not rescue it — the allowed set is empty, so the fallback is too', async () => {
141+
for (const searchFields of [['title'], ['book'], ['nope'], []]) {
142+
const err: any = await engine.find('lib_loan', { search: 'Dune', searchFields }).then(() => null, (e) => e);
143+
expect(err, `searchFields ${JSON.stringify(searchFields)}`).toMatchObject(REFUSAL);
144+
}
145+
});
146+
147+
it('a declaration naming only unscannable entries names them in the refusal', async () => {
148+
(engine.registry as any).registerObject({
149+
name: 'lib_loan',
150+
searchableFields: ['title', 'gone'],
151+
fields: {
152+
book: { type: 'lookup', reference: 'lib_book' },
153+
title: { type: 'formula', expression: 'record.book.title' },
154+
},
155+
}, 'test');
156+
const err: any = await engine.find('lib_loan', { search: 'Dune' }).then(() => null, (e) => e);
157+
expect(err).toMatchObject(REFUSAL);
158+
expect(String(err.message)).toContain("its 'searchableFields' (title, gone) names no stored field");
159+
});
160+
161+
it('an object whose only text column is internal: true is refused the same way, saying why', async () => {
162+
const err: any = await engine.find('lib_token', { search: 'abc' }).then(() => null, (e) => e);
163+
expect(err).toMatchObject({ ...REFUSAL, object: 'lib_token' });
164+
expect(String(err.message)).toContain("('internal: true' fields are never searched)");
165+
expect(reads.filter((r) => r.object === 'lib_token')).toEqual([]);
166+
});
167+
168+
it('a blank or whitespace term is not a search: every row, where untouched, on every verb', async () => {
169+
for (const search of ['', ' ', { query: ' ' }] as Search[]) {
170+
expect(await engine.find('lib_loan', { search })).toHaveLength(5);
171+
expect(await engine.count('lib_loan', { search })).toBe(5);
172+
expect(await engine.find('lib_loan', { search, where: { book: 'b1' } })).toHaveLength(2);
173+
}
174+
});
175+
176+
it('control: an object with a stored text field still filters, and an override intersected to nothing falls back to its set', async () => {
177+
expect((await engine.find('lib_book', { search: 'Dune' })).map((r) => r.id)).toEqual(['b1']);
178+
expect(await engine.find('lib_book', { search: 'zzzz-nothing' })).toEqual([]);
179+
expect(await engine.count('lib_book', { search: 'zzzz-nothing' })).toBe(0);
180+
// A direct engine caller naming only fields outside the allowed set: the
181+
// tolerant intersection falls back to the allowed set — a FILTERED
182+
// answer, never every row.
183+
expect((await engine.find('lib_book', { search: 'emma', searchFields: ['nope'] })).map((r) => r.id)).toEqual(['b2']);
184+
});
185+
});
186+
187+
/**
188+
* The internal producers and the data door, over a real engine: `searchAll`
189+
* (the ⌘K palette) hands `search` to objects it did not choose, so it must
190+
* keep skipping an object with nothing to scan BEFORE it queries — a refusal
191+
* there would fail the whole sweep. `findData` (the REST list / query door)
192+
* surfaces the refusal as the read's own answer.
193+
*/
194+
describe('[#24284] the palette keeps its skip; the data door surfaces the refusal', () => {
195+
async function makeHarness() {
196+
const made = makeDriver();
197+
const engine = new ObjectQL();
198+
engine.registerDriver(made.driver, true);
199+
await engine.init();
200+
const registry = engine.registry as any;
201+
registry.registerObject({ name: 'lib_book', fields: { title: { type: 'text' } } }, 'test');
202+
registry.registerObject({
203+
name: 'lib_loan',
204+
nameField: 'title',
205+
fields: {
206+
book: { type: 'lookup', reference: 'lib_book' },
207+
reader: { type: 'lookup', reference: 'lib_reader' },
208+
title: { type: 'formula', expression: "record.book.title + ' · ' + record.reader.name" },
209+
},
210+
}, 'test');
211+
registry.registerObject({ name: 'lib_token', fields: { digest: { type: 'text', internal: true } } }, 'test');
212+
const protocol = new ObjectStackProtocolImplementation(engine as never);
213+
return { protocol, reads: made.reads };
214+
}
215+
216+
it('searchAll answers the other objects\' hits, and leaves the junction and the internal-only object out of objectsScanned', async () => {
217+
const { protocol, reads } = await makeHarness();
218+
const objects = ['lib_book', 'lib_loan', 'lib_token'];
219+
const res: any = await protocol.searchAll({ q: 'Dune', objects, perObject: 5, limit: 25 });
220+
expect(res.hits.map((h: any) => [h.object, h.id])).toEqual([['lib_book', 'b1']]);
221+
expect(res.totalObjects).toBe(1);
222+
expect(reads.filter((r) => r.object !== 'lib_book')).toEqual([]);
223+
const none: any = await protocol.searchAll({ q: 'Dune', objects: ['lib_loan', 'lib_token'], perObject: 5, limit: 25 });
224+
expect(none).toMatchObject({ hits: [], totalObjects: 0, totalHits: 0 });
225+
});
226+
227+
it('findData over the junction answers the refusal, on the list and the query shapes', async () => {
228+
const { protocol, reads } = await makeHarness();
229+
for (const query of [{ search: 'zzzz-nothing' }, { search: 'Dune', searchFields: '' }, { search: { query: 'Dune' } }]) {
230+
const err: any = await (protocol as any).findData({ object: 'lib_loan', query }).then(() => null, (e: any) => e);
231+
expect(err, JSON.stringify(query)).toMatchObject(REFUSAL);
232+
}
233+
expect(reads.filter((r) => r.object === 'lib_loan')).toEqual([]);
234+
// …and the control object and the blank term are untouched.
235+
const book: any = await (protocol as any).findData({ object: 'lib_book', query: { search: 'Dune' } });
236+
expect(book.records.map((r: any) => r.id)).toEqual(['b1']);
237+
const blank: any = await (protocol as any).findData({ object: 'lib_loan', query: { search: '' } });
238+
expect(blank.records).toHaveLength(5);
239+
});
240+
});

0 commit comments

Comments
 (0)