Skip to content
Settings

Searching entities

Cyoda exposes search over REST for any query that returns a set of entities. Use it when you need more than one entity back, when the filter goes beyond a single id lookup, or when you want to scope by workflow state. For single-entity reads, stay on the CRUD endpoints in working with entities; for cross-entity analytics, use SQL; for event-driven compute, use gRPC compute nodes. For counts and aggregates without the entity bodies, jump to grouped statistics.

Cyoda splits search into Immediate and Background modes. Pick by expected result size and urgency; the filter grammar is identical.

  • Immediate (API term: direct) — synchronous. The request returns matching entities in the response body. Result size is capped, so direct is the right default only when you know the filter produces a bounded, small set: a UI list, a lookup, a small report. If a query hits the cap, switch it to async.
  • Background (API term: async) — queued. The request returns a job handle; poll it to retrieve results. Result size is unbounded and results are paged. On the Cassandra-backed tier (Cyoda Cloud, or a licensed Enterprise install), async runs distributed across the cluster: for a fixed query shape, throughput scales roughly linearly with the number of nodes.

The decision tree is short:

  • Small bounded result, UI-facing → direct.
  • Might be large, can tolerate a second or two of queuing, exports, reports, batch jobs → async.
  • Hitting the cap or the request timeout on directasync.

Filter by a combination of entity fields and workflow state:

Terminal window
curl -X POST http://localhost:8080/api/search/direct/orders/1 \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $TOKEN" \
-d '{
"filter": {
"state": "submitted",
"customerId": "CUST-7"
}
}'

The path is /api/search/direct/{entityName}/{modelVersion}. The response is the list of matching entities, each with its current state, revision, and timestamps.

Submit the search to /api/search/async/{entityName}/{modelVersion} and capture the handle:

Terminal window
curl -X POST http://localhost:8080/api/search/async/orders/1 \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $TOKEN" \
-d '{
"filter": { "state": "submitted" }
}'

Poll the returned jobId until the job is ready, then fetch pages (pageNumber is zero-indexed; pageSize caps the page):

GET /api/search/async/{jobId}/status
GET /api/search/async/{jobId}/results?pageNumber=0&pageSize=1000
GET /api/search/async/{jobId}/results?pageNumber=1&pageSize=1000

A single jobId can be paged repeatedly until the result is expired; expiry is controlled per deployment.

If a job is no longer needed — the user navigated away, a replacement query was submitted, the deployment is shutting down — cancel it rather than letting it run to completion:

Terminal window
curl -X DELETE http://localhost:8080/api/search/async/{jobId}/cancel \
-H "Authorization: Bearer $TOKEN"

Cancellation is cooperative: in-flight work is stopped at the next safe point and any partial results for that jobId are discarded.

The filter is a JSON document whose fields are entity field paths, metadata (state, createdAt, …), or workflow labels. The authoritative operator grammar — equality, comparisons, ranges, set membership, AND/OR combinators, nested-field access — lives in the REST API reference. The shape used in the simple examples above (flat field→value map) is the equality short form; use the full object form when you need operators:

{
"and": [
{ "field": "state", "eq": "submitted" },
{ "field": "amount", "gte": 1000 }
]
}

For the full predicate grammar — every operator, nesting rule, and function — run cyoda help search against your binary.

Every search accepts a pointInTime parameter to run against the world as it existed at a given timestamp. Each entity maintains a history of revisions; point-in-time queries return results using the entity state that was current at the specified timestamp. The result is the set of entities that would have matched, using the revision active at that time.

The rule is canonical across every storage engine and read path: inclusive of the requested instant (<=), compared at native precision with no millisecond round-up.

Terminal window
curl -X POST http://localhost:8080/api/search/direct/orders/1 \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $TOKEN" \
-d '{
"pointInTime": "2026-03-01T00:00:00Z",
"filter": { "state": "submitted", "customerId": "CUST-7" }
}'

This is the primary way to answer audit and regulatory questions from REST — what did this customer’s open orders look like at quarter close? The Trino surface exposes the same capability as a column named point_time (snake-case, matching SQL convention); for the analytical form, see point_time in analytics.

Both search endpoints (direct and async) accept sort keys. Over HTTP, add one or more sort query parameters using the grammar [@]path[:asc|desc]:

  • a bare dotted path sorts on a scalar data field (amount, customer.tier);
  • an @-prefix sorts on a meta field — one of state, creationDate, lastUpdateTime, transitionForLatestSave, transactionId, id;
  • direction defaults to asc.
POST /api/search/direct/orders/1?sort=@state&sort=amount:desc

Ordering is canonical across every backend: text by byte order, numeric by IEEE-754 double, bool as false < true, and temporal chronologically. Absent or null values sort last, and the entity id is always the final tiebreaker. An unsortable, array, or unknown path is rejected with 400 INVALID_FIELD_PATH. The number of sort keys per request is capped by CYODA_SEARCH_MAX_SORT_KEYS (default 16; see the configuration reference). Over gRPC the same capability is expressed as a structured orderBy array.

  • pageSize and pageNumber are query parameters on /search/async/{jobId}/results; they apply at result-fetch time, not at job submission. pageNumber is zero-indexed.
  • A completed jobId is stable for its retention window — page reads are idempotent.
  • Scope by state or a high-selectivity field first — the workflow state is indexed on every entity and is almost always the right first predicate.
  • Prefer async as soon as the result set might be thousands of entities; the distributed execution on the Cassandra tier makes it cheaper per entity than a series of direct pages.
  • Avoid open-ended pointInTime scans across every revision — anchor the query at a specific timestamp or a short window.
  • On PostgreSQL, supported predicates push down into SQL — JSONB extraction plus numeric, range, and string comparisons run in the database, and LIMIT/OFFSET are pushed down when no residual filtering remains. Non-pushable operators (regex, case-insensitive) are post-filtered as rows stream. This removes full-model scans and per-document decode; it is a constant-factor win, not a JSON-path index (indexing queried paths remains a separate operational step). SQLite already does this; the in-memory backend filters in memory by design.

When you want counts and aggregates rather than the entities themselves — how many orders are in each state? what is the total amount per country? — use the grouped-statistics query instead of paging a search result and summing client-side. It returns one row per group and never the underlying entity bodies, so it stays cheap over large populations.

POST /entity/stats/{entityName}/{modelVersion}/query
Terminal window
curl -X POST http://localhost:8080/api/entity/stats/orders/1/query \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $TOKEN" \
-d '{
"groupBy": ["state", "$.country"],
"aggregations": [
{ "op": "sum", "field": "$.amount", "as": "totalAmount" },
{ "op": "avg", "field": "$.amount" }
],
"condition": {
"type": "simple",
"jsonPath": "$.channel",
"operation": "EQUALS",
"value": "web"
}
}'

The request has four parts:

  • groupBy (required) — an ordered list of dimensions. Each entry is either the literal state (the workflow state) or a $.-prefixed JSONPath into the entity payload. The result groupKey is ordered to match.
  • aggregations (optional) — per-group numeric aggregates. Each is { "op", "field", "as" } where op is one of sum, avg, min, max, stdev, field is a JSONPath, and as is an optional alias (defaults to op(field), e.g. sum($.amount)). Every group also carries a count whether or not you request aggregations.
  • condition (optional) — a predicate that restricts the population, using the same Condition DSL as search.
  • limit (optional) — caps the number of buckets returned; must be ≤ the server’s CYODA_STATS_GROUP_MAX (default 10000).

The response is one bucket per group:

[
{
"groupKey": [
{ "field": "state", "value": "submitted" },
{ "field": "$.country", "value": "GB" }
],
"count": 31,
"aggregations": { "totalAmount": 48210.0, "avg($.amount)": 1555.16 }
}
]

Aggregation values are numeric, or null when a group has no eligible inputs.

Like search, the query accepts a pointInTime to aggregate the world as it existed at a past instant — without standing up a derived counter entity that processors must maintain on every transition. Add it to the request body:

{
"groupBy": ["state"],
"pointInTime": "2026-03-31T23:59:59Z"
}

This answers end-of-quarter and audit roll-up questions directly — how many claims were pending at midnight on the last day of Q1? When omitted, the query runs against the current consistency time.