Skip to content

Add end-to-end TypeScript typings for mango queries - #8941

Open
pubkey wants to merge 5 commits into
masterfrom
claude/rxdb-mango-query-types-mirrzm
Open

Add end-to-end TypeScript typings for mango queries#8941
pubkey wants to merge 5 commits into
masterfrom
claude/rxdb-mango-query-types-mirrzm

Conversation

@pubkey

@pubkey pubkey commented Aug 10, 2026

Copy link
Copy Markdown
Owner

This PR contains:

  • IMPROVED typings
  • IMPROVED TESTS
  • IMPROVED DOCS

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 were MangoQueryOperators<any>, the sort type accepted any string key, the index option accepted any string, the chained query builder (.where('x').gt(1)) was untyped, the update operators ($set, $inc, ...) accepted anything, and doc.get() returned any for any string path. This means { age: { $regex: 'foo' } } compiled on a number field 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

  • Selector keys: unchanged mechanism (template-literal dot-paths like 'address.city'), capped at a documented depth (see below). A typo in a field name is a compile error.
  • Per-field-type operators: $gt/$gte/$lt/$lte only on numbers and strings, $regex/$options only on strings, $elemMatch/$size only on arrays (with $elemMatch typed against the array item type, which the old typing got wrong), $mod only on numbers, $in/$nin take arrays of the field's type, $eq/$ne/$exists/$type everywhere.
  • Sort: sort accepts 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.)
  • Index: the index option only accepts field paths of the document type, including readonly arrays for as const index definitions.
  • Chained query builder: .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.
  • Update operators: 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 on WithDeleted<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. On RxLocalDocument the paths are typed relative to the data property, because the runtime prepends data. to the given path (the old type test asserted get('data').foo, which never worked at runtime).
  • Results: result typing of 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 = 5 passed to Paths<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 generates items.${number}.field paths instead of recursing into keyof Array (which used to generate garbage paths like items.push and items.length and was the main source of type instantiations).

Backwards compatibility and escape hatch

  • Untyped collections (RxCollection<any>, MangoQuery without 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 to any, so no operator constraint applies. RxCollection<any> stays assignable to and from RxCollection<DocType> in both directions (covered by type tests, this constraint is why the sort part of untyped documents resolves to any).
  • The escape hatch for dynamically built queries is a cast at the call site: collection.find(dynamicQuery as MangoQuery<DocType>). A MangoQuery<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 in rx-query.md. Same for updates via UpdateQuery<any>.
  • Hand-built loose query objects against typed collections will start erroring; that is the feature. Inside this repo, 21 test sites needed an as any cast; 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 plain string paths for get()/get$()/get$$() via MangoQueryPathsOrString, because it is used internally as "any document".
  • The minimum supported TypeScript version is now explicitly 4.1 (template literal types). The TypeScript tutorial page previously claimed 3.8; it is updated and the changelog states the new floor. (De facto the previous typings already used template literal types, so 4.1 was already required.)

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):

before after
Type instantiations 28,862 51,850
Check time 0.27s ~0.3s
Memory used ~75 MB ~70 MB

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 (tsc over the full project, 3 runs each):

before after
Total time 12.5s / 12.7s / 12.5s 11.1s / 11.7s / 11.4s
Check time 9.29s 7.61s (-18%)
Type instantiations 730,961 523,043 (-28%)

The per-operator constraints did not blow up compile times, so nothing had to be relaxed; the array special-casing in Paths more than pays for the added conditional types and the sort part exclusion.

Type tests

test/typings.test.ts (wired into npm run test:typings) gets new blocks asserting both directions for every feature:

  • valid queries compile: nested dot-paths, correct operators per field type, array $elemMatch on item selectors and scalar items, typed sort with nested paths, typed builder chains, typed index (including as const), typed updates including $set: { _deleted: true }, typed doc.get()/get$(), untyped/loose collections, and the cast-at-the-call escape hatch.
  • invalid queries fail to compile via @ts-expect-error: typoed field names (top-level, nested, inside $or, in where(), in sort(), in index, in $set, in get()), wrong-type operators ($regex on number, $gt on boolean, $elemMatch/$size on non-arrays, $inc on strings, $push on 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

  • Tests
  • Documentation
  • Typings
  • Changelog

🤖 Generated with Claude Code

https://claude.ai/code/session_013rQyZu2L4HAAJc91NupfZh

claude added 2 commits August 10, 2026 12:46
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
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

⚠️ Verify Test Reproduction: Tests PASSED without the fix (unexpected)

The changed tests do not fail without the source changes from this PR. Please inspect whether the test changes actually test the bug that the source changes fix.

This workflow runs the changed tests without the source fix to verify they reproduce the bug.

Show output
...(truncated, showing last 200 of 4293 lines)
      �[32m✓ �[39mshould compress text but not jpeg on the same document
    isCompressibleType()
      �[32m✓ �[39mshould match wildcard patterns
      �[32m✓ �[39mshould match exact patterns
      �[32m✓ �[39mshould be case-insensitive
      �[32m✓ �[39mshould NOT match non-compressible types
      �[32m✓ �[39mshould match types that include RFC 2045 parameters like charset
    MIME type preservation
      �[32m✓ �[39mfull roundtrip through storage should preserve MIME type on getData()
      �[32m✓ �[39mfull roundtrip should preserve MIME type for non-compressible type

  attachments-compression.test.ts (mode: gzip)
    basics
      �[32m✓ �[39mcompress->decompress
    CRUD
      �[32m✓ �[39mshould insert one attachment
      �[32m✓ �[39mshould get the attachment
      �[32m✓ �[39mshould get the data
    compare size
      �[32m✓ �[39mshould have a smaller size when compression is used
    selective compression
      �[32m✓ �[39mshould compress a compressible type (text/plain) and roundtrip correctly
      �[32m✓ �[39mshould NOT compress a non-compressible type (image/jpeg) but still roundtrip correctly
      �[32m✓ �[39mshould compress text but not jpeg on the same document
    isCompressibleType()
      �[32m✓ �[39mshould match wildcard patterns
      �[32m✓ �[39mshould match exact patterns
      �[32m✓ �[39mshould be case-insensitive
      �[32m✓ �[39mshould NOT match non-compressible types
      �[32m✓ �[39mshould match types that include RFC 2045 parameters like charset
    MIME type preservation
      �[32m✓ �[39mfull roundtrip through storage should preserve MIME type on getData()
      �[32m✓ �[39mfull roundtrip should preserve MIME type for non-compressible type
WARN LOG: �[36m'-------------- RxDB Open Core RxStorage -------------------------------
You are using the free Dexie.js based RxStorage implementation from RxDB https://rxdb.info/rx-storage-dexie.html?console=dexie 
While this is a great option, we want to let you know that there are faster storage solutions available in our premium plugins.
For professional users and production environments, we highly recommend considering these premium options to enhance performance and reliability.
 https://rxdb.info/premium/?console=dexie 
If you already purchased premium access you can disable this log by calling the setPremiumFlag() function from rxdb-premium/plugins/shared.
---------------------------------------------------------------------'�[39m

  migration-storage.test.ts (prev-major to newest (dexie))
    basic migrations
      �[32m✓ �[39mcreate both databases
      �[32m✓ �[39mshould migrate all documents
      �[32m✓ �[39mshould migrate in parallel
      �[32m✓ �[39mmigrate new->new should also work
    issues
      �[32m✓ �[39mmigration with multiple collections

  migration-storage.test.ts (newest to newest)
    basic migrations
      �[32m✓ �[39mcreate both databases
      �[32m✓ �[39mshould migrate all documents
      �[32m✓ �[39mshould migrate in parallel
      �[32m✓ �[39mmigrate new->new should also work
    issues
      �[32m✓ �[39mmigration with multiple collections

  webmcp.test.ts
    �[32m✓ �[39mshould register query tool when registerWebMCP is called
    �[32m✓ �[39mshould wait for changes using wait_changes tool
    �[32m✓ �[39mchanges tool should return documents without internal meta fields
    �[32m✓ �[39mshould iterate over changes using checkpoint
    �[32m✓ �[39mshould execute modifier tools successfully (insert/upsert/delete)
    �[32m✓ �[39mshould unregister tools when collection is closed
    �[32m✓ �[39mshould not register modifier tools when readOnly is true
    �[32m✓ �[39mshould emit log$ and error$ events for executed tools
    �[32m✓ �[39mshould register tools for newly added collections dynamically
    custom targets
      �[32m✓ �[39mshould build the same tools for a target that is not an RxCollection
      �[32m✓ �[39mshould throw WMCP1 from a target that has no such document
      �[32m✓ �[39mshould not build modifier tools for a readOnly target
      �[32m✓ �[39mshould respect awaitReplicationsInSync on a target
      �[32m✓ �[39mshould register at a given modelContext and unregister on close
      �[32m✓ �[39mshould emit log$ and error$ for a target
      �[32m✓ �[39mshould build a working target from an RxCollection

  crdt.test.ts
    collection creation
      �[32m✓ �[39mshould throw if the wrong conflict handler is set
      �[32m✓ �[39mshould automatically set the CRDT conflict handler
    .insert()
      �[32m✓ �[39mshould insert a document and initialize the crdt state
      �[32m✓ �[39mshould insert document via bulkInsert
    .insertCRDT()
      �[32m✓ �[39mshould insert the document
      �[32m✓ �[39mshould insert the document with undefined argument
      �[32m✓ �[39mshould respect the if-else logic
    .remove()
      �[32m✓ �[39mshould delete the document via .remove
    .incrementalPatch()
      �[32m✓ �[39mshould update the document
    disallowed methods
      �[32m✓ �[39mshould throw on incrementalModify
      �[32m✓ �[39mshould throw on modify
    redirected methods
      �[32m✓ �[39mshould redirect patch through updateCRDT
      �[32m✓ �[39mshould redirect incrementalRemove through updateCRDT
      �[32m✓ �[39mshould redirect update through updateCRDT
    conflict handling
      init
        �[32m✓ �[39minit
      .getCRDTConflictHandler()
        �[32m✓ �[39mshould merge 2 inserts correctly
        �[32m✓ �[39mshould preserve schema default values during conflict resolution
        �[32m✓ �[39mshould preserve the composite primary key during conflict resolution
      conflicts during replication
        �[32m✓ �[39mshould merge the +1 increments

  population.test.js
    createRxSchema
      positive
        �[32m✓ �[39mshould allow to create a schema with a relation
        �[32m✓ �[39mshould allow primary as relation key
        �[32m✓ �[39mshould allow to create a schema with a relation in nested
        �[32m✓ �[39mshould allow to create relation of array
        �[32m✓ �[39mshould allow to create relation with nullable string
      negative
        �[32m✓ �[39mthrow if ref-type is no string
        �[32m✓ �[39mthrow if ref-type is no string (array)
    RxDocument().populate()
      positive
        �[32m✓ �[39mpopulate top-level-field
        �[32m✓ �[39mpopulate nested field
        �[32m✓ �[39mpopulate string-array
        �[32m✓ �[39mpopulate with primary as ref
      negative
        �[32m✓ �[39mthrow DOC5 for a path that does not exist in the schema, even when the value is falsy
        �[32m✓ �[39mthrow DOC6 when populating a non-ref schema field, even when the value is falsy
    RxDocument populate via pseudo-proxy
      positive
        �[32m✓ �[39mpopulate top-level-field
        �[32m✓ �[39mpopulate nested field
    issues
      �[32m✓ �[39m#222 population not working when multiInstance: false
      �[32m✓ �[39mpopulate array should preserve the order of ref ids when two documents reference the same set in different order
      �[32m✓ �[39mpopulate array when ref is defined on items instead of on the array field

  leader-election.test.js
    .die()
      �[32m✓ �[39mother instance applies on death of leader
    election
      �[32m✓ �[39ma single instance should always elect itself as leader
      �[32m✓ �[39mshould not elect as leader if other instance is leader
      �[32m✓ �[39mwhen 2 instances apply at the same time, one should win
      �[32m✓ �[39mwhen many instances apply, one should win
      �[32m✓ �[39mwhen the leader dies, a new one should be elected
    cleanup
      �[32m✓ �[39mshould properly call die() on the elector when the database is closed
      �[32m✓ �[39m#8893 close() must not resolve before the broadcast channel is closed
    integration
      �[32m✓ �[39mnon-multiInstance should always be leader
      �[32m✓ �[39mnon-multiInstance: waitForLeadership should instant
      �[32m✓ �[39mwaitForLeadership: run once when instance becomes leader

  import-export.test.js
    Collection
      .exportJSON()
        �[32m✓ �[39mexport the collection
        �[32m✓ �[39mexport encrypted as decrypted
      .importJSON()
        positive
          �[32m✓ �[39mimport json
        negative
          �[32m✓ �[39mshould not import if schema is different
    Database
      .exportJSON()
        �[32m✓ �[39mshould export a valid dump
        �[32m✓ �[39mexport encrypted as decrypted
        �[32m✓ �[39mexport with multiple collections
        �[32m✓ �[39mexport 1 of 2 collections
      .importJSON()
        positive
          �[32m✓ �[39mimport dump
        negative
          �[32m✓ �[39mshould not import if schema is different
    issues
      �[32m✓ �[39m#319 collections must be created before importDump
      �[32m✓ �[39m#1396 import/export should work with attachments

  database-lifecycle.ts
    �[32m✓ �[39mdo some writes updates and deletes and cleanups and reopens

  last.test.ts (dexie)
    �[32m✓ �[39mrun a minimal performance test to ensure the performance function works
    �[32m✓ �[39mensure all Memory RxStorage instances are closed
    �[32m✓ �[39mensure every db is cleaned up
    �[32m✓ �[39mensure all collections are closed
    �[32m✓ �[39mensure all BroadcastChannels are closed
    �[32m✓ �[39mensure all replication states are closed
    �[32m✓ �[39mensure all RemoteMessageChannels have been closed
    �[32m✓ �[39mensure all websockets have been closed
    �[32m✓ �[39mensure all leader electors are dead
    �[32m✓ �[39mexit the process

Chrome Headless 150.0.0.0 (Linux 0.0.0): Executed 1354 of 1354�[32m SUCCESS�[39m (2 mins 2.249 secs / 2 mins 0.943 secs)
�[32mTOTAL: 1354 SUCCESS�[39m



View full workflow run

pubkey commented Aug 10, 2026

Copy link
Copy Markdown
Owner Author

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 as any casts on tests that intentionally build invalid queries (QU13/QU14 validation and storage-level fields), added so those tests keep compiling under the stricter typings.

The tests that verify the change are the type tests in test/typings.test.ts, which run through npm run test:typings at compile time, not in the browser suite. I verified they fail without the source changes: checking out the previous src/types and rebuilding yields 9 errors, 8 TS2578: Unused '@ts-expect-error' directive (the operator and sort constraints that the old typings did not catch, for example $regex on a number field) and one TS2322 on a valid query that the old typings wrongly rejected ({ tags: 'foo' } matching an item of an array field).


Generated by Claude Code

claude added 3 commits August 10, 2026 13:59
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants