From 76fde58e651c2fd8897fba95e8bb38c24aee39f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 19:31:06 +0000 Subject: [PATCH 1/2] docs(spec): a declared never-string-capable search column is admitted but contributes no clause The declared-branch comment in search-fields.ts and its test comment still called a declared json column "a scan that CAN match". Since the search expansion skips a column whose declared type can never hold a string (the text-operator door's door-refusal verdict), such a column is admitted to the set but contributes no clause, and a set made only of such columns answers no rows. Comment-only; no behaviour change. The docblock ships in the spec package's .d.ts, so a patch changeset rides. Claude-Session: https://claude.ai/code/session_01JQwxbWkmiSMGh5SFWcG7XY Co-authored-by: Claude --- ...eclared-search-column-no-clause-comment.md | 17 ++++++++++++++ packages/spec/src/data/search-fields.test.ts | 18 +++++++++------ packages/spec/src/data/search-fields.ts | 22 ++++++++++++++----- 3 files changed, 45 insertions(+), 12 deletions(-) create mode 100644 .changeset/29546-declared-search-column-no-clause-comment.md diff --git a/.changeset/29546-declared-search-column-no-clause-comment.md b/.changeset/29546-declared-search-column-no-clause-comment.md new file mode 100644 index 00000000000..9bb701039fd --- /dev/null +++ b/.changeset/29546-declared-search-column-no-clause-comment.md @@ -0,0 +1,17 @@ +--- +'@objectstack/spec': patch +--- + +docs(spec): the `resolveSearchFieldResolution` docblock says a declared search column whose type can never hold a string is admitted to the set but contributes no clause + +Clause-②: no +Metadata-protocol: none + +The declared-branch text on `resolveSearchFieldResolution` (`@objectstack/spec`, shipped in its `.d.ts`) still said a `json` column declared in `searchableFields` is "a scan that CAN match". The `$search` expansion in `@objectstack/objectql` gives such a column no clause: its declared type can never hold a string, which is the text-operator door's `door-refusal` verdict (`textOperatorDoorVerdict`). The docblock now says so: + +- a declared `json` column (or any numeric, boolean, date or time, or structured-JSON column, or a `formula` whose `returnType` names one) is still admitted to the set, but contributes no clause; +- a set made only of such columns answers no rows, never an error; +- a declared single-valued `lookup` column still matches by substring over the stored foreign key; +- only an entry that names no column at all is dropped, as before. + +Comment-only: no behaviour, schema or accepted value moves. diff --git a/packages/spec/src/data/search-fields.test.ts b/packages/spec/src/data/search-fields.test.ts index ffd2c22cf12..1540553967e 100644 --- a/packages/spec/src/data/search-fields.test.ts +++ b/packages/spec/src/data/search-fields.test.ts @@ -309,13 +309,17 @@ describe('a virtual field declared in searchableFields — not admitted, since n it('CONTROL — the declared branch still bypasses the auto-default exclusions', () => { // The dividing line is STORAGE, not search quality. `lookup` and `json` are - // auto-EXCLUDED types, and declaring them is still the author's choice: the - // engine runs a `$contains` over the stored foreign key / the stored JSON - // text. Narrow and rarely useful, but a scan that CAN match — so it must not - // be swept up by the virtual filter. This control is what keeps #6674 from - // silently becoming "the declared branch is type-filtered after all", which - // would reject metadata the runtime accepts (ADR-0072 D1, and the #4830 - // changeset's explicit carve-out). + // auto-EXCLUDED types, and declaring them is still the author's choice, so + // both are ADMITTED here. What each then contributes is the expansion's call + // (`expandSearchToFilter`, `@objectstack/objectql`): `lookup` becomes an + // `$icontains` over the stored foreign key, a narrow scan that can match; + // [#29394] `json` can never hold a string (the text-operator door's + // `door-refusal` verdict), so it contributes NO clause, and a set made only + // of such columns answers no rows. Neither is swept up by the virtual + // filter, which drops only an entry with no column at all. This control is + // what keeps #6674 from silently becoming "the declared branch is + // type-filtered after all", which would reject metadata the runtime accepts + // (ADR-0072 D1, and the #4830 changeset's explicit carve-out). expect( resolveSearchFieldResolution({ fields, diff --git a/packages/spec/src/data/search-fields.ts b/packages/spec/src/data/search-fields.ts index d45c69a63f3..6fdd828e67c 100644 --- a/packages/spec/src/data/search-fields.ts +++ b/packages/spec/src/data/search-fields.ts @@ -283,11 +283,23 @@ function autoDefaultFields(fields: Record, displayField * * Filter 2 is deliberately NOT a type allow-list on the declared branch. The * declaration remains the author's explicit choice and still bypasses the - * auto-default's exclusions: a `json` or `lookup` column declared here is - * executed by the engine as a `$contains` over the stored JSON text / stored - * foreign key — narrow, rarely useful, but a scan that CAN match. Only "there - * is no column at all" is refused, which is why the vocabulary is a storage - * fact rather than a search-quality one. + * auto-default's exclusions, so a `lookup` or `json` column declared here is + * ADMITTED to the set. What an admitted column then contributes to a search is + * the expansion's call (`expandSearchToFilter`, `@objectstack/objectql`), not + * this function's: + * + * - a single-valued `lookup` column becomes an `$icontains` over the stored + * foreign key — narrow, rarely useful, but a scan that can match; + * - [#29394] a column whose DECLARED type can never hold a string — the + * text-operator door's `door-refusal` verdict (`textOperatorDoorVerdict`, + * `./filter-text-operator-declared-type`: numeric, boolean, calendar-date, + * instant, clock-time and structured-JSON types such as `json`, plus a + * `formula` whose readable `returnType` names one) — contributes NO clause. + * A set made only of such columns answers no rows: never an error, never + * every row. + * + * Only "there is no column at all" is refused, which is why the vocabulary is + * a storage fact rather than a search-quality one. * * Why the drop is not the whole fix. Dropping alone would only make the * declaration silently NARROWER — the failure mode #4254 exists to close on From 691a1581b7591b4aa0dc40eb6aebd8cafa556630 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:14:48 +0000 Subject: [PATCH 2/2] docs(spec): apply contract-review should-fixes to the search-fields comments - Drop the formula mention from the no-clause bullet: Filter 2 drops every formula before admission, so no formula reaches the expansion from the declared branch (same fix in the changeset bullet). - Say "substring scan" for the generic scan on the docblocks, and correct the SEARCH_VIRTUAL_TYPES docblock, which still said a declared json column is a scan that can match. - Test CONTROL comment: "single-valued" lookup, as in the docblock. Comment-only; no behaviour change. Claude-Session: https://claude.ai/code/session_01JQwxbWkmiSMGh5SFWcG7XY Co-authored-by: Claude --- ...eclared-search-column-no-clause-comment.md | 2 +- packages/spec/src/data/search-fields.test.ts | 5 +++-- packages/spec/src/data/search-fields.ts | 19 +++++++++---------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.changeset/29546-declared-search-column-no-clause-comment.md b/.changeset/29546-declared-search-column-no-clause-comment.md index 9bb701039fd..03ecc8eafff 100644 --- a/.changeset/29546-declared-search-column-no-clause-comment.md +++ b/.changeset/29546-declared-search-column-no-clause-comment.md @@ -9,7 +9,7 @@ Metadata-protocol: none The declared-branch text on `resolveSearchFieldResolution` (`@objectstack/spec`, shipped in its `.d.ts`) still said a `json` column declared in `searchableFields` is "a scan that CAN match". The `$search` expansion in `@objectstack/objectql` gives such a column no clause: its declared type can never hold a string, which is the text-operator door's `door-refusal` verdict (`textOperatorDoorVerdict`). The docblock now says so: -- a declared `json` column (or any numeric, boolean, date or time, or structured-JSON column, or a `formula` whose `returnType` names one) is still admitted to the set, but contributes no clause; +- a declared `json` column (or any numeric, boolean, date or time, or structured-JSON column) is still admitted to the set, but contributes no clause; - a set made only of such columns answers no rows, never an error; - a declared single-valued `lookup` column still matches by substring over the stored foreign key; - only an entry that names no column at all is dropped, as before. diff --git a/packages/spec/src/data/search-fields.test.ts b/packages/spec/src/data/search-fields.test.ts index 1540553967e..874df33a8b7 100644 --- a/packages/spec/src/data/search-fields.test.ts +++ b/packages/spec/src/data/search-fields.test.ts @@ -311,8 +311,9 @@ describe('a virtual field declared in searchableFields — not admitted, since n // The dividing line is STORAGE, not search quality. `lookup` and `json` are // auto-EXCLUDED types, and declaring them is still the author's choice, so // both are ADMITTED here. What each then contributes is the expansion's call - // (`expandSearchToFilter`, `@objectstack/objectql`): `lookup` becomes an - // `$icontains` over the stored foreign key, a narrow scan that can match; + // (`expandSearchToFilter`, `@objectstack/objectql`): a single-valued + // `lookup` becomes an `$icontains` over the stored foreign key, a narrow scan + // that can match; // [#29394] `json` can never hold a string (the text-operator door's // `door-refusal` verdict), so it contributes NO clause, and a set made only // of such columns answers no rows. Neither is swept up by the virtual diff --git a/packages/spec/src/data/search-fields.ts b/packages/spec/src/data/search-fields.ts index 6fdd828e67c..cf5226ff43a 100644 --- a/packages/spec/src/data/search-fields.ts +++ b/packages/spec/src/data/search-fields.ts @@ -89,15 +89,15 @@ export const SEARCH_AUTO_EXCLUDED_TYPES: ReadonlySet = new Set([ ]); /** * [#6674] Field types with NO STORED COLUMN — the value is computed on read, so - * a `$contains` scan has nothing to look at. Exactly `formula` today. + * a substring scan has nothing to look at. Exactly `formula` today. * * This is a STORAGE fact, not a taste judgment, and that is what separates it * from the three vocabularies above. `SEARCH_AUTO_EXCLUDED_TYPES` says "the * auto-default does not GUESS this type" — an author may still declare a `json` - * or `lookup` column in `searchableFields` and the engine executes it (a - * `$contains` over the stored JSON text, over the stored foreign key), which is - * a choice that can match. A `formula` entry cannot: the drivers materialize no - * column for it (`driver-sql/src/schema-drift.ts` `fieldHasColumn`, + * or `lookup` column in `searchableFields` and it is admitted, because a stored + * column exists (what each then contributes to a search is set out on + * {@link resolveSearchFieldResolution}). A `formula` entry is not: the drivers + * materialize no column for it (`driver-sql/src/schema-drift.ts` `fieldHasColumn`, * `driver-turso/src/remote-transport.ts` "Virtual — no column"), and the engine * excludes it from the projection it sends down (`objectql/src/engine.ts` * `buildFormulaPlan`) because the driver would fail on the column name. @@ -279,7 +279,7 @@ function autoDefaultFields(fields: Record, displayField * 2. [#6674] SCANNABILITY — an entry naming a VIRTUAL field * ({@link isVirtualSearchField}) is dropped. The entry names a real field, * passes existence, and can still never match, because the field has no - * stored column for `$contains` to scan. + * stored column for a substring scan to read. * * Filter 2 is deliberately NOT a type allow-list on the declared branch. The * declaration remains the author's explicit choice and still bypasses the @@ -293,10 +293,9 @@ function autoDefaultFields(fields: Record, displayField * - [#29394] a column whose DECLARED type can never hold a string — the * text-operator door's `door-refusal` verdict (`textOperatorDoorVerdict`, * `./filter-text-operator-declared-type`: numeric, boolean, calendar-date, - * instant, clock-time and structured-JSON types such as `json`, plus a - * `formula` whose readable `returnType` names one) — contributes NO clause. - * A set made only of such columns answers no rows: never an error, never - * every row. + * instant, clock-time and structured-JSON types such as `json`) — + * contributes NO clause. A set made only of such columns answers no rows: + * never an error, never every row. * * Only "there is no column at all" is refused, which is why the vocabulary is * a storage fact rather than a search-quality one.