Skip to content

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/query for 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 orders or customers
  • 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:

  • users queries are tenancy-scoped through workspace membership, so cross-workspace users are filtered correctly.
  • Joined assignment surfaces such as user-roles, user-groups, and service-account-roles support include for the related side of the relationship.
  • The legacy GET list 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:

GET /data/workspace/{workspaceSlug}/api/v1/records/slug/{recordSlug}

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:

POST /data/workspace/{workspaceSlug}/api/v1/records/query

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 true when you render numbered pagination or a visible total
  • When withTotal is omitted, the backend derives hasMore with a limit + 1 probe instead of a count(*)

Example:

{
  "resource": "orders",
  "page": {
    "limit": 25,
    "offset": 0,
    "withTotal": true
  }
}

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 504 with error code GATEWAY_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 }
}
  • where narrows the batched fetch — parents still return, but unmatched singular relations attach as null and unmatched many-to-many relations attach as [].
  • select.fields trims 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 example data.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/query when you want logic you would otherwise build in application code.
  • Keep filters in the query, not in post-processing code.
  • Use select.fields when you do not need the full record payload.
  • Use text for discovery and where for business rules.
  • Use include when you need referenced data in the same query result.