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.
Two query modes
Section titled “Two query modes”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, sodirectis 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 toasync. - 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),asyncruns 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
direct→async.
A direct search
Section titled “A direct search”Filter by a combination of entity fields and workflow state:
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.
An async search
Section titled “An async search”Submit the search to /api/search/async/{entityName}/{modelVersion} and
capture the handle:
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}/statusGET /api/search/async/{jobId}/results?pageNumber=0&pageSize=1000GET /api/search/async/{jobId}/results?pageNumber=1&pageSize=1000A single jobId can be paged repeatedly until the result is
expired; expiry is controlled per deployment.
Cancelling a job
Section titled “Cancelling a job”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:
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.
Filter shape
Section titled “Filter shape”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.
Historical reads with pointInTime
Section titled “Historical reads with pointInTime”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.
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.
Sorting results
Section titled “Sorting results”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 ofstate,creationDate,lastUpdateTime,transitionForLatestSave,transactionId,id; - direction defaults to
asc.
POST /api/search/direct/orders/1?sort=@state&sort=amount:descOrdering 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.
Paging (async)
Section titled “Paging (async)”pageSizeandpageNumberare query parameters on/search/async/{jobId}/results; they apply at result-fetch time, not at job submission.pageNumberis zero-indexed.- A completed
jobIdis stable for its retention window — page reads are idempotent.
Performance notes
Section titled “Performance notes”- Scope by
stateor a high-selectivity field first — the workflow state is indexed on every entity and is almost always the right first predicate. - Prefer
asyncas 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 ofdirectpages. - Avoid open-ended
pointInTimescans 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/OFFSETare 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.
Grouped statistics
Section titled “Grouped statistics”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}/querycurl -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 literalstate(the workflow state) or a$.-prefixed JSONPath into the entity payload. The resultgroupKeyis ordered to match.aggregations(optional) — per-group numeric aggregates. Each is{ "op", "field", "as" }whereopis one ofsum,avg,min,max,stdev,fieldis a JSONPath, andasis an optional alias (defaults toop(field), e.g.sum($.amount)). Every group also carries acountwhether 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’sCYODA_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.
Point-in-time statistics
Section titled “Point-in-time statistics”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.
Where to go next
Section titled “Where to go next”- REST API reference — authoritative search payload schema, operator grammar, status and result endpoints.
- Working with entities — single-entity CRUD and transitions; the CRUD page for reference on the same surface.
- Analytics with SQL — heavy analytical
work, cross-entity joins, historical scans via
point_time. - Entities and lifecycle — the
audit/history model behind
pointInTime.