Skip to content

[P3] data: FieldNode's nested-select object form is declared but nothing produces or consumes it — enforce or remove #4196

Description

@os-zhuang

Found while fixing #4171 (PR #4194). Out of scope there; filing rather than widening it.

The finding

FieldNodeSchema in packages/spec/src/data/query.zod.ts declares two forms for a select-list entry:

export type FieldNode =
  | string                                                        // "name"
  | { field: string; fields?: FieldNode[]; alias?: string };       // nested select on a relationship

The object form is declared-but-inert. Every producer and consumer on the QueryAST['fields'] path treats the list as string[]:

  • packages/objectql/src/engine.tsast.fields as string[] at four sites (planFormulaProjection for find and findOne, plus both known-field filters); one of them already reaches for String(f).split('.')[0], which is what defensiveness against a shape you don't actually handle looks like.
  • packages/plugins/driver-sql/src/sql-driver.ts(query.fields as string[]).map(f => this.mapSortField(f)).
  • packages/plugins/driver-mongodb/src/mongodb-driver.ts — indexed the projection with the entry itself, so an object form would have produced the projection key "[object Object]". (Fixed in fix(spec): five exported symbols resolved to any — type the recursive schemas and gate it in CI (#4171) #4194 to read .field, because the newly-precise type made it a compile error — but that is defensive handling of a shape nothing sends, not evidence the shape is live.)

Nothing anywhere reads .fields or .alias off a FieldNode. The capability the object form describes — selecting fields on a related object — is actually served by a different key: QueryAST['expand'], which the engine resolves via batch $in queries with a default max depth of 3.

So this is the ADR-0049 enforce-or-remove shape, and the ADR-0078 no-silently-inert shape: an authorable structure the runtime cannot honour. An author who writes fields: [{ field: 'owner', fields: ['name'] }] today gets it accepted by validation and then silently dropped or mangled, depending on the driver.

Decision needed

Remove (likely correct): drop the object form, leaving FieldNode = string, and let expand be the single spelling for nested selection. That deletes a second de-facto contract for one capability, which is Prime Directive #12's whole argument. Needs the ADR-0087 conversion/tombstone treatment if the shape was ever publicly authorable — FieldNodeSchema is exported from ./data, so it was.

Enforce: lower the object form into expand at the schema boundary (a conversion-layer entry, not a consumer-side ??), so the two spellings converge before any executor sees them. More work, and it buys an alias for something expand already expresses.

packages/spec/liveness + .claude/skills/spec-property-retirement cover the removal route; the liveness ledger is the right place to confirm the dead verdict before acting.

Note on scope

#4194 gave FieldNode a real type (it was z.ZodType<any>, so QueryAST['fields'] was any[]). That change is what made the inertness visible — it did not create it. The check:exported-any gate added there does not cover this class; a declared-but-unproduced shape is a liveness question, not a type question.

Refs #4171, ADR-0049, ADR-0078, ADR-0087.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions