Skip to content

Commit fdb9a07

Browse files
docs(spec): a declared never-string-capable search column is admitted but contributes no clause (#29584)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8Ffn7zen4zj6cFnXv7bG1yJ)_ Fixes #29546 Clause-②: no Metadata-protocol: none Dispatched by `domain:spec#3 · os-warren · session_01JQwxbahe4v7GBD94QeSnir`; dev = card thread `session_01JQwxbWkmiSMGh5SFWcG7XY`. ## 维护者速读 - 改了什么:只改注释。`resolveSearchFieldResolution` 的 declared 分支说明和对应测试注释,改为「从不存字符串的列(如 `json`)仍可声明、进入搜索集,但搜索展开不给它出子句」。 - 为什么改:#29502 之后 `$search` 展开跳过这类列,旧注释仍说 json 列「a scan that CAN match」,作者和 AI 会以为把 json 字段放进 `searchableFields` 能搜到东西。 - 风险与回滚:零行为改动,无 schema、无接受值变化;回滚即 revert 本 PR。 - 先看:`packages/spec/src/data/search-fields.ts`(declared 分支两条列表项)、`packages/spec/src/data/search-fields.test.ts`(CONTROL 用例注释)、`.changeset/29546-declared-search-column-no-clause-comment.md`。 - 两分钟验证:`git diff origin/main -- packages/spec/src` 只见注释行变化;`pnpm --filter @objectstack/spec exec vitest run src/data/search-fields.test.ts` 应 50/50 通过。 - 需要你做的:无,席位落地。 ## What changes - `packages/spec/src/data/search-fields.ts`, the declared-branch docblock on `resolveSearchFieldResolution`: a declared `lookup` or `json` column is ADMITTED to the set, and what it contributes is the expansion's call (`expandSearchToFilter`, `@objectstack/objectql`). A single-valued `lookup` becomes an `$icontains` over the stored foreign key, a scan that can match. A column whose declared type can never hold a string (the text-operator door's `door-refusal` verdict, `textOperatorDoorVerdict`) contributes NO clause, and a set made only of such columns answers no rows, never an error. Only "there is no column at all" is refused, as before. - `packages/spec/src/data/search-fields.test.ts`, the CONTROL case comment: the same reading. The assertion is unchanged. - `.changeset/29546-declared-search-column-no-clause-comment.md`: `@objectstack/spec` patch. The docblock sits on an exported function and ships in `dist/data/index.d.ts` (measured below), so the published text changes. ## Why The declared-branch text is the written contract authors and AI read to decide what to put in `searchableFields`. Since #29502 (#29394) the expansion skips never-string-capable columns, so "a scan that CAN match" for `json` was false. `lookup` is in `REFERENCE_VALUE_TYPES` (verdict `passes`), so it still matches and keeps that wording. ## Tests - `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/data/search-fields.test.ts` at `76fde58e6`: Test Files 1 passed (1), Tests 50 passed (50). - `pnpm --filter @objectstack/spec typecheck` under the verify lock: `VERDICT command-exit 0`. - No ablation: no tested behaviour moved. ## Acceptance notes - Premise measured on `origin/main` `ff827bcf`: `packages/objectql/src/search-filter.ts` `fieldClausesForTerm` returns no clause when `neverHoldsText(meta)`, which is `textOperatorDoorVerdict(...) === 'door-refusal'`; `json` sits in `STRUCTURED_JSON_TYPES` (verdict `door-refusal`). A single-valued `lookup` falls to the default `$icontains` clause, so the old text also said `$contains` where the expansion emits `$icontains`; the new text says `$icontains`. - Changeset decision: the card said "no changeset unless the gate asks". The os-dev standard (published = what `files[]` ships) wins on conflict: after `pnpm --filter @objectstack/spec build`, `grep -c "contributes NO clause" packages/spec/dist/data/index.d.ts` = 1 and the old phrase = 0 (positive control: `SCANNABILITY` hits the same file on the base build). So a patch changeset rides. - Not changed here, same stale sentence: `packages/lint/src/validate-searchable-fields.ts` docblock (about L95 to L100) still says a declared json column is "a scan that CAN match". Outside this card's file surface; named in the report for the seat. - `packages/lint/CHANGELOG.md` and `packages/metadata-protocol/CHANGELOG.md` carry the old phrase in released entries; release-owned, untouched. ## Verification Gate union at head `76fde58e6`: <details><summary>dispatch-gates --commands / --ran</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 79 derived families, run from a runner writing one exit line each. - 77 exited 0 on the first pass. `node scripts/check-plugin-teardown-shape.mjs --self-test` exited 3 (its positive-control commit was outside the shallow clone); after `git fetch origin 621a487` it re-ran with exit 0 (48 cases pass). - `pnpm check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET (needs every package built). NOT MEASURED locally, left to CI. - `node scripts/pm/dispatch-gates.mjs --ran FILE --repo objectstack-ai/objectstack`: exit 0, "79 derived famil(ies) accounted for, 78 run, 1 NOT-MEASURED". - Prerequisite builds under the lock: `turbo run build` for objectql, lint, formula, client, client-react (38 tasks), then spec. </details> ## 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. Repo-wide `pnpm lint` is CI's run, not run locally.) - [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. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [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. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbWkmiSMGh5SFWcG7XY --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbWkmiSMGh5SFWcG7XY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2da42e7 commit fdb9a07

3 files changed

Lines changed: 51 additions & 18 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
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
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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:
11+
12+
- 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;
13+
- a set made only of such columns answers no rows, never an error;
14+
- a declared single-valued `lookup` column still matches by substring over the stored foreign key;
15+
- only an entry that names no column at all is dropped, as before.
16+
17+
Comment-only: no behaviour, schema or accepted value moves.

‎packages/spec/src/data/search-fields.test.ts‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -309,13 +309,18 @@ describe('a virtual field declared in searchableFields — not admitted, since n
309309

310310
it('CONTROL — the declared branch still bypasses the auto-default exclusions', () => {
311311
// The dividing line is STORAGE, not search quality. `lookup` and `json` are
312-
// auto-EXCLUDED types, and declaring them is still the author's choice: the
313-
// engine runs a `$contains` over the stored foreign key / the stored JSON
314-
// text. Narrow and rarely useful, but a scan that CAN match — so it must not
315-
// be swept up by the virtual filter. This control is what keeps #6674 from
316-
// silently becoming "the declared branch is type-filtered after all", which
317-
// would reject metadata the runtime accepts (ADR-0072 D1, and the #4830
318-
// changeset's explicit carve-out).
312+
// auto-EXCLUDED types, and declaring them is still the author's choice, so
313+
// both are ADMITTED here. What each then contributes is the expansion's call
314+
// (`expandSearchToFilter`, `@objectstack/objectql`): a single-valued
315+
// `lookup` becomes an `$icontains` over the stored foreign key, a narrow scan
316+
// that can match;
317+
// [#29394] `json` can never hold a string (the text-operator door's
318+
// `door-refusal` verdict), so it contributes NO clause, and a set made only
319+
// of such columns answers no rows. Neither is swept up by the virtual
320+
// filter, which drops only an entry with no column at all. This control is
321+
// what keeps #6674 from silently becoming "the declared branch is
322+
// type-filtered after all", which would reject metadata the runtime accepts
323+
// (ADR-0072 D1, and the #4830 changeset's explicit carve-out).
319324
expect(
320325
resolveSearchFieldResolution({
321326
fields,

‎packages/spec/src/data/search-fields.ts‎

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -89,15 +89,15 @@ export const SEARCH_AUTO_EXCLUDED_TYPES: ReadonlySet<string> = new Set([
8989
]);
9090
/**
9191
* [#6674] Field types with NO STORED COLUMN — the value is computed on read, so
92-
* a `$contains` scan has nothing to look at. Exactly `formula` today.
92+
* a substring scan has nothing to look at. Exactly `formula` today.
9393
*
9494
* This is a STORAGE fact, not a taste judgment, and that is what separates it
9595
* from the three vocabularies above. `SEARCH_AUTO_EXCLUDED_TYPES` says "the
9696
* auto-default does not GUESS this type" — an author may still declare a `json`
97-
* or `lookup` column in `searchableFields` and the engine executes it (a
98-
* `$contains` over the stored JSON text, over the stored foreign key), which is
99-
* a choice that can match. A `formula` entry cannot: the drivers materialize no
100-
* column for it (`driver-sql/src/schema-drift.ts` `fieldHasColumn`,
97+
* or `lookup` column in `searchableFields` and it is admitted, because a stored
98+
* column exists (what each then contributes to a search is set out on
99+
* {@link resolveSearchFieldResolution}). A `formula` entry is not: the drivers
100+
* materialize no column for it (`driver-sql/src/schema-drift.ts` `fieldHasColumn`,
101101
* `driver-turso/src/remote-transport.ts` "Virtual — no column"), and the engine
102102
* excludes it from the projection it sends down (`objectql/src/engine.ts`
103103
* `buildFormulaPlan`) because the driver would fail on the column name.
@@ -279,15 +279,26 @@ function autoDefaultFields(fields: Record<string, SearchFieldMeta>, displayField
279279
* 2. [#6674] SCANNABILITY — an entry naming a VIRTUAL field
280280
* ({@link isVirtualSearchField}) is dropped. The entry names a real field,
281281
* passes existence, and can still never match, because the field has no
282-
* stored column for `$contains` to scan.
282+
* stored column for a substring scan to read.
283283
*
284284
* Filter 2 is deliberately NOT a type allow-list on the declared branch. The
285285
* declaration remains the author's explicit choice and still bypasses the
286-
* auto-default's exclusions: a `json` or `lookup` column declared here is
287-
* executed by the engine as a `$contains` over the stored JSON text / stored
288-
* foreign key — narrow, rarely useful, but a scan that CAN match. Only "there
289-
* is no column at all" is refused, which is why the vocabulary is a storage
290-
* fact rather than a search-quality one.
286+
* auto-default's exclusions, so a `lookup` or `json` column declared here is
287+
* ADMITTED to the set. What an admitted column then contributes to a search is
288+
* the expansion's call (`expandSearchToFilter`, `@objectstack/objectql`), not
289+
* this function's:
290+
*
291+
* - a single-valued `lookup` column becomes an `$icontains` over the stored
292+
* foreign key — narrow, rarely useful, but a scan that can match;
293+
* - [#29394] a column whose DECLARED type can never hold a string — the
294+
* text-operator door's `door-refusal` verdict (`textOperatorDoorVerdict`,
295+
* `./filter-text-operator-declared-type`: numeric, boolean, calendar-date,
296+
* instant, clock-time and structured-JSON types such as `json`) —
297+
* contributes NO clause. A set made only of such columns answers no rows:
298+
* never an error, never every row.
299+
*
300+
* Only "there is no column at all" is refused, which is why the vocabulary is
301+
* a storage fact rather than a search-quality one.
291302
*
292303
* Why the drop is not the whole fix. Dropping alone would only make the
293304
* declaration silently NARROWER — the failure mode #4254 exists to close on

0 commit comments

Comments
 (0)