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 streams matching entities back as NDJSON. It is bounded-or-fail:limitcaps the matched set, and a query matching more than that fails outright rather than returning part of the answer. Use it when you know the filter produces a bounded, small set — a UI list, a lookup, a small report. - Background (API term:
async) — queued. The request returns a job handle; poll it, then page the results. Result size is unbounded. 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
SEARCH_RESULT_LIMITor the request timeout →async. - Ordered top-N over a large model →
async(see sorting).
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?limit=200' \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $TOKEN" \ -d '{ "type": "group", "operator": "AND", "conditions": [ { "type": "lifecycle", "field": "state", "operatorType": "EQUALS", "value": "submitted" }, { "type": "simple", "jsonPath": "$.customerId", "operatorType": "EQUALS", "value": "CUST-7" } ] }'The path is /api/search/direct/{entityName}/{modelVersion}, and the request
body is the condition document itself — there is no wrapper object. limit,
pointInTime and trackingRead are query parameters, not body fields.
The response is application/x-ndjson: one complete entity envelope per line,
each carrying data plus a meta block with the id, model key, state, and
timestamps.
{"type":"ENTITY","data":{"customerId":"CUST-7"},"meta":{"id":"74807f00-…","state":"submitted","creationDate":"2026-03-01T10:00:00.000000000Z"}}{"type":"ENTITY","data":{"customerId":"CUST-7"},"meta":{"id":"89abc100-…","state":"submitted","creationDate":"2026-03-02T11:30:00.000000000Z"}}Because it is a stream, parse it line by line rather than buffering the whole body as a single JSON document.
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 '{ "type": "lifecycle", "field": "state", "operatorType": "EQUALS", "value": "submitted" }'The response is a bare UUID string — the job id. Poll its status until the job
reports SUCCESSFUL, then page the results:
GET /api/search/async/{jobId}/statusGET /api/search/async/{jobId}?pageNumber=0&pageSize=1000GET /api/search/async/{jobId}?pageNumber=1&pageSize=1000The status response carries searchJobStatus (RUNNING, SUCCESSFUL,
FAILED, CANCELLED, or NOT_FOUND), entitiesCount for the total match
count, and expirationDate. Fetching results before the job is SUCCESSFUL
returns 400 BAD_REQUEST.
Results come back as a paged JSON object — content plus a page block with
number, size, totalElements and totalPages — not as a stream. Job
results expire 24 hours after creation; a single jobId can be paged
repeatedly until then.
Async search never returns SEARCH_RESULT_LIMIT: it snapshots the whole
matched set and pages over it. An out-of-range pageSize or pageNumber is a
plain 400 BAD_REQUEST.
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 PUT 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 condition DSL
Section titled “The condition DSL”The request body is a Condition document. Every condition carries a
type discriminator. The same DSL is used by workflow and transition criteria,
so what you learn here transfers directly.
simple — match a JSONPath into the entity payload:
{ "type": "simple", "jsonPath": "$.amount", "operatorType": "GREATER_OR_EQUAL", "value": 1000 }lifecycle — match entity metadata rather than payload. field is one of
state, creationDate, lastUpdateTime, transitionForLatestSave (alias
previousTransition), transactionId, id:
{ "type": "lifecycle", "field": "state", "operatorType": "EQUALS", "value": "submitted" }group — combine conditions. operator is AND or OR; these are the
only two, and NOT is not supported. Groups nest to a maximum depth of 50:
{ "type": "group", "operator": "AND", "conditions": [ { "type": "lifecycle", "field": "state", "operatorType": "EQUALS", "value": "submitted" }, { "type": "simple", "jsonPath": "$.amount", "operatorType": "GREATER_OR_EQUAL", "value": 1000 } ]}array — match positional values in a JSON array, where null matches any
value at that index:
{ "type": "array", "jsonPath": "$.laureates", "values": ["John", null, "Hopfield"] }function — delegate the predicate to a compute node. The condition is
dispatched as an EntityCriteriaCalculationRequest, exactly like a workflow
criterion:
{ "type": "function", "function": { "name": "my-criteria-fn", "config": { "calculationNodesTags": "approval-service", "attachEntity": true, "responseTimeoutMs": 30000 } }}function.name and config.calculationNodesTags are required for routing;
attachEntity defaults to true and responseTimeoutMs to 30000.
operatorType also accepts the spellings operator and operation. The
available operators are:
| Group | Operators |
|---|---|
| Comparison | EQUALS, NOT_EQUAL, GREATER_THAN, GREATER_OR_EQUAL, LESS_THAN, LESS_OR_EQUAL |
| Range | BETWEEN, BETWEEN_INCLUSIVE — value must be a two-element [low, high] array |
| Presence | IS_NULL, NOT_NULL |
| String | CONTAINS, STARTS_WITH, ENDS_WITH and their NOT_* inverses |
| Case-insensitive | IEQUALS, INOT_EQUAL, ICONTAINS, INOT_CONTAINS, ISTARTS_WITH, INOT_STARTS_WITH, IENDS_WITH, INOT_ENDS_WITH |
| Pattern | LIKE (anchored glob), MATCHES_PATTERN (anchored RE2 regex) |
An operator outside this list is rejected with 400 BAD_REQUEST, and the
error detail includes the canonical list. IS_CHANGED and IS_UNCHANGED are
change-generation operators, not search predicates — cyoda-go does not
implement them.
Predicate semantics
Section titled “Predicate semantics”Search and workflow criteria run on one shared evaluation kernel, so a
predicate means the same thing wherever you write it. The kernel implements
Cyoda Cloud’s model; cyoda help predicates is the exhaustive reference.
Comparison is type-directed and same-type. An operand is parse-tested
against the field’s declared type(s), so the JSON number 30 and the string
"30" behave identically. There is no cross-type coincidental matching: an
operand that parses only as a number never matches a string-stored value.
Numbers compare at arbitrary precision, so values beyond 2^53 are exact.
Absent and null fields never match a binary operator — including
negatives. NOT_EQUAL, NOT_CONTAINS, INOT_* and the rest are
null-guarded to non-match rather than evaluated as !positive. A missing field
does not satisfy NOT_EQUAL just because it fails EQUALS. Use IS_NULL and
NOT_NULL to test presence — they are the only operators that do.
LIKE is a real anchored glob. % matches any run of characters, _
matches exactly one, and \ escapes a literal %, _ or \. The match is
whole-string and case-sensitive. MATCHES_PATTERN is a whole-string-anchored
RE2 regex; RE2 diverges from Java’s dialect on constructs such as
backreferences and some lookaround.
Temporal fields compare chronologically, not lexically, across
LOCAL_DATE, LOCAL_DATE_TIME, LOCAL_TIME, ZONED_DATE_TIME, YEAR and
YEAR_MONTH. The creationDate and lastUpdateTime meta fields accept a
coarser operand that upscales — "2024" or "2024-09" are valid against a
full timestamp.
Validation is parse-based, evaluated against the target model:
400 CONDITION_TYPE_MISMATCH— the operand parses into none of the field’s declared types. Comparison and range operators only; string operators and presence tests carry no operand-type constraint.400 INVALID_FIELD_PATH— the path is unknown to the model, or it names a pure container (an object) and you used a scalar operator. Navigate to a scalar leaf instead. A path seen as both an object and a scalar across entities stays searchable via its scalar type.IS_NULL/NOT_NULLare exempt, since they test presence rather than a value.400 INVALID_CONDITION— anulloperand on a binary or range operator, a range operand that is not a two-element array, or an object operand.
There is no operator-versus-type rejection: CONTAINS on a numeric field is a
valid request that simply evaluates to a non-match.
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.
pointInTime is a query parameter (RFC 3339), not a body field:
curl -X POST 'http://localhost:8080/api/search/direct/orders/1?pointInTime=2026-03-01T00:00:00Z' \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $TOKEN" \ -d '{ "type": "lifecycle", "field": "state", "operatorType": "EQUALS", "value": "submitted" }'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. Meta date fields order chronologically; temporal data paths are ordered as text, which for ISO-8601 values coincides with chronological order. Note that this is the sort contract — predicate comparison uses the arbitrary-precision, type-directed rules described under predicate semantics. 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, as is exceeding the sort-key cap set 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}; they apply at result-fetch time, not at job submission.pageNumberis zero-indexed.- A completed
jobIdis stable for its 24-hour retention window — page reads are idempotent.
Searching inside a transaction
Section titled “Searching inside a transaction”A search issued inside an active transaction is read-your-own-writes correct: it sees that transaction’s uncommitted writes, without falling back to a full-model scan. The memory and SQLite backends overlay the transaction buffer on the committed stream; PostgreSQL runs the query natively on the transaction’s own connection.
By default the search is a plain snapshot read that records nothing. Pass
trackingRead=true to opt into recording the entities the search returns
into the transaction’s read-set, so that a concurrent commit touching any of
them aborts your transaction with 409 Conflict at commit time:
POST /api/search/direct/orders/1?trackingRead=trueThe protection is entity-level, the same as a GetAll read. Neither setting
protects against phantoms — an entity that starts matching your predicate
after the snapshot was taken is not detected either way. The flag is ignored
outside a transaction, and async search does not expose it at all, since it
runs detached.
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. 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.
- Pushdown is a narrowing optimization only — the in-process kernel remains authoritative for every match decision, so results never diverge between backends. Pushdown applies inside transactions too, so an in-transaction search no longer degrades into a full
GetAllscan. - Boolean conditions on PostgreSQL are pushed down correctly as of v0.8.3. Previously an
EQUALS/NOT_EQUALagainst a boolean field returned500, because the planner bound a raw Goboolagainst a text-typed JSON extraction that the driver cannot encode. - A condition the plugin cannot translate falls back to in-memory filtering after a full scan. If that residual scan examines more rows than the backend’s budget allows, it fails with
400 SCAN_BUDGET_EXHAUSTED— narrow the query or add an indexable predicate rather than retrying.
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.