Skip to content
Settings

search — entity search API

cyoda-go version 0.8.4

search — entity search API: synchronous direct search and asynchronous snapshot search. Entity statistics endpoints (/api/entity/stats/...) are documented in the crud topic.

POST /api/search/direct/{entityName}/{modelVersion}
POST /api/search/async/{entityName}/{modelVersion}
GET /api/search/async/{jobId}
GET /api/search/async/{jobId}/status
PUT /api/search/async/{jobId}/cancel

Context path prefix is CYODA_CONTEXT_PATH (default /api). All endpoints require Authorization: Bearer <token> except when CYODA_IAM_MODE=mock.

Search operates against a specific entity model (entityName, modelVersion). Two modes are supported:

Synchronous (direct) search: POST /search/direct/{entityName}/{modelVersion}. Executes inline within the HTTP request. The response is an NDJSON stream (application/x-ndjson), one entity envelope per line. Search is bounded-or-fail: limit caps the matched set rather than paging it — a matched set larger than limit returns 400 SEARCH_RESULT_LIMIT, never a truncated prefix. The default limit when omitted is 1000; the maximum is 10000; values below 1 are rejected with 400 BAD_REQUEST.

Asynchronous search: POST /search/async/{entityName}/{modelVersion}. Submits a search job and returns a job UUID immediately. The search executes in a background goroutine (or in the plugin’s own executor for SelfExecutingSearchStore plugins). Results are retrieved by polling status and then fetching pages.

Both modes accept the same Condition DSL as the request body. The condition is translated to a plugin-level predicate and pushed down to the backend — including inside an active transaction, where the pushdown is read-your-own-writes correct against the transaction’s own uncommitted writes (see trackingRead below and docs/CONSISTENCY.md §3c). There is no in-memory fallback: a condition that cannot be translated is rejected with 400 INVALID_CONDITION (or INVALID_FIELD_PATH for a path-shaped failure), and every backend implements the pushdown. The pushdown is a narrowing optimization only — the in-process kernel is authoritative for every match decision, so results never diverge by backend.

Bounding. The server bounds search results, never search time. Direct search is bounded-or-fail on limit; async search caps neither duration nor result count. No backend meters examined rows or applies a scan budget, so a non-indexable condition forcing a residual scan runs to completion however long it takes.

Time is the caller’s to bound, and it has the levers: timeoutMillis on direct search (408 SEARCH_TIMEOUT, nothing partial returned), and job cancellation on async, which takes effect mid-flight. Omitting them means unbounded, by choice. The one server-side exception is an operator-configured backend ceiling on the async scan (CYODA_POSTGRES_SEARCH_STATEMENT_TIMEOUT, see the config.database topic); a job that hits it fails with a message naming both ways out.

Operator semantics (type-directed comparison, null handling, LIKE/regex grammar, validation) are documented in the predicates topic; workflow and transition criteria use the identical predicate semantics (see workflows).

All search requests accept a Condition JSON document as the POST body. Conditions are parsed recursively up to a maximum nesting depth of 50. Body size limit: 10 MiB.

SimpleCondition — match a single JSON path against a scalar value:

{
"type": "simple",
"jsonPath": "$.category",
"operatorType": "EQUALS",
"value": "physics"
}
  • type: "simple"
  • jsonPath: JSON Path string, $. leader required (e.g., "$.year", "$.laureates[0].firstname") — see JSONPath grammar below
  • operatorType (also accepted as operator or operation): operator string (see valid values below)
  • value: any JSON scalar

Valid operatorType values (exhaustive): EQUALS, NOT_EQUAL, GREATER_THAN, GREATER_OR_EQUAL, LESS_THAN, LESS_OR_EQUAL, CONTAINS, NOT_CONTAINS, STARTS_WITH, NOT_STARTS_WITH, ENDS_WITH, NOT_ENDS_WITH, LIKE, IS_NULL, NOT_NULL, BETWEEN, BETWEEN_INCLUSIVE, MATCHES_PATTERN, IEQUALS, INOT_EQUAL, ICONTAINS, INOT_CONTAINS, ISTARTS_WITH, INOT_STARTS_WITH, IENDS_WITH, INOT_ENDS_WITH. BETWEEN/BETWEEN_INCLUSIVE require value to be a two-element array [low, high]. Comparison is type-directed and same-type only (a JSON number and a numeric-looking string are treated identically); a missing/null field never matches any binary operator, including the NOT_*/INOT_* negatives. Full per-operator semantics, LIKE grammar, and validation rules are in the predicates topic.

IS_CHANGED/IS_UNCHANGED are not supported.

JSONPath grammar. A condition’s jsonPath is JSON Path nomenclature, checked at the API boundary before anything executes:

jsonPath = "$." segment ( "." segment )*
segment = name subscript*
name = 1*( ALPHA / DIGIT / "_" / "-" ) ; ASCII only
subscript = "[" ( "*" / 1*DIGIT ) "]" ; the digit run must fit a signed 32-bit integer

The $. leader is required. A bare amount is not a path and is rejected 400 errors.INVALID_FIELD_PATH — it is not a tolerated alias for $.amount. So are an empty path, an empty or trailing segment ($..a, $.a.), bracket-quoted property access ($['x'], $.['x'], $.a["b"] — write $.x), and any character outside the segment set.

Well-formed array subscripts — the wildcard [*] or a non-negative index [0]are valid and accepted ($.tags[*].name, $.arr[0], $.matrix[*][*], $.orders[*].lines[*].sku). A positional index pushes into the storage query on a backend that supports it. A wildcard cannot be pushed into a scalar comparison — it addresses a set, not one value — so it is always evaluated by re-checking the candidate rows; results are identical either way, only throughput differs.

[*] addresses every element, so a leaf on it holds when some element satisfies it: $.tags[*] EQUALS "red" selects the entities whose tags contains "red". It is existential, so nothing matches an empty array — neither IS_NULL nor NOT_NULL holds on {"tags": []}. [0] addresses that one element. A trailing [*] on an array of pure objects is rejected 400 errors.INVALID_FIELD_PATH under a scalar operator — the element has no scalar form, so navigate to the leaf ($.items[*].sku, not $.items[*]).

Multi-branch fields and vacuity. A field may be declared as more than one shape, and a path is accepted when it is a valid statement for at least one declared branch. Per entity the predicate then applies to whichever branch that entity’s data actually is; where the path is not a valid statement for that branch the entity simply does not match — that is a non-match, not an error. So for a field declared as string and array-of-string, $.a EQUALS "A" selects the scalar-shaped entities and $.a[*] EQUALS "A" the array-shaped ones, and neither condition is rejected.

An empty array answers the three path forms differently, because each addresses something different. A bare $.a addresses the array itself, which exists when it is empty, so NOT_NULL is true. $.a[*] addresses the elements and never the array’s own nullness, so over [] both IS_NULL and NOT_NULL are false — on a wildcard path the two are complements only where at least one element exists. $.a[0] addresses one position, which is absent and therefore null, so IS_NULL is true. Full addressing, branch and vacuity rules: docs/cloud-parity/path-grammar.md.

Every other bracket spelling is rejected 400 errors.INVALID_FIELD_PATH: unclosed or unmatched ($.a[, $.a[0, $.a]), no field name before it ($.[0]), empty ($.a[]), negative or signed ($.a[-1], $.a[+1]), a slice ($.a[0:2]), a union ($.a[0,1]), a filter expression ($.a[?(@.x)]), whitespace inside ($.a[ 0]), or a positional index too large to fit a signed 32-bit integer ($.a[2147483648]) — 2147483647 is the largest index accepted, and no entity array is long enough for a larger one to address a real position. The path is scanned to the end, so trailing junk after a valid subscript is caught too ($.a[0]b, $.a[0];DROP, $.a[*]..b). These used to go unvalidated and return 200 with an empty page.

Metadata is not addressed through jsonPath at all — a lifecycle condition names a meta field directly (see LifecycleCondition) and is not subject to this grammar. A data path that happens to spell $._meta.state is an ordinary dotted path.

The same grammar governs grouped statistics (groupBy, aggregation field), which additionally rejects array subscripts because a group key must be a single scalar — see the crud topic. It also governs workflow and transition criterion paths, rejected at workflow import with 400 errors.VALIDATION_FAILED — see the workflows topic.

Operator strings outside this list are rejected with errors.INVALID_CONDITION at request time; the error detail includes the canonical list.

LifecycleCondition — match entity lifecycle metadata:

{
"type": "lifecycle",
"field": "state",
"operatorType": "EQUALS",
"value": "APPROVED"
}
  • type: "lifecycle"
  • field: state, creationDate, lastUpdateTime, transitionForLatestSave (alias previousTransition), transactionId, id
  • operatorType (also accepted as operator or operation): operator string — same valid values as for SimpleCondition
  • value: any JSON scalar

creationDate/lastUpdateTime are temporal: compared chronologically at millisecond resolution. A comparison/range operand (EQUALS, NOT_EQUAL, GREATER_THAN, LESS_THAN, GREATER_OR_EQUAL, LESS_OR_EQUAL, BETWEEN, BETWEEN_INCLUSIVE) must parse as a temporal value — an offset-bearing RFC3339 instant, or a coarser value ("2024", "2024-09", an offset-less date-time) which upscales to an instant; only an operand that parses into no temporal form is rejected 400 CONDITION_TYPE_MISMATCH. String and pattern operators (CONTAINS, LIKE, MATCHES_PATTERN, the case-insensitive family, …) do not apply to these fields and are rejected 400 INVALID_CONDITION — not a type mismatch, since no operand could make the operator valid here. IS_NULL/NOT_NULL test presence and carry no type constraint. An unknown meta filter field is rejected 400 INVALID_FIELD_PATH.

GroupCondition — combine conditions with a logical operator:

{
"type": "group",
"operator": "AND",
"conditions": [
{ "type": "simple", "jsonPath": "$.year", "operatorType": "EQUALS", "value": "2024" },
{ "type": "lifecycle", "field": "state", "operatorType": "EQUALS", "value": "NEW" }
]
}
  • type: "group"
  • operator: "AND", "OR", or "NOT" — any other string is rejected 400 errors.INVALID_CONDITION at validation time (“unknown group operator”)
  • conditions: array of Condition objects (recursive; maximum nesting depth 50) — for "AND"/"OR" any number of entries, including zero; for "NOT" exactly one entry

An AND group with an empty conditions array evaluates to true (vacuous conjunction). An OR group with an empty conditions array evaluates to false (vacuous disjunction).

NOT inverts its single child’s two-valued answer: NOT(c) is true exactly when c is false. conditions with zero entries, or two or more, is rejected 400 errors.INVALID_CONDITION — a bare list under NOT has two defensible readings (“not both” vs. “neither”) that disagree on the same data, so the group is written by nesting: NOT(A AND B), not NOT[A, B]. NOT(NOT(x)) is legal and restores x’s own answer.

Over a wildcard path NOT is a universal quantifier, where the leaf underneath it is existential: NOT($.tags[*] EQUALS "red") matches when no element equals "red", while $.tags[*] NOT_EQUAL "red" matches when some element differs from "red" — for {"tags":["red","blue"]} the first is false and the second is true. NOT is never rewritten by De Morgan into a leaf’s negative twin (NOT(EQUALS) is not NOT_EQUAL; NOT(IS_NULL) is not NOT_NULL — see predicates), and a NOTted group is never distributed over its children.

NOT over an empty list, an explicit null, or an absent field is true, because the inner leaf is false in all three states — [*] is existential and nothing matches an empty array (see above), and a missing/null value never matches any binary operator including the negatives (see predicates). On an absent field NOT($.x EQUALS "A") matches while both $.x EQUALS "A" and $.x NOT_EQUAL "A" do not — NOT sits outside the operator and inverts a result the operator itself never inverts.

There is no ALL(P) (“every element satisfies P”) operator, and no sound way to build one from NOT over a list that may contain null: NOT(some element satisfies ¬P) reports “every element satisfies P” for ["red", null], which is wrong. Do not use that construction.

A NOT anywhere in a condition makes the whole query residual: no backend pushes a NOT into its own query language, so a condition containing one is not bounded by a pushed SQL LIMIT clause. It is evaluated in memory by the kernel, streaming through the model and stopping once enough matches accumulate to satisfy the request’s own limit, rather than narrowing in SQL first.

EMPTY CONDITION: Submitting an empty body ({}) or a body with no type field as the top-level search condition is rejected with errors.BAD_REQUEST — the parser requires a valid type field. Submitting a valid AND group with an empty conditions array ({"type":"group","operator":"AND","conditions":[]}) is accepted and matches all entities — this is the correct way to retrieve all entities without filtering.

ArrayCondition — match positional values in a JSON array:

{
"type": "array",
"jsonPath": "$.laureates[*]",
"values": ["John", null, "Hopfield"]
}
  • type: "array"
  • jsonPath: JSON Path to the array’s elements, and must carry a trailing [*] (see JSONPath grammar below) — a bare path ($.laureates) addresses the array itself, not its elements, and is rejected 400 errors.INVALID_FIELD_PATH
  • values: positional values, one per array index in order; a null entry tests nothing at that index and is skipped

Each non-null entry is a positional test: values[i] compared against element i. The clause is read as an AND of those positional comparisons — ["John", null, "Hopfield"] means element 0 equals "John" and element 2 equals "Hopfield". values made entirely of null matches every entity.

FunctionCondition — server-side function predicate dispatched to a compute member. Criteria only — search requests reject it. Documented here because criteria and search share the one Condition DSL; a search, async-search, grouped-stats or conditional-delete body carrying a function clause at any depth is rejected 400 INVALID_CONDITION. Use it in a workflow or transition criterion (see workflows).

{
"type": "function",
"function": {
"name": "my-criteria-fn",
"config": {
"calculationNodesTags": "approval-service",
"attachEntity": true,
"responseTimeoutMs": 30000
}
}
}
  • type: "function"
  • function.name: string — identifies the function; becomes criteriaId / criteriaName in the dispatch request; required for routing
  • function.config.calculationNodesTags: string — comma-separated tags used to select a registered compute member; follows the same tag-intersection rules as processor dispatch
  • function.config.attachEntity: boolean (optional, default true) — when true, the full entity payload is included in the dispatch request
  • function.config.responseTimeoutMs: int64 (optional, default 30000) — timeout in milliseconds

When used as a criterion, the function is dispatched as EntityCriteriaCalculationRequest to the matching compute member — see the grpc topic for the request/response shape — and must be the whole criterion; one nested inside a group fails the evaluation. Search has no dispatcher: FunctionCondition cannot be translated to a storage-plugin pushdown filter and the in-process kernel has no evaluator for it, which is why it is rejected up front rather than attempted.

POST /api/search/direct/{entityName}/{modelVersion} — Synchronous search

  • entityName (path): string
  • modelVersion (path): int32
  • pointInTime (query, optional): RFC 3339 date-time — search against entity state at this instant. Point-in-time search uses the canonical inclusive (<=, no rounding) bound — see cyoda help crud (“Point-in-time semantics”).
  • limit (query, optional): string-encoded integer, minimum 1, maximum 10000; default 1000
  • trackingRead (query, optional): boolean, default false. Only meaningful inside an active transaction (see crud topic and docs/CONSISTENCY.md §3c for the transactional read-set): when true, the entities this search returns are recorded into the transaction’s read-set, so a concurrent commit touching any of them aborts with 409 Conflict at commit time. When false (default), the search is a plain snapshot read that records nothing — cheap, but it does not protect the returned rows from concurrent writes, and neither setting protects against phantoms (a new entity matching the predicate after the snapshot was taken). Ignored outside a transaction.
  • timeoutMillis (query, optional): int64, no default — when absent, the search has no server-side deadline. When present, the search is aborted once it elapses and the request fails 408 errors.SEARCH_TIMEOUT with no partial results returned. Rejected with 400 BAD_REQUEST on a non-positive value or on a request that joins an open transaction (a routed compute-node callback cannot impose its own deadline on a transaction it does not own).

Request body: Condition JSON document.

Response: 200 OK, Content-Type: application/x-ndjson.

Each line is a complete entity envelope JSON object:

{"type":"ENTITY","data":{"category":"physics","year":"2024"},"meta":{"id":"74807f00-ed0d-11ee-a357-ae468cd3ed16","modelKey":{"name":"nobel-prize","version":1},"state":"NEW","creationDate":"2025-08-01T10:00:00.000000000Z","lastUpdateTime":"2025-08-01T10:00:00.000000000Z"}}
{"type":"ENTITY","data":{"category":"chemistry","year":"2023"},"meta":{"id":"89abc100-ed0d-11ee-a357-ae468cd3ed16","modelKey":{"name":"nobel-prize","version":1},"state":"APPROVED","creationDate":"2025-07-15T09:00:00.000000000Z","lastUpdateTime":"2025-07-20T14:00:00.000000000Z"}}

The stream is truncated on encode failure after the header has been sent; the client detects truncation via a connection error or incomplete last line.

POST /api/search/async/{entityName}/{modelVersion} — Submit async search job

  • entityName (path): string
  • modelVersion (path): int32
  • pointInTime (query, optional): RFC 3339 — if not provided, the current time is captured at submission

Request body: Condition JSON document.

Response: 200 OK, application/json — bare UUID string (job ID):

"a1b2c3d4-e5f6-11ee-9e63-ae468cd3ed16"

The job is stored with status RUNNING. For non-SelfExecutingSearchStore backends, a goroutine begins the search immediately using a background context derived from the submitting user’s tenant context.

Submission is bounded by a fixed-size worker pool (CYODA_SEARCH_ASYNC_WORKERS, CYODA_SEARCH_ASYNC_QUEUE); once both the running workers and the queue are exhausted, submission fails 503 SEARCH_QUEUE_FULL (retryable) instead of blocking or spawning an unbounded goroutine per request.

Results stream incrementally as the scan runs rather than being materialized in memory and saved all at once. A running job stamps its own liveness on a fixed cadence (CYODA_SEARCH_JOB_HEARTBEAT_INTERVAL, default 15s) starting from the moment it is submitted — including while it is still queued, not only while it is scanning — and the same poll also picks up a cancellation or an externally-recorded terminal status.

If a job’s owning node dies without ever reaching a terminal status, a background reaper claims it once its heartbeat has gone silent for CYODA_SEARCH_JOB_STALE_AFTER (default 5m, enforced to be at least 4x the heartbeat interval), clears any partial results the dead executor left, and re-runs it on a live node as-at its originally stored pointInTime — the job still completes SUCCESSFUL. It is FAILED (with a generic message) only after CYODA_SEARCH_JOB_MAX_ATTEMPTS executor losses (default 3): the status is contractual, the message text is not. A graceful node shutdown or restart releases its in-flight jobs immediately for reclaim rather than waiting for them to go stale, so a planned handoff is prompt. The reaper runs on CYODA_SEARCH_JOB_HEARTBEAT_INTERVAL’s ticker (default 15s) plus once at startup — not CYODA_SEARCH_REAP_INTERVAL, which drives only the unrelated snapshot-TTL cleanup — so actual detection latency for a crash is up to CYODA_SEARCH_JOB_STALE_AFTER + one heartbeat interval (~5m15s at the defaults).

GET /api/search/async/{jobId}/status — Get async job status

  • jobId (path): UUID

Response: 200 OK, application/json:

{
"searchJobStatus": "SUCCESSFUL",
"createTime": "2025-08-01T10:00:00.000000000Z",
"entitiesCount": 42,
"calculationTimeMillis": 145,
"finishTime": "2025-08-01T10:00:00.145000000Z",
"expirationDate": "2025-08-02T10:00:00.000000000Z"
}
  • searchJobStatus: "RUNNING", "SUCCESSFUL", "FAILED", "CANCELLED", or "NOT_FOUND" (snapshot expired or not found on commercial backends)
  • createTime: RFC 3339 with nanoseconds
  • entitiesCount: total matching entities (0 while running)
  • calculationTimeMillis: elapsed search time in milliseconds
  • finishTime: RFC 3339 with nanoseconds; absent when status is RUNNING
  • expirationDate: createTime + 24h — job results expire after this time

A job ends FAILED when the search itself failed, when the reaper’s reclaim of a dead node’s job exhausts CYODA_SEARCH_JOB_MAX_ATTEMPTS, or when the model’s schema becomes unloadable between submit and execution — the executor re-reads the schema, and a job that cannot validate its condition against it fails rather than finishing SUCCESSFUL with a short page.

GET /api/search/async/{jobId} — Retrieve async job results (paginated)

  • jobId (path): UUID
  • pageSize (query, optional): string-encoded integer, default 1000
  • pageNumber (query, optional): string-encoded integer, default 0; offset = pageNumber * pageSize

The job must be in SUCCESSFUL status. Returns 400 BAD_REQUEST if the job is not yet complete.

Response: 200 OK, application/json:

{
"content": [
{
"type": "ENTITY",
"data": { "category": "physics", "year": "2024" },
"meta": {
"id": "74807f00-ed0d-11ee-a357-ae468cd3ed16",
"modelKey": {"name": "nobel-prize", "version": 1},
"state": "NEW",
"creationDate": "2025-08-01T10:00:00.000000000Z",
"lastUpdateTime": "2025-08-01T10:00:00.000000000Z"
}
}
],
"page": {
"number": 0,
"size": 1000,
"totalElements": 42,
"totalPages": 1
}
}

Results are fetched from the stored entity snapshots at the job’s pointInTime. Entities deleted or modified after submission are returned as they existed at submission time.

PUT /api/search/async/{jobId}/cancel — Cancel a running async job

  • jobId (path): UUID

Cancellation succeeds only when the job status is RUNNING. If the job has already reached a terminal state (SUCCESSFUL, FAILED, or CANCELLED), the server returns 400 Bad Request:

{
"detail": "snapshot by id=<jobId> is not running. current status=SUCCESSFUL",
"properties": {
"currentStatus": "SUCCESSFUL",
"snapshotId": "<jobId>"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}

On successful cancellation, response: 200 OK, application/json:

{
"isCancelled": true,
"cancelled": true,
"currentSearchJobStatus": "CANCELLED"
}

Both sync and async search accept one or more sort query parameters. Repeat the parameter for multi-key sorting; precedence follows declaration order.

Grammar: [@]path[:asc|desc]

  • Direction defaults to asc when omitted.
  • A leading $. on a data path is tolerated and stripped: $.year:desc equals year:desc.
  • Prefix @ to sort by a meta field: @creationDate:asc.

Meta field allowlist (only these are accepted with @): state, creationDate, lastUpdateTime, transitionForLatestSave, transactionId, id.

Order semantics:

  • Strings: byte (lexicographic) order.
  • Numbers: numeric order.
  • Meta dates (creationDate, lastUpdateTime, transitionForLatestSave): chronological; millisecond resolution is the minimum precision enforced cross-engine.
  • Absent or null values sort last regardless of direction.

Tiebreaker: entity_id ascending is always appended as the final key.

Key cap: configurable via CYODA_SEARCH_MAX_SORT_KEYS (default 16); exceeding the cap returns errors.INVALID_FIELD_PATH (400), like any other malformed sort value.

Invalid paths: unsortable, unknown, array, or non-scalar paths return errors.INVALID_FIELD_PATH (400). A path segment is drawn from A-Za-z0-9_-, and an array subscript or projection (items[*].name, items[0].name) is rejected — an ordering needs a single scalar, and being a recorded field is not enough: a scalar leaf inside an array of objects is one. The gRPC orderBy.path is held to the same grammar in the same resolver, so both transports answer a given path identically.

Async search results use page-number pagination: pageNumber=0 is the first page, offset = pageNumber * pageSize. pageNumber and pageSize are both string-encoded integers in query parameters.

Synchronous search neither paginates nor truncates: the matched set must fit within limit or the request fails 400 SEARCH_RESULT_LIMIT. Any result set larger than that — including an ordered top-N over a large model (sort plus a small limit) — belongs on the async path, which snapshots the full result set and pages over it.

  • errors.MODEL_NOT_FOUND404 — model not registered for the calling tenant (search, async submit)
  • errors.SEARCH_JOB_NOT_FOUND404 — async job UUID does not exist.
  • errors.SEARCH_JOB_ALREADY_TERMINAL400 — cancel attempted on a job that is already SUCCESSFUL, FAILED, or CANCELLED; body carries currentStatus and snapshotId
  • errors.SEARCH_RESULT_LIMIT400 — direct search’s matched entity count exceeded the requested limit; enforced by the backend’s bounded-or-fail Search. Async search never returns this code — an oversized pageSize/pageNumber on result retrieval is errors.BAD_REQUEST instead
  • errors.SEARCH_TIMEOUT408 — direct search’s client-supplied timeoutMillis elapsed before the result set was collected; retryable, and nothing partial is returned
  • errors.SEARCH_SHARD_TIMEOUT — per-shard search timeout exceeded (relevant for distributed backends)
  • errors.SEARCH_QUEUE_FULL503 — async submit refused for capacity: either the node’s worker pool and submit queue are both exhausted, or the tenant is at its in-flight share of this node; retryable, tune via CYODA_SEARCH_ASYNC_WORKERS/CYODA_SEARCH_ASYNC_QUEUE/CYODA_SEARCH_ASYNC_MAX_PER_TENANT
  • errors.INVALID_FIELD_PATH400 — a jsonPath is not valid JSON Path syntax (missing $. leader, bracket-quoted access, empty/trailing segment, disallowed character), or references field paths absent from the model’s locked schema, or a lifecycle condition names an unknown meta filter field; the response detail names each offending path and why
  • errors.CONDITION_TYPE_MISMATCH400 — condition value type is incompatible with the target field’s locked DataType, e.g. an operand that parses into no temporal form on a temporal meta field (creationDate/lastUpdateTime); a string or pattern operator on one of those fields is INVALID_CONDITION instead, see LifecycleCondition above
  • errors.INVALID_CONDITION400 — a condition fails a structural or shape check rather than a path or type check: an unknown or missing operatorType, a null/object/complex operand on a binary or range operator, a malformed LIKE/MATCHES_PATTERN operand, a string or pattern operator on a temporal meta field, an array clause on a bare path or with a badly-shaped values entry, or a function clause at any depth (criteria only — see predicates)
  • errors.BAD_REQUEST400 — malformed condition JSON, invalid limit/pageSize/pageNumber, result retrieval on non-SUCCESSFUL job, unknown async job ID in result retrieval
  • errors.SERVER_ERROR500 — the target model’s schema could not be loaded or parsed, so the condition could not be checked against it. The request fails with a ticket id and no result set rather than skipping validation: without declared types, comparison and ordering leaves match nothing, so the answer would be a short page indistinguishable from a complete one. HTTP and gRPC fail alike — over gRPC it is an envelope error, never an empty stream. A condition built only of lifecycle clauses needs no schema and is unaffected

Synchronous search — match by field value:

curl -s -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"simple","jsonPath":"$.category","operatorType":"EQUALS","value":"physics"}' \
"http://localhost:8080/api/search/direct/nobel-prize/1"

Synchronous search — match by lifecycle state:

curl -s -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"lifecycle","field":"state","operatorType":"EQUALS","value":"APPROVED"}' \
"http://localhost:8080/api/search/direct/nobel-prize/1"

Synchronous search — AND group:

curl -s -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": "group",
"operator": "AND",
"conditions": [
{"type":"simple","jsonPath":"$.year","operatorType":"EQUALS","value":"2024"},
{"type":"lifecycle","field":"state","operatorType":"EQUALS","value":"NEW"}
]
}' \
"http://localhost:8080/api/search/direct/nobel-prize/1"

Synchronous search at point in time with limit:

curl -s -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"group","operator":"AND","conditions":[]}' \
"http://localhost:8080/api/search/direct/nobel-prize/1?pointInTime=2025-08-01T00:00:00Z&limit=100"

Submit async search:

JOB_ID=$(curl -s -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"simple","jsonPath":"$.year","operatorType":"EQUALS","value":"2024"}' \
"http://localhost:8080/api/search/async/nobel-prize/1" | tr -d '"')

Poll async job status:

curl -s -H "Authorization: Bearer $TOKEN" \
"http://localhost:8080/api/search/async/$JOB_ID/status"

Retrieve async results (page 0):

curl -s -H "Authorization: Bearer $TOKEN" \
"http://localhost:8080/api/search/async/$JOB_ID?pageNumber=0&pageSize=500"

Cancel an async job:

curl -s -X PUT \
-H "Authorization: Bearer $TOKEN" \
"http://localhost:8080/api/search/async/$JOB_ID/cancel"
  • crud
  • models
  • analytics
  • predicates
  • workflows
  • errors.MODEL_NOT_FOUND
  • errors.SEARCH_JOB_NOT_FOUND
  • errors.SEARCH_JOB_ALREADY_TERMINAL
  • errors.SEARCH_RESULT_LIMIT
  • errors.SEARCH_SHARD_TIMEOUT
  • errors.SEARCH_QUEUE_FULL
  • errors.INVALID_FIELD_PATH
  • errors.CONDITION_TYPE_MISMATCH
  • errors.INVALID_CONDITION
  • openapi
  • cyoda help crud — Entities are instances of models. Each entity has a UUID, a model reference (entityName, modelVersion), and a lifecycle state managed by the workflow engine. Creating an entity requires the referenced model to be in LOCKED state. All write operations run within a Cyoda transaction and return a transactionId alongside the affected entity IDs.
  • cyoda help models — A model is a named, versioned schema registered per tenant. Every entity in the system is an instance of exactly one model. Models are identified by (entityName, modelVersion). The model ID is a deterministic UUID v5 derived from that key: UUID.newSHA1(NameSpaceURL, "{entityName}.{modelVersion}").
  • cyoda help analytics — Cyoda Cloud exposes entity data as Trino SQL tables through a Trino connector. The connector uses the Schema Management REST API to discover table definitions and the WebSocket (STOMP) messaging API to stream entity rows at query time.
  • cyoda help errors MODEL_NOT_FOUND — The entity type or model name specified in the request does not exist in the tenant’s model registry. Occurs on write paths (creating entities with an unknown type, importing data that references a missing model, performing model lifecycle transitions on a model ID that does not exist) and on read paths (list, stats, grouped-stats, and search operations that reference an unregistered model).
  • cyoda help errors SEARCH_JOB_NOT_FOUND — Polling a search job by ID returns this error when the job ID is unknown or belongs to a different tenant. Jobs are tenant-scoped; a valid job ID from one tenant is not visible to another.
  • cyoda help errors SEARCH_JOB_ALREADY_TERMINAL — Search jobs are long-running asynchronous operations. Once a job reaches a terminal state it cannot be cancelled, resumed, or otherwise modified. This error is returned when such an operation is attempted on a finished job — PUT /search/async/{jobId}/cancel is the only endpoint that raises it.
  • cyoda help errors SEARCH_RESULT_LIMIT — Direct (synchronous) search is bounded-or-fail: limit caps the matched result set rather than paging it. When more entities match than the limit allows, the request is rejected — it never returns a truncated prefix, because a partial result would be indistinguishable from a complete one.
  • cyoda help errors SEARCH_SHARD_TIMEOUT — Distributed search fans out to multiple shards in parallel. If any shard does not return results before the search timeout expires, the job is marked failed and this error is returned. Occurs under high load, during partial cluster degradation, or with expensive queries.
  • cyoda help errors SEARCH_QUEUE_FULLPOST /api/search/async/{entityName}/{modelVersion} runs on a bounded worker pool: a fixed number of workers drain a fixed-capacity queue. Submission fails fast with this error rather than blocking the request or spawning an unbounded goroutine per submission. The rejected submit leaves nothing behind — no job row, no id to poll.
  • cyoda help errors INVALID_FIELD_PATH — Three checks emit this code.
  • cyoda help errors CONDITION_TYPE_MISMATCH — Validation is parse-based: a comparison or range operand is rejected only when it parses into none of the field’s declared DataTypes. For example "abc" against a DOUBLE field is rejected — it is not a number. A numeric-looking string against a polymorphic [INTEGER, STRING] field is accepted (it parses as STRING).
  • cyoda help errors INVALID_CONDITION — Endpoints that accept a search-style condition in the request body — sync and async search, grouped statistics, and the conditional form of delete-by-model — reject a body whose condition cannot be parsed or is otherwise structurally invalid. The condition type is unrecognised, a nested clause is malformed, the JSON does not match the expected condition envelope, an operatorType is not one of the canonical operators, a LIKE or MATCHES_PATTERN operand is not a valid pattern, a BETWEEN/BETWEEN_INCLUSIVE operator’s value is not a two-element array, a group clause’s operator is not AND, OR, or NOT, or a NOT group’s conditions does not hold exactly one entry (zero, or two or more).
  • cyoda help predicates — predicates — operator catalog and evaluation semantics for the Condition DSL used by search (cyoda help search) and workflow/transition criteria (cyoda help workflows). Both consume the same kernel, so everything here applies identically to both.
  • cyoda help workflows — A workflow definition is a named finite state machine attached to an entity model. Workflows are stored per model reference (entityName, modelVersion). A model may have multiple workflow definitions; the engine selects the matching one per entity using the workflow-level criterion field evaluated at entity creation time. When no criterion matches, the engine uses the default built-in workflow.
  • cyoda help openapi — cyoda-go generates its OpenAPI 3.1 specification from the embedded api/openapi.yaml file compiled into the binary at build time. The spec is served at /openapi.json with runtime-patched server URLs. The Scalar API Reference UI is served at /docs and loads the spec from /openapi.json.