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 smartQueries → savedQueries 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, hitsPOST /records/queryclient.records.list(resource, urlOpts?)— explicit GET adapter, kept for the URL-param flavourclient.queryRecords(resource, definition)— top-level convenience forrecords.query; still accepts the legacy URL-param object too, with a one-shot deprecation warnclient.savedQueries.*— canonical saved-query namespaceclient.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 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.
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¶
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.
MCP-specific gotchas
includeDeleted: true— throws. Phase 1 doesn't support it. Drop the arg.includeTotal— silently dropped. Total counts are returned viameta.totalwhen 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_queriescreate_smart_query,update_smart_query,delete_smart_queryexecute_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.
Recommended migration order¶
- Find every
client.smartQueries— rename toclient.savedQueries. Done. - For every
centrali.queryRecords(slug, urlOpts)with bracket-suffix filters, switch toclient.records.query(resource, def)with canonicalwhere/sort/page. - Adopt the canonical
${var}placeholder syntax wherever you author saved queries. ({{var}}still parses but is in the deprecation window.) - 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.