Skip to content

Reading data

A read is three choices: which records (a filter), in what shape to get them, and how to page through them. The document query port composes all three, and the method name spells out the combination.

Filtering

You select records with a small filter DSL. $values holds field constraints, $fields compares fields to each other, and $and / $or / $not combine them. Inside $values, plain values are shorthands — a scalar means equals, a list means in, None means is null:

open_orders = await ctx.document.query(order_spec).find_many(
    {"$values": {"status": "open", "tags": ["priority"]}}
)

The same expression drives search filters and authorization scope filters, so it's worth learning once. The full operator set ($gt, $like, $overlaps, …) is in the query syntax reference.

When a value must drive logic inside the source — a window function or a view's own WHERE that a result filter can't reach — declare a typed query parameter instead.

Shape — the method prefix

What comes back is the prefix:

Prefix Returns
find / get the full read model
project a chosen subset of fields, as a JsonDict
select rows validated as a different return type
aggregate grouped or aggregated rows

Pagination — the method suffix

How many, and how you walk them, is the suffix:

Suffix Returns When
(none) / _many CountlessPage lists where the total doesn't matter
_page Page (with a total count) UIs that show "X of N"
_cursor CursorPage large or infinite lists, stable under writes
_stream an async generator of batches full exports — walk the whole set in bounded memory

Offset pages (_page, _many) are simple but get slower the deeper you go, and can skip or repeat rows as data shifts underneath. Cursor (keyset) pages walk by a stable key — reach for them on big result sets and live feeds. The cursor itself is a client-held token; on a public API you can sign or encrypt it so it can't be forged or replayed against a different query.

The name is the combination: select_page(...) is an alternate return type, offset-paged, with a count; find_cursor(...) is the read model, keyset-paged.

Searching

Full-text and vector search are a parallel surface, through the search query port — the same shape × pagination naming, but results come back ranked:

hits = await ctx.search.query(order_search).search("blue widget")

search / search_page / search_cursor / search_stream (with project_ and select_ variants) mirror the document methods. A query can also ask for facet counts and highlighted fragments alongside its hits — per query, through SearchOptions, over fields the spec declares facetable or highlightable (the full option surface is the search contract). Engines cover full-text, vector similarity, and hub / federated search that spans several relations. Vector search ranks by embeddings — vectors produced by an embeddings provider (ctx.embeddings.provider(spec)) — so semantically similar text scores together. Keeping the index current — upsert and delete — is the separate search command port; querying and index maintenance never mix.

Search is wired like any other capability: a SearchSpec resolved from the context, with per-engine setup living in each integration.

Not every backend compiles every feature — Firestore has no aggregations, a key-value document store no array quantifiers. The read DSL is capability-gated: a query that reaches for a feature the wired backend can't compile is rejected up front with a clean precondition naming the feature and backend, never a 500 deep in an adapter.

For a query-only service with no write side, see the read-only document API recipe. Reading is one half of the story; writes that change state also emit domain events — how those propagate is Events & sagas.