Skip to content

Query foundation: from legacy filters to canonical queries

A single query language for records, saved queries, MCP, and HTTP — what changed for SDK users, raw HTTP users, and MCP clients when canonical queries first shipped in 5.5.1.

Historical context

This guide describes the 5.5.1 cut. If you're running 5.5.x and upgrading to 6.0, read Upgrading to 6.0 — it covers every change since 5.5.1 and supersedes the "not yet shipped" callouts you'll see further down. The rest of this guide remains useful as a reference for the canonical shape itself, the operator vocabulary, and the SDK / HTTP / MCP migration paths.

Sunset of legacy URL-style filtering: 2026-10-28 for saved-queries / smart-queries · 2026-09-10 for the legacy GET /records/:slug structures path.

TL;DR — what to do today

Safe to migrate now

Ad-hoc record queries (HTTP POST /records/query, SDK client.records.query, MCP query_records canonical body) and the smartQueriessavedQueries namespace rename.

Wait for the 6.0 cut

The 5.5.1 callouts about typed variables and ${var} substitution are obsolete — those shipped in 6.0. See the 6.0 guide. The include clause expansion also shipped in 6.0 (CEN-1192/CEN-1219).

Nothing breaks for clients that change nothing today. Legacy paths still work; you'll just see a one-shot console.warn from the SDK, and a Sunset HTTP header on responses. Both are advisory.

What's changing (concepts)

Centrali used to expose three different query "shapes" — flat URL-param filters on GET /records, a custom MongoDB-ish syntax on /smart-queries, and yet another flavour inside compute functions. Each surface drifted independently and operators silently disagreed (the trigger for this work was CEN-1110: the visual builder offered $like, which the engine never supported, so saved filters silently returned everything).

The new canonical QueryDefinition is a single typed shape every queryable surface now speaks:

type QueryDefinition = {
  resource: string;          // collection slug, e.g. "orders"
  where?:   WhereExpression; // { field: { eq: value } }, plus and/or/not
  text?:    { query: string; fields?: string[]; typoTolerance?: boolean };
  sort?:    { field: string; direction: 'asc' | 'desc' }[];
  page?:    { limit: number; offset?: number } | { limit: number; cursor?: string };
  select?:  { fields: string[] };
  include?: { relation: string }[];   // expansion shipped in 6.0
};

Operators are bare names — no $ prefix. There is exactly one operator per FieldCondition:

"where": {
  "data.status":    { "eq": "active" },
  "data.feltCount": { "gte": 1 },
  "data.tags":      { "hasAny": ["featured", "trending"] }
}

Full operator vocabulary (single source of truth: services/backend/shared/query/src/types.ts):

Operator Means Applies to
eq / ne equals / not equals any
gt / gte / lt / lte numeric & ISO-datetime ranges number, datetime
in / nin scalar in / not in array string, number, datetime
contains / startsWith / endsWith substring matching string
hasAny / hasAll array intersection / superset array fields
exists field present (boolean) any

Two namespace renames for saved queries:

  • HTTP: /smart-queries/saved-queries (both mounted)
  • SDK: client.smartQueries.*client.savedQueries.* (both work)
  • MCP: tool names unchanged (get_smart_query, execute_smart_query, …) — kept on purpose for back-compat. Underlying transport routes to /saved-queries.

SDK migration

The Centrali SDK keeps both the legacy and canonical surfaces working. Migration is opt-in. The SDK exposes:

  • client.records.query(resource, definition) — canonical, hits POST /records/query
  • client.records.list(resource, urlOpts?) — explicit GET adapter, kept for the URL-param flavour
  • client.queryRecords(resource, definition) — top-level convenience for records.query; still accepts the legacy URL-param object too, with a one-shot deprecation warn
  • client.savedQueries.* — canonical saved-query namespace
  • client.smartQueries.* — preserved alias, also one-shot warns

Legacy URL-param deprecation

The first time you call client.queryRecords(slug, urlOpts) with the legacy URL-param object on a process, the SDK logs:

[centrali-sdk] client.queryRecords(slug, urlOpts) (legacy URL-param form) is deprecated — pass a canonical QueryDefinition ({ resource, where, sort, page, … }) for POST /records/query, or use client.records.list(resource, urlOpts) for the GET adapter explicitly.

Filter syntax: bracket-suffix → canonical operators

Legacy URL-param form Canonical equivalent
"data.status": "active" "data.status": { eq: "active" }
"data.field[gte]": v "data.field": { gte: v }
"data.field[lte]": v "data.field": { lte: v }
"data.field[gt]": v "data.field": { gt: v }
"data.field[lt]": v "data.field": { lt: v }
"data.field[startswith]": s "data.field": { startsWith: s }
"data.field[contains]": s "data.field": { contains: s }
"data.field[in]": [a,b] "data.field": { in: [a, b] }
sort: "-data.createdAt" sort: [{ field: "createdAt", direction: "desc" }]
pageSize: 20 page: { limit: 20 }
expand: "userId" include: [{ relation: "userId" }]

Real before / after — pulled from a live SDK consumer

A common pattern: list public, active posts for a prompt, sorted by recency.

const result = await centrali.queryRecords("posts", {
  "data.status":         "active",
  "data.visibility":     "public",
  "data.promptId":       promptId,
  "data.feltCount[gte]": 1,
  sort:     "-data.feltCount",
  pageSize: 5,
  expand:   "userId",
});
const result = await client.records.query("posts", {
  resource: "posts",
  where: {
    and: [
      { "data.status":     { eq: "active" } },
      { "data.visibility": { eq: "public" } },
      { "data.promptId":   { eq: promptId } },
      { "data.feltCount":  { gte: 1 } },
    ],
  },
  sort: [{ field: "data.feltCount", direction: "desc" }],
  page: { limit: 5 },
  include: [{ relation: "userId" }],
});

Object-shorthand AND — passing a flat FieldConditionMap like { "data.status": {eq:"active"}, "data.visibility": {eq:"public"} } — is also valid; the explicit and:[…] form just makes intent obvious.

Saved queries: namespace rename

If your code uses client.smartQueries.* the migration is purely a search-and-replace.

const query = await client.smartQueries
  .getByName("product-options", "getLowStockItems");

const result = await client.smartQueries
  .execute("product-options", query.data.id, {
    variables: { merchantId, threshold: String(threshold) },
  });
const query = await client.savedQueries
  .getByName("product-options", "getLowStockItems");

const result = await client.savedQueries
  .execute("product-options", query.data.id, {
    variables: { merchantId, threshold: String(threshold) },
  });

All method names are identical: list, listAll, get, getByName, execute, create, update, delete, test.

Raw HTTP migration

If you call the API directly (no SDK), the migration is the same shape, just over the wire.

Records: GET filter → POST query

GET /workspace/<ws>/api/v1/records/posts
    ?data.status=active
    &data.feltCount[gte]=1
    &sort=-data.createdAt
    &pageSize=20
POST /workspace/<ws>/api/v1/records/query

{
  "resource": "posts",
  "where": {
    "data.status":    { "eq": "active" },
    "data.feltCount": { "gte": 1 }
  },
  "sort": [{ "field": "data.createdAt", "direction": "desc" }],
  "page": { "limit": 20 }
}

Both endpoints are mounted today; either works. Responses use the canonical envelope { data: T[], meta: { limit, offset?, total?, hasMore?, … } }.

Saved queries: path rename

Legacy path (Sunset 2026-10-28) Canonical path
GET /smart-queries/:resource GET /saved-queries/:resource
POST /smart-queries/:resource/:id/execute POST /saved-queries/:resource/:id/execute
POST /smart-queries/:resource/test POST /saved-queries/:resource/test

Both mounts hit the same controllers, so behaviour is identical. The legacy mount returns these RFC 8594 advisory headers:

Deprecation: true
Sunset: Wed, 28 Oct 2026 GMT
Link: <https://docs.centrali.io/migrations/>; rel="successor-version"

You can ignore them; well-behaved monitors will surface them on a dashboard.

MCP migration

Tool names haven't changed — query_records, get_smart_query, execute_smart_query, etc. all still exist on the hosted MCP server. What changed is the argument shape of query_records.

query_records

The 5.5.x server accepts the canonical QueryDefinition directly. A built-in translator keeps the documented 5.4.0 argument shape working — it logs a one-shot deprecation warning to the MCP client console.

{
  "recordSlug": "orders",
  "filter": {
    "data.status": "open",
    "data.total[gte]": 100
  },
  "sort": "-createdAt",
  "page": 1,
  "pageSize": 20
}
{
  "resource": "orders",
  "where": {
    "data.status": { "eq": "open" },
    "data.total":  { "gte": 100 }
  },
  "sort": [{ "field": "createdAt", "direction": "desc" }],
  "page": { "limit": 20 }
}

MCP-specific gotchas

  • includeDeleted: truethrows. Phase 1 doesn't support it. Drop the arg.
  • includeTotalsilently dropped. Total counts are returned via meta.total when the engine produces them; you can't force it.
  • The translator only covers the documented 5.4.0 shape. Older / undocumented shapes (sort-as-array, raw filter dicts) won't translate cleanly.

Saved-query MCP tools

Tool names retain the _smart_query suffix on purpose — your existing MCP integrations don't need to be re-registered. Underneath, every tool now routes to the SDK's client.savedQueries.* and the canonical /saved-queries HTTP path.

  • get_smart_query, list_smart_queries
  • create_smart_query, update_smart_query, delete_smart_query
  • execute_smart_query, test_smart_query

The bodies these tools accept and return are the canonical shapes. If you were embedding raw $eq / $gte in create_smart_query definitions, switch to { eq: … } / { gte: … }.

Does the Centrali console need to migrate?

Two possible meanings — answering both.

The Centrali admin console (our React UI)

Already migrated. The console talks to /saved-queries; the visual builder + RecordFilterBuilder source operators from @centrali/query's OPERATOR_METADATA, so the dropdown and the engine can't drift again. A normalize pass rewrites legacy bodies on load; a destructive banner names saved queries that fail translation so a human can rewrite (covers the long-tail $regex, $type, multi-collection join).

Nothing for you to do here — but if you maintain a fork of the console, you'll want to pull these changes.

Your own admin / back-office UIs that hit our HTTP API

Yes — same migration as the SDK section. If your back-office is built on raw fetch against /records?… or /smart-queries/…, follow the Raw HTTP migration path. The Sunset header is your alarm clock.

  1. Find every client.smartQueries — rename to client.savedQueries. Done.
  2. For every centrali.queryRecords(slug, urlOpts) with bracket-suffix filters, switch to client.records.query(resource, def) with canonical where / sort / page.
  3. Adopt the canonical ${var} placeholder syntax wherever you author saved queries. ({{var}} still parses but is in the deprecation window.)
  4. If you need multi-join saved queries, OUTER joins, or typed parameter validation, you're already on 6.0 — see the 6.0 guide.

Source of truth: docs/superpowers/specs/2026-04-25-query-foundation-contract.md · types: services/backend/shared/query/src/types.ts · SDK: services/utils/centrali-sdk/src/ · MCP: services/utils/centrali-mcp/src/tools/records.ts.