Repository navigation
Commit 74aae1e
fix(spec)!: the published searchFields declarations refuse a value naming no field, as the Data API doors do (#27725)
domain:spec#3 · os-support-ai · session_01WGufzgbJhFoXYsLSe5WSPP
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} -->
_Requested by **Yibo Wang** · [project
thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBCNbr1niqMMAugbuJbWkmUX)_
Fixes #27683
Clause-②: no (narrowing) — the published declarations of the
search-field override (`QueryTransportParamsSchema.$searchFields`,
`QuerySchema.searchFields`, `FullTextSearchSchema.fields`, reached
through `QueryWithTransportSchema` and `FindDataRequestSchema.query`)
refuse a value naming no field, which they admitted before. The doors
already refused it with a 400 (#27484 / PR #27670); nothing is widened
and no key is added, removed or renamed.
Metadata-protocol: none
Skills-docs: none — every place under `skills/**` that teaches the
narrowed members still holds (`git grep -n
"searchFields\|search\.fields" -- skills` at this head):
`skills/objectstack-query/SKILL.md` § Full-Text Search teaches a
non-empty `searchFields` and "Omit it to search the object's declared
`searchableFields`", its key tables (lines 25-26) list the engine
option, which this PR does not narrow, and its eval and
`skills/objectstack-data/SKILL.md:125` emit only named overrides. No
skill emits or recommends an empty override.
`Metadata-protocol: none` was measured, not assumed: no metadata type
embeds `QuerySchema`, `FullTextSearchSchema` or the transport bag (`git
grep` of every `*.zod.ts` under
`packages/spec/src/{ui,automation,ai,system,kernel,security}` for the
three schema names and for `searchFields`: only prose comments in
`ui/component.zod.ts`). The readers are request bodies, the driver and
API contracts, and codegen.
PR #27670 (#27484) merged at `86f2afeb` before this branch started, so
there is no landing order to name; its predicate was measured on `main`.
## 维护者速读
- 改了什么:已发布的三处 `searchFields`
声明(`QueryTransportParamsSchema.$searchFields`、`QuerySchema.searchFields`、`FullTextSearchSchema.fields`)不再接受“一个字段都没写”的值(空串、只有逗号和空格、空数组、对象形
`search.fields: []`),三处描述带同一句接受集说明;`POST /data/:object/query` 因此在请求体
schema 处拒收,回 400 `VALIDATION_FAILED`(原为门内的 `VALIDATION_ERROR`),与
`select: []` 一致。
- 为什么改:数据接口自 #27484 起已拒收这些值,但发布的 Zod 与 JSON Schema 仍声明接受,按 schema
生成的客户端和 AI 会写出被门拒收的请求;`select` 的同类声明早已收紧(#26909)。
- 风险与回滚:只收窄声明,接受集与门完全一致(有逐值对照测试);`POST /query` 的错误码从 `VALIDATION_ERROR`
变 `VALIDATION_FAILED`,仍是 400、仍不读库。引擎侧声明不动(下一阶段卡)。回滚即还原本 PR。
-
先看:`packages/spec/src/data/search-fields-names-a-field.ts`(接受集与两条臂),`packages/rest/src/data-empty-search-fields-door.test.ts`(POST
行迁到 schema 拒收表、末尾 parity
用例),`packages/spec/src/data/query.zod.ts`(两处成员)。
- 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run
src/data/search-fields-names-a-field.test.ts`,应见 39 个用例全绿。
- 需要你做的:无,席位落地(进队前欠一次契约复核)。
## What changes
### Measured first, on `origin/main` `bfdaba43` (#27670 merged)
Each declaration parsed with the four spellings of "names no field",
next to what the protocol door (`assertSearchFieldsAreSearchable`, the
gate `findData` calls for the standalone slot and for the object-form
`search.fields`) answers for the same value. The door column is the
registry-less host, so it is the shape rule alone; a registered object
answers the same for these values, because the empty check runs before
the field-map lookup.
| value | `$searchFields` | `QuerySchema.searchFields` |
`FullTextSearchSchema.fields` | `FindDataRequestSchema`
`query.$searchFields` / `query.searchFields` / `query.search.fields` |
`EngineQueryOptionsSchema` / `EngineAggregateOptionsSchema` /
`EngineCountOptionsSchema` `.searchFields` | protocol door (standalone /
`search.fields`) |
|:--|:--|:--|:--|:--|:--|:--|
| `''` | accept | string refused (array-only) | n/a | accept / string
refused / n/a | n/a (array-only) | 400 `VALIDATION_ERROR` / 400
`VALIDATION_ERROR` |
| `' , '` | accept | string refused | n/a | accept / string refused /
n/a | n/a | 400 `VALIDATION_ERROR` / 400 `VALIDATION_ERROR` |
| `[]` | accept | accept | accept | accept / accept / accept | accept /
accept / accept | 400 `VALIDATION_ERROR` / 400 `VALIDATION_ERROR` |
| `search: { fields: [] }` | (see `FullTextSearchSchema.fields`) | |
accept | accept | accept (via the shared `FullTextSearchSchema`) | 400
`VALIDATION_ERROR` |
| `['']` (control) | accept | accept | accept | accept | accept |
accepted as a value; refused as an unknown field once the object is
known |
| `'name'` / `['name']` (control) | accept | accept (array) | accept |
accept | accept | accepted |
The door's predicate, read at
`packages/metadata-protocol/src/protocol-query-validation.ts` on `main`:
a string is `split(',')`, trimmed and emptied of blank members; an array
is kept as sent; the override is refused as naming no field iff that
leaves zero names. That is the set used here, exactly.
### After, at this PR's head
| value | `$searchFields` | `QuerySchema.searchFields` |
`FullTextSearchSchema.fields` | `FindDataRequestSchema` (all three
slots) | engine-side declarations | protocol door |
|:--|:--|:--|:--|:--|:--|:--|
| `''`, `' '`, `','`, `' , '` | **refused** (`invalid_format`, "Name at
least one field, or leave the key out to search the default search
fields") | string refused, as before | n/a | **refused** at
`query.$searchFields` | unchanged | unchanged (400 `VALIDATION_ERROR`) |
| `[]` | **refused** (`too_small`, same sentence) | **refused** |
**refused** | **refused** at `query.searchFields` /
`query.$searchFields` / `query.search.fields` | `searchFields` unchanged
(still admits `[]`); `search.fields: []` refused through the shared
`FullTextSearchSchema` | unchanged |
| `['']`, `'name'`, `' name , email '`, `['name']` | accept | accept |
accept | accept | accept | unchanged |
### The declarations (`packages/spec/src/data/`)
- New `search-fields-names-a-field.ts`, outside the `data` barrel (the
`projection-names-a-field.ts` pattern): the refusal sentence
`SEARCH_FIELDS_NAMES_NO_FIELD`, the shared accept-set sentence
`SEARCH_FIELDS_ACCEPT_SET` ("at least one field name; a value naming no
field (blank, only commas and spaces, or an empty list) is refused"),
the string arm (`regex(/[^\s,]/)`: holds iff some comma segment has a
non-whitespace character, `\s` being the set `trim()` strips) and the
array arm (`min(1)`). Its own module because `query.zod.ts` cannot
import `data-engine.zod.ts`. Both are a pattern and a minimum rather
than refinements, so the published JSON Schema states them (`pattern`,
`minItems`), and `dropped-refinements.baseline.json` does not move.
- `QueryTransportParamsSchema.$searchFields` (`data-engine.zod.ts`):
`z.union([searchFieldsString(), searchFieldsArray()])`, still
`.nullable().optional()`, so an explicit `null` is still a withdrawal.
- `QuerySchema.searchFields` and `FullTextSearchSchema.fields`
(`query.zod.ts`): `searchFieldsArray().optional()`.
- Each of the three `.describe()` texts ends with the shared accept-set
sentence; the references
(`content/docs/references/{api/contract,api/protocol,data/data-engine,data/query}.mdx`)
were regenerated with `check:generated --fix`.
- A blank member (`['']`) is deliberately outside the set. Unlike the
projection, whose door refuses a blank member through the same "names no
field" envelope, the search-field door keeps it as a name and then
refuses it as an unknown field when the object is known
(`INVALID_FIELD`). Refusing it at the schema would make the schema
refuse a value the door's "names no field" predicate admits, so the two
would no longer refuse the same set.
### The stage boundary (what the engine card owns)
Measured item 2 of the order: neither
`EngineQueryOptionsSchema.searchFields` (`data-engine.zod.ts` ~:189) nor
the aggregate (~:513) and count (~:558) siblings is a declaration the
door parses. `POST /data/:object/query` parses `FindDataRequestSchema`
(whose `query` is `QueryWithTransportSchema`, built from `QuerySchema`
and `QueryTransportParamsSchema`) at `packages/rest/src/crud-routes.ts`
and in the dispatcher (`packages/runtime/src/domains/data.ts`); `GET
/data/:object` and export reach `findData` with no schema at all. The
three engine-side declarations are the in-process engine's options
(`IDataEngine.find` / `aggregate` / `count`), so they stay, and so does
the engine's tolerance of an empty override: the next stage, in the
#27008 shape.
One coupling the engine card should know about:
`EngineQueryOptionsSchema.search` (and its aggregate and count siblings)
declare their structured form as the shared `FullTextSearchSchema`, so
`search: { query, fields: [] }` is now refused by those engine-side
declarations too. The engine does not parse `search`
(`fillQueryAstDefaults` in `packages/objectql/src/engine.ts` says so and
why), so no runtime answer moves. Likewise
`EngineQueryOptionsSchema.expand` values are `QuerySchema`, so an
`expand` entry's `searchFields: []` is refused by the declaration; the
engine does not parse `searchFields` on an expand entry either. The
`stage boundary` describe in the new spec test pins the three
`searchFields` members still admitting `[]`, so the engine card flips it
on purpose.
### `POST /data/:object/query`
(`packages/rest/src/data-empty-search-fields-door.test.ts`)
Cleared by the seat's claim amendment on #27683 (the file was outside
the dispatched surface). The two POST rows #27670 pinned at the door's
`400 VALIDATION_ERROR` now answer at the body schema, `400
VALIDATION_FAILED`, with `fields[0].field` naming the key:
`query.searchFields`, `query.$searchFields` or `query.search.fields`.
That is how the route already answers `select: []` (#26909). The new
`REFUSED_POST_AT_SCHEMA` table covers `searchFields: []`,
`$searchFields: ""`, `$searchFields: " , "`, `$searchFields: []` and
`search: { query, fields: [] }`, each with "no read of the object". The
GET list and export rows are unchanged: those doors have no body schema
and still answer the door's `VALIDATION_ERROR`.
## Tests
- New `packages/spec/src/data/search-fields-names-a-field.test.ts` (39
cases): per declaration, each empty spelling refused with the sentence
at the member's path (`$searchFields`: four strings and `[]`;
`QuerySchema.searchFields` and `FullTextSearchSchema.fields`: `[]`), and
controls kept unchanged (one name, a spaced comma list, a list, a blank
member); the same refusals through `QueryWithTransportSchema` and
`FindDataRequestSchema.query` for `$searchFields`, `searchFields`,
`search.fields` and `$search.fields`; an explicit `null` still a
withdrawal; a kept comma list lowered onto `searchFields`; each
`.describe()` carrying the shared sentence; and the stage-boundary pin
above.
- `packages/rest/src/data-empty-search-fields-door.test.ts`: the POST
rows move to `REFUSED_POST_AT_SCHEMA` as above, and a new **parity**
case holds the schema door (`FindDataRequestSchema`, which the POST
route parses) and the protocol door (`protocol.findData`, which the GET
and export routes reach with no schema) to one refused set, probe for
probe, over a real SQLite object: 8 strings and 4 lists on
`$searchFields`, the 4 lists on `searchFields` and on `search.fields`
(20 probes). The schema refuses a probe iff the door answers
`VALIDATION_ERROR` "names no field"; the verdict counts are pinned (8
refused, 9 served, 3 `INVALID_FIELD` for the blank member), so neither
side can pass vacuously.
### Ablation
At head `7b3667ba` (fix and tests committed), through
`scripts/ablation-replace.mjs` (trap restore), under the lock, against
`src` (the spec suite imports the schemas by relative path, so no build
or dist step is involved):
- Leg 1, array arm: `z.array(z.string()).min(1,
SEARCH_FIELDS_NAMES_NO_FIELD)` → `z.array(z.string()).min(0,
'ABLATED-27683')`. Landed: anchor 1 → 0, blob `4cb5ab59` → `288d09ac`.
`search-fields-names-a-field.test.ts`: 7 failed / 32 passed (every `[]`
refusal on all three declarations and all four body spellings).
Restored: blob `4cb5ab59` = HEAD, `git diff HEAD` empty.
- Leg 2, string arm: `regex(/[^\s,]/, …)` → `regex(/ABLATED-27683|/, …)`
(matches everything). Landed: anchor 1 → 0, blob `4cb5ab59` →
`e13506da`. 4 failed / 35 passed: the four `$searchFields` string
refusals on `QueryTransportParamsSchema`. The same strings through
`QueryWithTransportSchema` stayed refused, because the fold lowers `''`
/ `' , '` to `searchFields: []` and the array arm on
`QuerySchema.searchFields` catches it: the two arms back each other on
the transport-aware slot. Restored: blob = HEAD, `git diff HEAD` empty.
- A first leg-1 attempt used a replacement (`z.array(z.string())`) that
already occurred inside the anchor, so the tool refused it (replacement
count did not rise) and restored before running anything; it was redone
with the marker above.
- Not ablated at the rest layer (it reads the spec through `dist`, so
each leg would need a rebuild). What stands in for it: the first run of
the new POST case on the narrowed spec was red on one row for the right
reason (the structured form answered `query.search.fields`, not the
`query.search` first written), so the case reads the live schema answer;
and the parity case compares each of its 20 probes against the door's
own answer with pinned verdict counts.
## Acceptance notes
- Two pre-existing schema/door differences outside "names no field",
unchanged and not widened here: the door accepts a comma STRING in the
canonical `searchFields` slot and in the structured `search.fields`
("the engine accepts the comma-string and array forms from either
slot"), while `QuerySchema.searchFields` and
`FullTextSearchSchema.fields` declare arrays only, so `POST
{"searchFields":"label"}` is refused at the body schema as it was on
`main` (measured: `query.searchFields` `invalid_type`). Recorded for the
engine card's reading; not a finding filed from here.
- The `#27484` changeset's "Not yet narrowed" bullet becomes false in
the same release; `check-empty-changeset` refuses an edit to a changeset
the merge base carries, so this PR's changeset carries a "Supersedes, in
the same release" paragraph instead.
- No caller in the repository sends an empty override through these
schemas: the Console and the bundled view plugins send `$searchFields`
only when it holds a field (#27484's measurement), and `git grep` for
`searchFields: []` / `fields: []` beside `search` in `*.test.ts` finds
only the rest door file (updated) and objectql's
`query-expression-conformance.test.ts`, which calls `protocol.findData`
directly and so is unaffected.
## Verification
All at head `4ccc5167` (this PR's head: the two commits plus a merge of
`origin/main` `a75ddba1` through `scripts/pm/os-regen-merge.sh`, which
kept the branch's bytes of every generated reference since `main` had
not moved them), under `scripts/pm/os-verify-lock.sh` on the shared box.
<details><summary>Measurement script (before and after)</summary>
- A throwaway `tsx` probe (not committed) parsed every declaration in
the tables above with `''`, `' , '`, `[]`, `['']`, `'name'`, `['name']`,
and called `assertSearchFieldsAreSearchable` (registry-less host) for
the standalone slot and for `search.fields`. On `bfdaba43` it produced
the "Measured first" table; on this head the diff against that output is
exactly the rows marked **refused** in the "After" table, and the
engine-side `searchFields` rows and the door rows are byte-identical.
</details>
<details><summary>Suites: spec, rest and the full dogfood battery, all
exit 0</summary>
- `pnpm build --concurrency=2` (full turbo build, 114 tasks): `Tasks:
114 successful, 114 total`, lock VERDICT command-exit 0 (17m43s).
- One locked chain, VERDICT command-exit 0 (46m52s):
- `pnpm --filter @objectstack/dogfood test`: exit 0, `Test Files 352
passed | 3 skipped (355)`, `Tests 2844 passed | 20 skipped (2864)`. No
failures.
- `pnpm --filter @objectstack/spec test`: exit 0, `Test Files 754 passed
(754)`, `Tests 22430 passed | 1 todo (22431)`.
- `pnpm --filter @objectstack/spec typecheck`: exit 0.
- `pnpm --filter @objectstack/rest test`: exit 0, `Test Files 340 passed
(340)`, `Tests 6671 passed | 326 skipped (6997)`.
- `pnpm --filter @objectstack/rest typecheck`: exit 0.
- `pnpm --filter @objectstack/spec check:generated` (after `--fix`): all
14 artifacts current; `--fix` regenerated only
`content/docs/references/**` (4 files).
- `pnpm check:adr-0087-registration`: exit 0, `1 declared-breaking
changeset(s), each carrying an ADR-0087 disposition` (`not-required
(no-migration-prescription)`).
- `spec test` does not run the `test:repo` project; CI's `Test Core`
does.
</details>
<details><summary>Derived gates: 104 derived, 104 run, all exit
0</summary>
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `4ccc5167`: 104 families (the same list
as at `7b3667ba`, before the merge).
- Serial pass after the full build and `git fetch origin 621a487` (the
teardown-shape self-test's pinned fixture): 104 exit 0, none exit 3.
- `dispatch-gates --ran`: `104 derived, 104 run, 0 NOT-MEASURED, 0
UNRUN`, exit 0.
</details>
`origin/main` moved 21 commits after the merge; none touches a file in
this diff (`git diff --name-only HEAD...origin/main` against the ten
paths), so no second merge was made, and the dogfood reading above is on
the PR's final head.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WGufziW1GcMSZeg21L26Xk)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 3702be4 commit 74aae1e
10 files changed
Lines changed: 318 additions & 36 deletions
File tree
- .changeset
- content/docs/references
- api
- data
- packages
- rest/src
- spec/src/data
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
587 | 587 | | |
588 | 588 | | |
589 | 589 | | |
590 | | - | |
| 590 | + | |
591 | 591 | | |
592 | 592 | | |
593 | 593 | | |
| |||
606 | 606 | | |
607 | 607 | | |
608 | 608 | | |
609 | | - | |
| 609 | + | |
610 | 610 | | |
611 | 611 | | |
612 | 612 | | |
| |||
640 | 640 | | |
641 | 641 | | |
642 | 642 | | |
643 | | - | |
| 643 | + | |
644 | 644 | | |
645 | 645 | | |
646 | 646 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
902 | 902 | | |
903 | 903 | | |
904 | 904 | | |
905 | | - | |
| 905 | + | |
906 | 906 | | |
907 | 907 | | |
908 | 908 | | |
| |||
923 | 923 | | |
924 | 924 | | |
925 | 925 | | |
926 | | - | |
| 926 | + | |
927 | 927 | | |
928 | 928 | | |
929 | 929 | | |
| |||
0 commit comments