Skip to content

EngineQueryOptionsSchema.search rejects the bare query string that ADR-0061 D1 calls canonical — so every engine caller that wants it must as any, losing the whole query's checking #7178

Description

@os-zhuang

Surfaced while implementing #6231 (metadata seat). Filed unassigned and unlabeled for triage grading. Not fixed there: the fix is in packages/spec, which that card's dispatch declared a STOP boundary (cross-seat declaration on #6298).

The divergence

Two sibling schemas in packages/spec describe the same key and disagree.

packages/spec/src/data/query.zod.tsBaseQuerySchema (hence QueryAST, hence DriverQuery):

search: z.union([z.string(), FullTextSearchSchema]).optional()

with a doc comment that is unusually explicit about which spelling is canonical:

The bare string IS the canonical Tier-1 contract (ADR-0061 D1: "the client sends only the query text; the server resolves which fields to search from object metadata") — it is what every surface sends and what the dogfood HTTP proof (showcase-search.dogfood.test.ts) pins.

packages/spec/src/data/data-engine.zod.ts:119EngineQueryOptionsSchema, the options type of IDataEngine.find / findOne:

/** Full-text search configuration */
search: FullTextSearchSchema.optional(),

Structured form only. The canonical bare string is not accepted.

Why this is a live cost, not dormant drift

The runtime serves the string — it is the engine's own tests that prove it, and they prove the workaround at the same time. packages/objectql/src/engine-findone-contract.test.ts passes the canonical spelling five times, each behind a cast:

const row = await engine.findOne('crm_account', { search: 'Two' } as any);
const rows = await engine.find('crm_account', { search: 'Two' } as any);

So the type forbids what the runtime serves and what the ADR declares canonical, and callers pay the standard price: as any on the query argument, which does not suppress search alone — it switches off checking for where / orderBy / fields in the same literal. That is exactly the account #5181's changeset opened and that #6231 has just been closing at five other call sites (cloud#1053 measured 20 such sites; cloud#1030's $like reached runtime through one).

Note the shape of the trap: EngineQueryOptionsSchema is not .strict(), and check:query-options-erasure's own rationale spells out the consequence — an unknown key is silently dropped. So the cast that works around this divergence is precisely the cast that ratchet exists to stop.

There is a same-family precedent for the repair. The identical drift once existed on the query side and was fixed, and query.zod.ts records why:

The union is schema-side drift REPAIR, not a new dialect: the schema declared only the object form while the executor and the ADR's own conformance ledger served the string — surfaced the moment #3899 started validating request bodies against this schema.

EngineQueryOptionsSchema is the same drift, unrepaired.

How it surfaced

In #6231, DatabaseLoader's three read helpers were retyped from Record< string, unknown > to the driver contract's DriverQuery. The driver branch then compiled with no cast at all. The engine branch did not:

src/loaders/database-loader.ts(230,38): error TS2345: Argument of type 'DriverQuery' is not
assignable to parameter of type '{ ... }'.
  Types of property 'search' are incompatible.
    Type 'string | { query: string; ... } | undefined' is not assignable to
    type '{ query: string; ... } | undefined'.
      Type 'string' is not assignable to type '{ query: string; ... }'.

DriverQuery is Omit< QueryAST, 'object' >, so it inherits the union; EngineQueryOptionsParsed does not have it. Concretely: DriverQuery is not assignable to EngineQueryOptionsParsed, purely because of search. Nothing else differs.

Interesting asymmetry worth keeping: IDataEngine.count takes EngineCountOptions, which is a z.input type, and it accepts the same value fine. Only find / findOne — which take the z.infer (parsed) type — reject it.

#6231's PR therefore left the three engine-branch as any casts exactly as main had them, with a comment naming this issue, rather than narrowing the cast or restoring a wider one.

Suggested direction (for triage, not a decision I am making)

Align EngineQueryOptionsSchema.search with BaseQuerySchema.search — the same z.union([ z.string(), FullTextSearchSchema ]) — on the grounds that the ADR, the executor, the HTTP surface and the dogfood proof all already agree on the string, so the engine option schema is the only outlier. That would additionally let the three database-loader engine-branch casts be deleted, which is real where/orderBy/fields checking recovered on the metadata main read path.

Worth confirming before acting, since it is a spec change and I did not verify it: whether any consumer relies on EngineQueryOptionsParsed['search'] being narrowed to the object form (e.g. reading .query off it without a typeof guard). If some do, they need the guard added in the same PR.

Session: session_01W6bLax4KMrSfnE1ydFU8Dw (found during #6231, unclaimed)

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions