This is part 3. Part 1 covered what an ontology is — objects, links, actions, security — and Part 2 covered how it's consumed. This time: the single most important thing for anyone building on top of it — how you read. Every screen, every list, every chart, every agent answer starts with a query. Get the query model right and the whole frontend falls out of it. We'll build the intuition with Mai and Hamed, play with it live, then the appendix pins it down as an actual API contract you could implement.
The big idea: you don't write SQL, you build an object set
Here's the mental shift. You're not writing a query string that runs once and vanishes. You build an object set — a description of a slice of your data — and then you ask that description for things.
The description is lazy. Nothing is fetched when you build it. It's a recipe, not the cooked dish. Only when you ask it a concrete question ("give me the first page," "how many," "group these by status") does anything actually run.
👩💻 Mai (junior): Why not just send a query and get rows back, like SQL?
👨🏫 Hamed (mentor): Because an object set is composable and reusable. You build "open orders over $100," and then you can hand that same thing around — page it on one screen, count it on a badge, chart it on a dashboard — without rewriting the filter three times. One description, many questions. SQL makes you rewrite the FROM/WHERE every time; this doesn't.
You build a set by stacking operations, and every operation just returns another description — still nothing fetched:
In code, on the client, that's a fluent, immutable builder:
const openOrders = client.objects(Order)
.where({ status: { eq: "open" }, total: { gt: 100 } })
.orderBy("createdAt", "desc");
// Nothing has run. openOrders is a description you can pass around.
One description, many questions
Now the payoff. That one openOrders set answers several different questions, each a different "terminal" operation that actually hits the server:
const page = await openOrders.fetchPage({ pageSize: 25 }); // objects
const total = await openOrders.count(); // a number
const byStatus = await openOrders.aggregate({ // a chart
groupBy: "status",
metrics: ["count"],
});
This is the reason to model reads as object sets rather than one-off queries. A table, a count badge, and a chart on the same screen are three terminal calls on one shared description — not three hand-written queries that can drift out of sync.
Try it. The playground below is this model, live. Build an object set step by step, then press Run to materialize it — and watch the engine console trace every stage of the pipeline. It's also where Part 1's ideas come alive: flip View as to watch the security gate hide rows and mask columns, and acknowledge an alert to see an action's optimistic write (and a rollback when it's rejected).
Filters: how you narrow
A filter is a tree of predicates. At the leaves are simple comparisons on a property; above them, boolean glue. The operator vocabulary you need to cover everything:
- Equality and sets:
eq,neq,in - Ordering:
lt,lte,gt,gte,between(ranges — numbers, dates) - Null checks:
isNull - Text:
contains,startsWith,endsWith - Geo:
withinRadius,withinBbox - Relationship existence:
hasLink(objects that have a link to some other set) - Composition:
and,or,not
👩💻 Mai: What about "customers who have at least one open order"? That's not a property on the customer.
👨🏫 Hamed: Right — that's a relationship filter, and it's where this gets more powerful than a flat WHERE clause. You filter one set by the existence of a link into another set. Which brings us to the real superpower: traversal.
Traversal: walking the graph
Because objects are linked, a query can move across the graph. The key rule that keeps it simple: traversing a link gives you back another object set. Start with a set of Customers, follow their orders link, and now you have a set of Orders — which you can filter, order, and paginate exactly like any other set.
const hongkongOpenOrders = client.objects(Customer)
.where({ branch: { eq: "Hong Kong" } })
.traverse("orders") // now an object set of Orders
.where({ status: { eq: "open" } }); // keep narrowing — it's just a set
That uniformity — traversal in, set out — is what lets the frontend treat "a customer's orders" and "all orders" identically. Same component, same hook, same pagination.
Ordering and pagination
Two boring-but-critical mechanics.
Ordering is a list of keys (property + direction), applied in order. One rule that saves you from flaky lists: always tie-break on the primary key. Two orders with the same timestamp must come back in a stable order, every time, or pagination breaks.
Pagination is cursor-based, not offset-based. You ask for a pageSize and get back a nextPageToken; you pass that token to get the next page. Why not LIMIT/OFFSET? Because data changes under you — if someone inserts a row while a user is scrolling, offset-based paging silently skips or repeats items. A cursor points at "where you were," so it stays correct even as the underlying set shifts. It's also far cheaper at scale.
Aggregations: ask for numbers, not objects
Sometimes you don't want the objects — you want a summary. Aggregations run on the server over the whole set (not just one page) and return groups and metrics:
const revenueByMonth = await openOrders.aggregate({
groupBy: [{ property: "createdAt", bucket: { dateHistogram: "month" } }],
metrics: [{ sum: "total" }, "count"],
});
Metrics cover count, sum, avg, min, max, and approxDistinct. Grouping is either by an exact property value or by a bucket — numeric ranges, or a date histogram (day/week/month). This is what powers every dashboard tile without shipping thousands of objects to the browser.
Search: when the user types words, not filters
Filters are for precise, structured narrowing. Search is for fuzzy, human input — a search box. It runs full-text (and typically prefix/fuzzy) matching across the properties you've marked searchable, ranks the results, and can be combined with a regular filter:
const results = client.objects(Order)
.search("acme refund") // ranked, fuzzy
.where({ status: { neq: "archived" } }); // still composable
Keep the distinction clean: filters answer "exactly these," search answers "best matches for these words."
Derived and computed properties
Not every property is stored. A derived property is computed — daysOpen, riskScore, fullName — by a function defined in the ontology. The important part for the query model: once defined, a derived property behaves like any other. You can select it, filter on it, and order by it:
client.objects(Order)
.where({ daysOpen: { gt: 30 } }) // filter on a computed value
.orderBy("riskScore", "desc"); // order by another
The only caveat is cost — a derived value may be computed at query time, so filtering/sorting on it can be heavier than on a stored column. That's a performance note, not a different API.
Two things that are always true
Selection keeps payloads small. By default, don't ship every property and every linked object. Let the caller say which properties it needs and which links to expand — the table that shows three columns shouldn't download forty.
openOrders.select(["id", "status", "total"]).expand({ customer: ["name"] });
Security is not your job here — it's automatic. Every one of these calls runs through the policy gates from Part 1. An object set silently returns only the rows the caller may see, with forbidden columns omitted. You never write a permission check in a query; the set is already scoped to the caller. The frontend's only related job is to read the visibility metadata so it doesn't render a column the server was never going to return.
👩💻 Mai: So the whole read side is really just: build a set, then call one of a handful of terminal operations on it?
👨🏫 Hamed: That's it. Build → filter → traverse → shape, then
fetchPage/count/aggregate/search. Learn those once and you've learned every read in the platform. And it maps one-to-one onto the frontend hooks — which is exactly why we pinned the query model down before touching React.
The one-paragraph summary
You read an ontology by composing an object set — a lazy, reusable description built from a starting object type plus filters, link traversals, ordering, and selection. You then ask that set a terminal question: a page of objects, a count, an aggregation, or a ranked search. Traversal turns one set into another, so the whole graph is reachable through one uniform model. Derived properties behave like stored ones. Pagination is cursor-based for stability. And every set is automatically scoped to what the caller is allowed to see. That single model — build a description, ask it questions — is the entire read surface, and it's what your data-layer hooks will wrap one-for-one.
Appendix — The Query API (REST contract)
This is the concrete contract to implement. REST-ish: simple reads are GET; anything with a nested filter is POST with a JSON body (query strings can't carry a filter tree). The object-set description is the central data structure; the endpoints are thin operations over it.
Conventions
- Base path:
/api/v1/ontology {type}= an object type's API name (e.g.Order).{pk}= a primary key value.- All
POSTreads take a JSON body and are safe/idempotent (they don't mutate).POSTis used only because the filter tree won't fit in a URL. - All responses are scoped to the caller's policy. Forbidden rows are absent; forbidden properties are omitted from each object (never returned as
nullto disguise existence).
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET | /objects/{type}/{pk} | Fetch one object by primary key |
POST | /objects/{type}/load | Load a page of an object set |
POST | /objects/{type}/count | Count an object set |
POST | /objects/{type}/aggregate | Aggregate an object set |
POST | /objects/{type}/search | Ranked full-text search |
POST | /objects/{type}/{pk}/links/{link}/load | Load a page of linked objects |
POST | /objectSets/load | Load from a full object-set description (incl. traversal chains) |
The per-type endpoints are conveniences. /objectSets/load is the general form: it accepts a complete object-set description, including traversal, so multi-hop queries have a home.
The object-set description
The body shared by load, count, and aggregate:
{
"base": { "objectType": "Order" },
"where": { "/* see Filter grammar below */": "..." },
"traverse": [ { "link": "customer" } ],
"orderBy": [ { "property": "createdAt", "direction": "desc" } ],
"select": ["id", "status", "total"],
"expand": { "customer": { "select": ["id", "name"] } },
"page": { "pageSize": 25, "pageToken": null }
}
base— the starting object type (and optionally a startingwhere).traverse— an ordered list of link hops. After traversal, the set's type is the final hop's target;where/orderBy/selectapply to that type.select/expand— projection.selectlists properties to return (omit ⇒ a default set).expandpulls in linked objects with their ownselect.orderBy— ordered keys; the server appends the primary key as a final tie-breaker.page—pageSize(server clamps to a max, e.g. 1000) and the opaquepageToken.
Filter grammar
A filter is either a leaf or a composite.
{ "property": "status", "op": "eq", "value": "open" }
{ "and": [
{ "property": "status", "op": "eq", "value": "open" },
{ "property": "total", "op": "gt", "value": 100 },
{ "not": { "property": "channel", "op": "in", "value": ["test", "demo"] } }
] }
Leaf operators:
| Category | Ops | Value shape |
|---|---|---|
| Equality / set | eq, neq, in | scalar; in → array |
| Ordering / range | lt, lte, gt, gte, between | scalar; between → [min, max] |
| Null | isNull | boolean |
| Text | contains, startsWith, endsWith | string |
| Geo | withinRadius, withinBbox | {center, radius} / {bbox} |
| Relationship | hasLink | a nested set + filter (see below) |
hasLink is how you express "customers who have at least one open order" without leaving the customer set:
{ "op": "hasLink",
"value": { "link": "orders", "where": { "property": "status", "op": "eq", "value": "open" } } }
Load response
{
"data": [
{ "__type": "Order", "__primaryKey": "ord_9", "id": "ord_9", "status": "open", "total": 240 }
],
"nextPageToken": "eyJvZmZzZXQiOiAyNX0"
}
nextPageToken is null when the last page has been returned. Clients pass it back verbatim in page.pageToken.
Count response
{ "count": 1843 }
Aggregate
Request:
{
"where": { "property": "status", "op": "eq", "value": "open" },
"groupBy": [
{ "property": "branch" },
{ "property": "createdAt", "bucket": { "dateHistogram": "month" } }
],
"metrics": [ { "op": "count" }, { "op": "sum", "property": "total" } ]
}
Bucketing kinds: exact (no bucket), dateHistogram (day|week|month|quarter|year), and ranges. Metric ops: count, sum, avg, min, max, approxDistinct.
Response — one row per group key combination:
{
"groups": [
{ "key": { "branch": "Hong Kong", "createdAt": "2026-05" }, "metrics": { "count": 92, "sum_total": 41200 } }
]
}
Search
{ "query": "acme refund", "where": { "property": "status", "op": "neq", "value": "archived" },
"page": { "pageSize": 20 } }
Runs full-text (prefix + fuzzy) over the type's searchable properties, combinable with a structured where. Response is a load response plus a per-object __score for ranking.
Derived properties
Derived properties (computed by an ontology function) appear in select, where, and orderBy exactly like stored ones — no special syntax. The server resolves them; treat them as potentially more expensive to filter/sort on.
Errors
{ "error": { "code": "INVALID_FILTER", "message": "Unknown property 'totl' on Order", "details": { "property": "totl" } } }
Codes: INVALID_FILTER, INVALID_ORDER, UNKNOWN_OBJECT_TYPE, UNKNOWN_LINK, PAGE_TOKEN_EXPIRED, FORBIDDEN (caller can't see the type at all), PAGE_SIZE_EXCEEDED.
Mapping to the frontend hooks
This is why the model was worth pinning down first — the hooks are one-for-one wrappers:
| Hook | Endpoint | Returns |
|---|---|---|
useObject(type, pk) | GET /objects/{type}/{pk} | one object |
useObjects(set) | POST /objects/{type}/load | page + fetchMore |
useLinks(object, link) | POST /objects/{type}/{pk}/links/{link}/load | linked page |
useCount(set) | POST /objects/{type}/count | a number |
useAggregation(set, spec) | POST /objects/{type}/aggregate | groups |
useSearch(type, query) | POST /objects/{type}/search | ranked page |
Design decisions (the "why")
- POST for complex reads. A filter tree, traversal chain, and aggregation spec don't fit a query string. Keep
GETonly for fetch-by-id. - Object set as a first-class description. It composes (filter → traverse → filter), it's serializable (you can save/share/cache a set definition), and the server can plan/optimize the whole thing at once.
- Cursor pagination. Stable under concurrent writes; scales past the offset cliff.
- Stable ordering via primary-key tie-break. Non-negotiable, or pagination duplicates/skips.
- Projection by default-narrow.
select/expandexist so the common case (a few columns) doesn't drag the whole object graph over the wire. - Security is server-side and invisible to the query. The query never expresses permissions; the set is pre-scoped to the caller. The UI mirrors it via metadata, never enforces it.