Add end-to-end TypeScript typings for mango queries - #8941
Conversation
Selector keys and sort fields of MangoQuery<DocType> are now checked against the document type, including nested dot-paths generated with template literal types. Query operators are constrained by the type of the field they run on: $gt/$gte/$lt/$lte on numbers and strings, $regex and $options only on strings, $elemMatch and $size only on arrays, $mod only on numbers, $in/$nin take arrays of the field type. Dot-paths are generated for up to 6 levels of nesting (MangoQueryPathsMaxDepth) to keep compile times bounded. The Paths utility type now special-cases arrays so that array item paths are generated instead of array method names, which reduces tsc instantiations for the whole repo from 730k to 469k and check time from 9.3s to 7.5s. Untyped collections (RxCollection<any>) and loosely typed documents keep accepting the same queries as before. Dynamically built queries can opt out by casting to MangoQuery<any>. Raises the documented minimum TypeScript version to 4.1 because of the template literal types. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rQyZu2L4HAAJc91NupfZh
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rQyZu2L4HAAJc91NupfZh
|
|
This is a false positive of the reproduction check: the PR is a types-only feature, so the changed runtime tests are expected to pass without the source changes. The runtime test edits are only The tests that verify the change are the type tests in Generated by Claude Code |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rQyZu2L4HAAJc91NupfZh
…rt parts
Extends the typed mango queries to the remaining query APIs:
- The chained query builder: .where() only accepts field paths of the
document type and returns a builder state whose operator methods are
constrained by the type of that field, so .where('age').regex('foo')
is a compile error on a number field.
- The update operators of RxDocument.update() and RxQuery.update():
field paths of $set, $inc, $push, $addToSet, $pop, $pullAll, $unset,
$min, $max and $rename are checked against the document type and the
values against the field type. CRDT operations and document updates
run on WithDeleted<RxDocType> so that setting _deleted stays possible.
- The index option of queries only accepts field paths of the
document type, including readonly arrays for 'as const' indexes.
- RxDocument.get(), get$() and get$$() check the path against the
document type and return the value type of that field. On local
documents the paths are relative to the data property which matches
the runtime that prepends 'data.' to the given path.
- A sort part now must contain exactly one field because the field
order of a JSON object is not deterministic. Multiple sort fields
are given as multiple sort parts.
The escape hatch for dynamically built queries is now a cast at the
call site, like find(dynamicQuery as MangoQuery<DocType>), because a
MangoQuery<any> object is no longer directly assignable to a typed
query parameter. Untyped collections keep working like before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rQyZu2L4HAAJc91NupfZh
The test asserted the signal value directly after the insert, but the signal updates asynchronously, so under slow storages the assertion ran before the signal had received the new results. Wait for the value instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rQyZu2L4HAAJc91NupfZh
This PR contains:
Describe the problem you have without this PR
A query against a typed
RxCollection<DocType>was only partially type-checked. The selector keys were already constrained to the document's dot-paths, but the operator payloads wereMangoQueryOperators<any>, thesorttype accepted any string key, theindexoption accepted any string, the chained query builder (.where('x').gt(1)) was untyped, the update operators ($set,$inc, ...) accepted anything, anddoc.get()returnedanyfor any string path. This means{ age: { $regex: 'foo' } }compiled on anumberfield and a typo in a sort field compiled and silently sorted by a non-existent field.With this PR, the whole query surface is type-checked against
DocType. This is a types-only change: no runtime behavior changes, no new runtime code, no new dependencies.What is typed now
'address.city'), capped at a documented depth (see below). A typo in a field name is a compile error.$gt/$gte/$lt/$lteonly on numbers and strings,$regex/$optionsonly on strings,$elemMatch/$sizeonly on arrays (with$elemMatchtyped against the array item type, which the old typing got wrong),$modonly on numbers,$in/$nintake arrays of the field's type,$eq/$ne/$exists/$typeeverywhere.sortaccepts only dot-paths that exist on the document type, and one sort part must contain exactly one field, because the key order of a JSON object is not deterministic. Multiple sort fields are given as multiple sort parts. (The runtime keeps accepting multi-key parts and splitting them; the type marks them as an error because their order is ambiguous.)indexoption only accepts field paths of the document type, including readonly arrays foras constindex definitions..where('age')only accepts existing field paths and returns a builder state (RxQueryFieldSelector) whose operator methods are constrained by that field's type, so.where('age').regex('foo')is a compile error while.where('age').gt(10).lte(20)compiles..sort()accepts typed sort parts,'field'and'-field'strings.UpdateQuery<DocType>types the field paths of$set/$unset/$min/$max/$rename(any known field),$inc(number fields), and$push/$addToSet/$pop/$pullAll(array fields, values typed by the item type).doc.update()and CRDT entries run onWithDeleted<DocType>so soft-deleting via$set: { _deleted: true }stays typed, matching the runtime.doc.get()/get$()/get$$(): the path is checked against the document type and the return value has that field's type. OnRxLocalDocumentthe paths are typed relative to thedataproperty, because the runtime prependsdata.to the given path (the old type test assertedget('data').foo, which never worked at runtime).find()/findOne()/exec()is unchanged. No result-type narrowing based on$exists/$type(out of scope for this version).Depth cap
Dot-paths are generated for up to 6 levels of nesting (
MangoQueryPathsMaxDepth = 5passed toPaths<T, D>, which yields paths of up to 6 segments). The cap is documented in the type's JSDoc and in the docs. Deeper fields need the escape hatch.As part of this, the shared
Paths<T>utility type now special-cases arrays: it generatesitems.${number}.fieldpaths instead of recursing intokeyof Array(which used to generate garbage paths likeitems.pushanditems.lengthand was the main source of type instantiations).Backwards compatibility and escape hatch
RxCollection<any>,MangoQuerywithout a type argument) and documents with index signatures ({ [k: string]: any }) behave as before; when a field's type cannot be determined it falls back toany, so no operator constraint applies.RxCollection<any>stays assignable to and fromRxCollection<DocType>in both directions (covered by type tests, this constraint is why the sort part of untyped documents resolves toany).collection.find(dynamicQuery as MangoQuery<DocType>). AMangoQuery<any>object is no longer directly assignable to a typed query parameter, because the exactly-one-key sort part cannot accept a wide object type. This is documented inrx-query.md. Same for updates viaUpdateQuery<any>.as anycast; almost all of them are tests that intentionally build invalid queries to assert runtime validation errors (QU13/QU14/QU17), pass storage-level fields (_deleted,_id) into user-level query types, or exercise the runtime's multi-key sort splitting.RxDocument<{}>(the default) keeps plainstringpaths forget()/get$()/get$$()viaMangoQueryPathsOrString, because it is used internally as "any document".Compile time benchmark
Measured with
tsc --extendedDiagnostics(TypeScript 5.9.3, Node 22), best of interleaved runs.Isolated benchmark (a 6-level nested document type with ~30 typed queries, only the mango query types in scope):
The instantiation increase comes from the exactly-one-key sort part, which excludes all other paths per sort key (quadratic in the number of paths, computed once per document type and then cached).
Whole repo (
tscover the full project, 3 runs each):The per-operator constraints did not blow up compile times, so nothing had to be relaxed; the array special-casing in
Pathsmore than pays for the added conditional types and the sort part exclusion.Type tests
test/typings.test.ts(wired intonpm run test:typings) gets new blocks asserting both directions for every feature:$elemMatchon item selectors and scalar items, typed sort with nested paths, typed builder chains, typedindex(includingas const), typed updates including$set: { _deleted: true }, typeddoc.get()/get$(), untyped/loose collections, and the cast-at-the-call escape hatch.@ts-expect-error: typoed field names (top-level, nested, inside$or, inwhere(), insort(), inindex, in$set, inget()), wrong-type operators ($regexon number,$gton boolean,$elemMatch/$sizeon non-arrays,$incon strings,$pushon non-arrays), wrong-typed values, multi-key sort parts, and invalid sort paths.One TypeScript quirk worth knowing for review: distributing the sort keys over a conditional chain like
Paths<T> extends infer K ? K extends string ? ...silently fails to enforce the exactly-one shape; the distribution must happen over a naked type parameter of a helper generic (MangoQuerySortPartDistribute). The broken variant compiles and accepts multi-key sort parts, which is why the type tests assert the negative case.Todos
🤖 Generated with Claude Code
https://claude.ai/code/session_013rQyZu2L4HAAJc91NupfZh