Query Records¶
Querying is one of the core Centrali workflows. The important model is simple:
- Use
GET /records/slug/{recordSlug}for simple, bookmarkable list views - Use
POST /records/queryfor richer queries - Use the same operator vocabulary everywhere:
eq,gte,contains,in,and,or,not - Expect the same result envelope from canonical query surfaces:
{ data, meta }
If you learn the canonical query shape once, you can reuse it across the SDK, compute functions, saved queries, and records query endpoints.
Choose the Right Surface¶
| Need | Start Here | Why |
|---|---|---|
| Flat filters, sorting, pagination, bookmarkable URLs | GET /records/slug/{recordSlug} | Great for simple collection views and HTTP clients |
| Boolean trees, projections, text clauses, relation includes | POST /records/query | Full canonical QueryDefinition body |
| JavaScript or TypeScript app code | centrali.records.query() or centrali.queryRecords() | Same canonical body, less HTTP boilerplate |
| Compute functions | api.queryRecords() | Same canonical body inside the sandbox |
| Reusable named queries | Saved Queries | Store a canonical query and execute it with variables |
The Canonical Query Shape¶
Every rich query starts from the same shape:
{
"resource": "orders",
"where": {
"and": [
{ "data.status": { "eq": "paid" } },
{
"or": [
{ "data.amount": { "gte": 100 } },
{ "data.priority": { "eq": "high" } }
]
}
]
},
"text": {
"query": "urgent shipping",
"fields": ["data.notes"]
},
"sort": [
{ "field": "createdAt", "direction": "desc" }
],
"page": {
"limit": 25,
"offset": 0
},
"select": {
"fields": ["id", "data.status", "data.amount", "createdAt"]
},
"include": [
{ "relation": "customerId" }
]
}
Vocabulary¶
- Resource: the collection slug you are querying, such as
ordersorcustomers - Field paths: use dotted paths like
data.status,data.customer.email,createdAt - Comparison operators:
eq,ne,gt,gte,lt,lte - List operators:
in,nin - String operators:
contains,startsWith,endsWith - Array operators:
hasAny,hasAll - Existence:
exists - Boolean tree:
and,or,not
Result Envelope¶
Canonical query surfaces return:
{
"data": [],
"meta": {
"limit": 25,
"offset": 0,
"hasMore": false,
"processingTimeMs": 4,
"mode": "filter"
}
}
meta.total is opt-in on POST /records/query. Set page.withTotal: true when you actually render a total count. Otherwise the backend skips the extra count(*) query and still returns an accurate hasMore.
meta may also include cursor fields on cursor-based pagination.
Same Query Model on IAM Resources¶
The canonical query shape is no longer records-only. On the auth host, 6.1 adds POST .../query routes that accept the same resource, where, sort, page, select, and include model.
| Resource family | Endpoint |
|---|---|
| Users | POST https://auth.centrali.io/workspace/{workspace}/api/v1/users/query |
| User attributes | POST https://auth.centrali.io/workspace/{workspace}/api/v1/users/attributes/query |
| User roles | POST https://auth.centrali.io/workspace/{workspace}/api/v1/user-roles/query |
| User groups | POST https://auth.centrali.io/workspace/{workspace}/api/v1/user-groups/query |
| Resources | POST https://auth.centrali.io/workspace/{workspace}/api/v1/access/resources/query |
| Permissions | POST https://auth.centrali.io/workspace/{workspace}/api/v1/access/permissions/query |
| Policies | POST https://auth.centrali.io/workspace/{workspace}/api/v1/access/policies/query |
| Roles | POST https://auth.centrali.io/workspace/{workspace}/api/v1/roles/query |
| Groups | POST https://auth.centrali.io/workspace/{workspace}/api/v1/groups/query |
| OAuth clients | POST https://auth.centrali.io/workspace/{workspace}/api/v1/oauth-clients/query |
| Service account roles | POST https://auth.centrali.io/workspace/{workspace}/api/v1/service-account-roles/query |
Notes:
usersqueries are tenancy-scoped through workspace membership, so cross-workspace users are filtered correctly.- Joined assignment surfaces such as
user-roles,user-groups, andservice-account-rolessupportincludefor the related side of the relationship. - The legacy
GETlist endpoints for these IAM resources still exist during the migration window, but new integrations should prefer the canonical query routes.
For the IAM route map and auth notes, see Authorization & Policies.
Simple HTTP Queries With GET¶
Use the list route when a query fits naturally in URL parameters:
Common Parameters¶
| Parameter | Example | Notes |
|---|---|---|
search | ?search=urgent | Simple text lookup on the list surface |
searchFields | ?searchFields=data.name,data.description | Comma-separated fields |
sort | ?sort=-createdAt,data.priority | Prefix - for descending |
page | ?page=2 | 1-indexed page number |
pageSize | ?pageSize=25 | Default 50, max 500 |
fields | ?fields=id,data.status,createdAt | Project a smaller payload |
expand | ?expand=customerId | Expand reference fields on the list route |
all | ?all=true | Include soft-deleted records |
| Field filters | ?data.status=paid | Exact match |
| Operator filters | ?data.amount[gte]=100 | Bracket form |
Examples¶
# Paid orders, newest first
curl "https://api.centrali.io/data/workspace/acme/api/v1/records/slug/orders?data.status=paid&sort=-createdAt&pageSize=25" \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
# Search products by name and description
curl "https://api.centrali.io/data/workspace/acme/api/v1/records/slug/products?search=laptop&searchFields=data.name,data.description" \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
# Active customers created this year
curl "https://api.centrali.io/data/workspace/acme/api/v1/records/slug/customers?data.status=active&createdAt[gte]=2026-01-01T00:00:00Z" \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
Use GET when your filters are effectively one flat AND. If you need nested or / not, a text clause, or an include clause, move to POST /records/query.
Rich HTTP Queries With POST¶
Use the canonical query endpoint for richer logic:
Example¶
curl -X POST "https://api.centrali.io/data/workspace/acme/api/v1/records/query" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"resource": "orders",
"where": {
"and": [
{ "data.status": { "eq": "paid" } },
{
"or": [
{ "data.amount": { "gte": 100 } },
{ "data.priority": { "eq": "high" } }
]
}
]
},
"sort": [
{ "field": "createdAt", "direction": "desc" }
],
"page": {
"limit": 25,
"withTotal": true
},
"select": {
"fields": ["id", "data.status", "data.amount", "createdAt"]
}
}'
Count Only When You Need It¶
page.withTotal defaults to false on POST /records/query.
- Omit it when you only need the current page and
hasMore - Set it to
truewhen you render numbered pagination or a visible total - When
withTotalis omitted, the backend deriveshasMorewith alimit + 1probe instead of acount(*)
Example:
GET /records/slug/{recordSlug} keeps its legacy default of returning totals unless you opt out on that surface.
Statement-Timeout Guardrail¶
Ad-hoc POST /records/query requests run under a server-side statement timeout so an unbounded JSONB filter cannot block the query pool indefinitely.
- Default timeout: 5000 ms
- Environment override:
RECORDS_QUERY_STATEMENT_TIMEOUT_MS - Timeout response: HTTP
504with error codeGATEWAY_TIMEOUT
Typical timeout response:
{
"error": "GATEWAY_TIMEOUT",
"message": "Query exceeded the allowed execution time. Narrow the filter or use a saved query for predictable performance.",
"status": 504
}
Text Search in the Canonical Body¶
Use the text clause when you want full-text search as part of the same query:
{
"resource": "products",
"text": {
"query": "wireless headphones",
"fields": ["data.name", "data.description"]
},
"where": {
"data.inStock": { "eq": true }
},
"sort": [
{ "field": "createdAt", "direction": "desc" }
],
"page": {
"limit": 10
}
}
Include Reference Data¶
Use include to expand reference relations in canonical queries:
{
"resource": "comments",
"where": {
"data.postId": { "eq": "rec_post_123" }
},
"include": [
{ "relation": "postId" }
],
"page": {
"limit": 20
}
}
Filter and project on the included relation¶
Each include entry can carry its own where and select. They operate on the target collection's namespace — fields resolve against the included relation's own properties, not the parent's.
{
"resource": "comments",
"include": [
{
"relation": "postId",
"where": { "data.published": { "eq": true } },
"select": { "fields": ["id", "data.title", "data.slug"] }
}
],
"page": { "limit": 20 }
}
wherenarrows the batched fetch — parents still return, but unmatched singular relations attach asnulland unmatched many-to-many relations attach as[].select.fieldstrims the attached row post-fetch. Use it to keep payloads small when the parent only needs a few related fields.- If the top-level query also uses
select.fields, each included relation must be covered by that projection path (for exampledata.postId) or the query is rejected.
Nested includes¶
include entries can nest. Each level is a fresh hop, and children inherit nothing from the parent's where or select. If a parent include has its own select.fields, any nested relation must still be covered by that local projection (for example data.authorId) or the query is rejected.
{
"resource": "comments",
"include": [
{
"relation": "postId",
"select": { "fields": ["id", "data.title", "data.authorId"] },
"include": [
{
"relation": "authorId",
"select": { "fields": ["id", "data.name"] }
}
]
}
],
"page": { "limit": 20 }
}
Default include depth is 3. Operators can change this via the DATA_QUERY_INCLUDE_MAX_DEPTH environment variable on the data service. Cycles and depth overflow are rejected as unsupported_clause (UNSUPPORTED_CLAUSE); missing relations or uncovered projection paths are rejected as invalid_query (INVALID_QUERY).
SDK Usage¶
For new SDK code, start with the records namespace:
| Method | Use when |
|---|---|
centrali.records.query(resource, definition) | Canonical rich query |
centrali.records.list(resource, urlOptions) | Simple GET-style query |
centrali.records.search(resource, text, options?) | Text-search convenience |
centrali.records.test(resource, definition) | Validate a query before saving or shipping it |
centrali.queryRecords(resource, definition) | Top-level convenience for canonical queries |
Canonical SDK Example¶
const orders = await centrali.records.query('orders', {
where: {
and: [
{ 'data.status': { eq: 'paid' } },
{ 'data.amount': { gte: 100 } }
]
},
sort: [{ field: 'createdAt', direction: 'desc' }],
page: { limit: 25 },
select: { fields: ['id', 'data.amount', 'data.status'] }
});
console.log(orders.data);
console.log(orders.meta.hasMore);
SDK Text Search Example¶
const matches = await centrali.records.search('products', 'wireless headphones', {
where: {
'data.inStock': { eq: true }
},
page: { limit: 10 }
});
Compute Functions¶
Compute functions use the same canonical body shape through api.queryRecords():
async function run() {
const result = await api.queryRecords('orders', {
where: {
and: [
{ 'data.status': { eq: 'pending' } },
{ 'data.retryCount': { lt: 3 } }
]
},
sort: [{ field: 'createdAt', direction: 'asc' }],
page: { limit: 100 }
});
api.log({ message: 'Pending orders', count: result.data.length });
return {
success: true,
orders: result.data
};
}
Practical Guidance¶
- Use
GET /records/slug/{recordSlug}for dashboards, list pages, and simple filters. - Use
POST /records/querywhen you want logic you would otherwise build in application code. - Keep filters in the query, not in post-processing code.
- Use
select.fieldswhen you do not need the full record payload. - Use
textfor discovery andwherefor business rules. - Use
includewhen you need referenced data in the same query result.