Best for
- You are constructing a filter expression for record retrieval
- You need to sort or paginate query results
- You are writing aggregation queries (count, sum, avg, group by)
objectstack-ai/objectstack/skills/objectstack-query/SKILL.md
Construct ObjectQL queries — filters, sorting, pagination, aggregation, relation expansion, and full-text search. Use when the user is writing a query DSL expression, picking pagination strategy, or designing a list view's filter spec. Do not use for defining objects / fields / relationships (see objectstack-data) or for designing the API endpoint that exposes a query (see objectstack-api).
Decision brief
Expert instructions for constructing data queries using the ObjectStack Query DSL. This skill covers filter expressions, sorting, pagination, aggregation, full-text search, and the expand system for related records.
Compatibility matrix
| Platform | Status | Evidence | What to check |
|---|---|---|---|
| Codex | Not declared | No explicit evidence | Portability before use |
| Claude Code | Not declared | No explicit evidence | Portability before use |
| Cursor | Not declared | No explicit evidence | Portability before use |
| Gemini CLI | Not declared | No explicit evidence | Portability before use |
Installation
The source command is displayed only when detected. A safe inspection prompt is always available so your agent can explain every action before execution.
npx skills add https://github.com/objectstack-ai/objectstack --skill "skills/objectstack-query"Inspect the Agent Skill "objectstack-query" from https://github.com/objectstack-ai/objectstack/blob/2cc71222459e91964e883419611a820c28302429/skills/objectstack-query/SKILL.md at commit 2cc71222459e91964e883419611a820c28302429. List every install step, command, network request, credential, file read/write, external action, and rollback step. Explain whether it fits my task. Do not install or execute anything until I approve.
Workflow
Review the “Skill Boundaries” section in the pinned source before continuing.
You are constructing a filter expression for record retrieval
Every ObjectStack query follows the QuerySchema structure:
Every ObjectStack query follows the QuerySchema structure:
For comprehensive documentation with incorrect/correct examples:
Permission review
No configured static risk pattern was detected
This is not proof of safety. Runtime behavior, indirect dependencies, and hidden external systems are outside the static scan.
Evidence record
| Signal | Value | Evidence type | Meaning |
|---|---|---|---|
| Quality score | 94/100 | Computed | Documentation, specificity, maintenance, and trust rules |
| Repository stars | 39 | Source | Repository attention, not individual Skill quality |
| Compatibility | 0 platforms | Source | Declared in the catalog source record |
| Usage guide | automated source guide | Editorial | Generated or reviewed according to the visible evidence level |
Pinned source
Expert instructions for constructing data queries using the ObjectStack Query DSL. This skill covers filter expressions, sorting, pagination, aggregation, full-text search, and the expand system for related records.
Schema vs. runtime: the QueryAST schema declares more than the engine
currently executes. Sections below marked
⚠️ Schema-reserved — NOT executed by the engine yet.
describe properties that validate against the schema but are silently ignored (or rejected) at runtime. Never emit them in production queries — each caveat shows the working alternative.
| Need | Use instead |
|---|---|
| Define objects, fields, or relationships | objectstack-data |
| Define REST API endpoints or auth | objectstack-api |
| Build views, dashboards, or apps | objectstack-ui |
| Create a plugin or register services | objectstack-platform |
Every ObjectStack query follows the QuerySchema structure:
{
object: 'account', // Target object (required)
fields: ['name', 'email'], // SELECT — fields to retrieve
where: { status: 'active' }, // WHERE — filter conditions
orderBy: [{ field: 'created_at', order: 'desc' }], // ORDER BY
limit: 20, // LIMIT — max records
offset: 0, // OFFSET — skip records
}
Key rule: object is the only required property. Everything else is optional.
For comprehensive documentation with incorrect/correct examples:
ObjectStack uses a declarative, database-agnostic filter DSL inspired by Prisma, Strapi, and MongoDB.
The simplest filter — field equals value:
{ where: { status: 'active' } }
// SQL: WHERE status = 'active'
| Operator | Purpose | SQL Equivalent | Types |
|---|---|---|---|
$eq | Equal | = | Any |
$ne | Not equal | <> | Any |
$gt | Greater than | > | Number, Date |
$gte | Greater than or equal | >= | Number, Date |
$lt | Less than | < | Number, Date |
$lte | Less than or equal | <= | Number, Date |
{ where: { age: { $gte: 18 } } }
// SQL: WHERE age >= 18
{ where: { created_at: { $gt: '2025-01-01' } } }
// SQL: WHERE created_at > '2025-01-01'
| Operator | Purpose | SQL Equivalent |
|---|---|---|
$in | In list | IN (...) |
$nin | Not in list | NOT IN (...) |
$between | Inclusive range | BETWEEN ? AND ? |
{ where: { status: { $in: ['active', 'pending'] } } }
// SQL: WHERE status IN ('active', 'pending')
{ where: { amount: { $between: [100, 500] } } }
// SQL: WHERE amount BETWEEN 100 AND 500
| Operator | Purpose | SQL Equivalent |
|---|---|---|
$contains | Contains substring | LIKE '%?%' |
$notContains | Does not contain | NOT LIKE '%?%' |
$startsWith | Starts with prefix | LIKE '?%' |
$endsWith | Ends with suffix | LIKE '%?' |
{ where: { email: { $contains: '@company.com' } } }
// SQL: WHERE email LIKE '%@company.com%'
| Operator | Purpose | SQL / NoSQL |
|---|---|---|
$null | Is null check | IS NULL / IS NOT NULL |
$exists | Field exists (NoSQL) | MongoDB $exists |
{ where: { deleted_at: { $null: true } } }
// SQL: WHERE deleted_at IS NULL
Combine conditions with $and, $or, and $not:
// OR: active accounts OR accounts with high revenue
{
where: {
$or: [
{ status: 'active' },
{ revenue: { $gt: 1000000 } }
]
}
}
// AND + OR combined
{
where: {
$and: [
{ type: 'enterprise' },
{ $or: [
{ region: 'us' },
{ region: 'eu' }
]}
]
}
}
// NOT: exclude closed accounts
{
where: {
$not: { status: 'closed' }
}
}
Filter through relationships without an explicit join:
// Filter accounts where the related contact has a verified profile
{
object: 'account',
where: {
contact: { // Relation field name
profile: { // Nested relation
verified: true
}
}
}
}
⚠️ Schema-reserved — NOT executed by the engine yet.
$fieldexists only in the filter schema. No engine or driver code interprets it — the{ $field: '...' }object binds as a literal value, so the query silently returns zero rows. Do not use it.
// ❌ Schema-valid but NOT executed — matches nothing
{
where: {
actual_revenue: { $gt: { $field: 'estimated_revenue' } }
}
}
Working alternatives:
exceeds_estimate as a boolean), then filter on it:
{ where: { exceeds_estimate: true } } (see objectstack-data).Sort with orderBy — an array of sort nodes:
{
object: 'account',
orderBy: [
{ field: 'priority', order: 'desc' },
{ field: 'name', order: 'asc' }, // Secondary sort
]
}
Rules:
order is 'asc' — you can omit it for ascending sorts{
object: 'account',
limit: 20,
offset: 40, // Skip first 40 records (page 3)
}
When to use: UI pages, small datasets (<100K records), when you need "jump to page N".
Pitfall: Offset pagination degrades on large offsets — the database still scans skipped rows.
⛔
query.cursorwas REMOVED in@objectstack/spec17. No engine or driver ever read it — a query carryingcursorsilently returned page 1 forever. The key is tombstoned (a query carrying it fails to parse with the prescription) andQueryBuilder.cursor()is gone. Do keyset pagination withwhere+orderBy+limit:
// First page
{
object: 'account',
orderBy: [{ field: 'created_at', order: 'desc' }],
limit: 20,
}
// Next page — filter past the last record you've seen
{
object: 'account',
where: { created_at: { $lt: lastSeenCreatedAt } },
orderBy: [{ field: 'created_at', order: 'desc' }],
limit: 20,
}
When to use: Infinite scroll, APIs, large datasets, real-time feeds.
Rule: The keyset where field must match the orderBy field (use a
unique or near-unique column such as created_at or id) so
WHERE created_at < ? picks up exactly where the previous page ended.
top is an alias for limit (for OData-style APIs):
{ object: 'account', top: 50 }
// Equivalent to: { object: 'account', limit: 50 }
| Function | Purpose | SQL |
|---|---|---|
count | Count rows | COUNT(*) or COUNT(field) |
sum | Sum values | SUM(field) |
avg | Average | AVG(field) |
min | Minimum | MIN(field) |
max | Maximum | MAX(field) |
count_distinct | Unique count | COUNT(DISTINCT field) |
⚠️ Driver support varies. On SQL datasources the driver executes only
count/sum/avg/min/maxand throws oncount_distinct; the per-aggregationdistinct: trueflag is also ignored there. The in-memory fallback path (driver-rest, driver-memory, timezone/date-bucket fallbacks) supports all six functions plusdistinct. For portable queries, stick to the first five.
Removed in 17.
array_aggandstring_aggleft this vocabulary: declared but lowered by no SQL backend, so whether they worked depended on which driver sat behind the object. Either one is now refused at parse. There is no replacement — read the rows with an ordinaryfieldsquery and shape them in the caller, or materialise the roll-up as a stored field.
// Total revenue per region
{
object: 'deal',
fields: ['region'],
aggregations: [
{ function: 'sum', field: 'amount', alias: 'total_revenue' },
{ function: 'count', alias: 'deal_count' },
],
groupBy: ['region'],
orderBy: [{ field: 'total_revenue', order: 'desc' }],
}
// SQL: SELECT region, SUM(amount) AS total_revenue, COUNT(*) AS deal_count
// FROM deal GROUP BY region ORDER BY total_revenue DESC
groupBy entries can also be structured objects for date bucketing —
{ field: 'closed_at', dateGranularity: 'quarter' } — see
Aggregation rules for the full pattern.
✅ Enforced. The engine applies
havingAFTER aggregation, on both the native-driver path and the in-memory fallback. It references the aggregated row's columns — aggregation aliases and groupBy projections — with the ordinary FilterCondition operators plus$and/$or/$not. An unknown operator is rejected loudly, never ignored.
// ✅ Only regions with more than 100k revenue
const rows = await engine.aggregate('deal', {
groupBy: ['region'],
aggregations: [
{ function: 'sum', field: 'amount', alias: 'total_revenue' },
],
having: { total_revenue: { $gt: 100000 } },
});
⚠️ Per-aggregation
filteris schema-reserved — NOT executed by the engine yet. The SQL driver ignores it and the in-memory path ignores it too, so afilter-carrying aggregation returns the unfiltered number — silently wrong results. Working alternative: issue one aggregate call per condition, moving the condition into the query-levelwhere:
// ❌ filter on the aggregation is silently ignored
// { function: 'count', alias: 'high_value_orders',
// filter: { amount: { $gt: 1000 } } }
// ✅ Separate aggregate calls, condition in `where`
const [totals] = await engine.aggregate('order', {
aggregations: [{ function: 'count', alias: 'total_orders' }],
});
const [highValue] = await engine.aggregate('order', {
where: { amount: { $gt: 1000 } },
aggregations: [{ function: 'count', alias: 'high_value_orders' }],
});
Load related records through lookup/master_detail fields:
{
object: 'task',
fields: ['title', 'status'],
expand: {
assignee: {
object: 'user',
fields: ['name', 'email'],
},
project: {
object: 'project',
fields: ['name'],
expand: {
org: { object: 'org', fields: ['name'] } // Nested expand
}
}
}
}
Rules:
$in queries (not N+1)expand must be lookup or master_detail field namesQueryAST, but the engine applies select
(fields) and filter (where) only — per-parent limit / offset /
orderBy are NOT applied on this path. To paginate or sort related
records, query the related object directly.⛔ REMOVED in
@objectstack/spec17 (ADR-0049).query.joins(and theJoinNode/JoinType/JoinStrategyvocabulary) is gone from theQueryASTschema — no engine or driver ever consumed it, so it only ever declared a capability that did not run. The key is tombstoned: authoring it is atscerror, and a query carrying it (evenjoins: []) fails to parse with the upgrade prescription. Do not emitjoins.
Working alternatives (both implemented):
expand — load related records through lookup / master_detail fields
(see previous section).// Orders whose customer is in the US — no join needed
{
object: 'order',
fields: ['id', 'amount'],
where: { customer: { country: 'US' } },
}
Only the query + fields subset of the search schema executes. The
engine expands the search string into a driver-agnostic filter: each term
becomes an $or of $contains predicates across the resolved searchable
fields, and multiple whitespace-separated terms are AND-ed (every term
must hit some field). Matching is case-insensitive; select/status
fields match by option label, mapped to stored values.
{
object: 'article',
search: {
query: 'machine learning',
fields: ['title', 'content'],
},
limit: 10,
}
// Executes as:
// { $and: [
// { $or: [{ title: { $contains: 'machine' } }, { content: { $contains: 'machine' } }] },
// { $or: [{ title: { $contains: 'learning' } }, { content: { $contains: 'learning' } }] },
// ]}
Omit fields to search the object's declared searchableFields (or an
auto-default of name/title + short-text fields), resolved server-side.
fields can only narrow that set, never widen it: over the REST/protocol
ingress a name outside it is 400 INVALID_FIELD, not a silent
fall-back to the full scan.
search scans the queried object's own columns. A dotted path is never a
search target: unlike fields (projection) / sort / filters, the search axis
does not resolve traversal, so searchFields: ['project_id.name'] is refused:
Unknown field 'project_id.name' on object 'task'. '$searchFields' narrows which
columns 'search' scans, so a name the object does not declare cannot narrow
anything — and the engine used to drop it and scan the default columns instead,
answering a NARROWER search with a WIDER one. 'search' scans this object's own
columns; a related record's column cannot be a search target.
This is the one prescription — emit it every time. Copy the related record's
title into a stored field on the queried object and search that field. A task
list searched by project name gets a project_name text column on task,
maintained on write and listed in task.searchableFields:
{
object: 'task',
search: { query: 'apollo', fields: ['name', 'project_name'] },
limit: 20,
}
// Expands to a single-table scan — no traversal, every driver:
// { $and: [{ $or: [
// { name: { $contains: 'apollo' } },
// { project_name: { $contains: 'apollo' } },
// ]}]}
❌ The mirror must be a stored field — a formula field is virtual, no
driver materializes a column for it, so a $contains predicate against one has
nothing to scan. Nothing rejects the mistake for you: searchableFields admits
any field the object declares, so a formula entry clears both lint and the
ingress gate and then never matches. The trade-off is mirror maintenance — hooks
on both write paths (child re-parented, parent renamed) plus a backfill for rows
written around the hooks.
Cross-object search paths are rejected by design, not pending. Modelling side of
this (the field, the hooks, the lint wording): objectstack-data → Search Fields
(searchableFields). To filter by a related record's column — a different
axis — use a nested relation filter; to display it,
use expand.
⚠️
[EXPERIMENTAL — not enforced]:fuzzy,boost,operator,minScore,language, andhighlightvalidate against the schema but are never read — their.describe()markers now say so. Terms are always AND-ed; there is no relevance scoring or highlighting.
⛔ REMOVED from the request surface in
@objectstack/spec17.query.windowFunctionsis gone from theQueryASTschema — the engine never routed it to any driver, so every OVER clause it declared was silently dropped. The key is tombstoned (a query carrying it fails to parse with the prescription), and theWindowFunction/WindowSpec/WindowFunctionNodeexports left with it. Do not emitwindowFunctions. The one live door is the SQL driver's ownfindWithWindowFunctions()method (driver-level, its own flat input shape — and even there the builder drops thefieldargument, solag(revenue)renders asLAG()).
Working alternatives:
dateGranularity
bucketing, compareTo for period-over-period) — see objectstack-ui.orderBy + limit) and
compute ranks or running sums in application code.| Scenario | Use |
|---|---|
| Load lookup fields for display | expand |
| Filter parent by child conditions | Nested relation filter |
| Keyword-search by a related record's title | Mirror the title into a stored field on this object and search that — search never traverses (see Full-Text Search above) |
| Simple parent→child navigation | expand |
| Paginate/sort a parent's related records | Query the related object directly |
| Analytical queries across objects | Report/dashboard metadata, or separate queries combined in app code (joins was removed — see above) |
// Page-based API response
{
object: 'account',
where: { status: 'active' },
fields: ['id', 'name', 'email'],
orderBy: [{ field: 'name', order: 'asc' }],
limit: 20,
offset: (page - 1) * 20,
}
Unconditional KPIs can share one aggregate call; a KPI with its own
condition needs a separate call with the condition in where
(per-aggregation filter is schema-reserved — see Filtered Aggregation):
// KPI dashboard: unconditional aggregations share one call
const [kpis] = await engine.aggregate('deal', {
aggregations: [
{ function: 'count', alias: 'total_deals' },
{ function: 'sum', field: 'amount', alias: 'pipeline_value' },
{ function: 'avg', field: 'amount', alias: 'avg_deal_size' },
],
});
// Conditional KPI: separate call, condition in `where`
const [won] = await engine.aggregate('deal', {
where: { stage: 'closed_won' },
aggregations: [{ function: 'count', alias: 'won_deals' }],
});
Model analytics in dashboard/report metadata rather than hand-written query code — the renderer issues the queries for you:
| Query Need | Pattern |
|---|---|
| KPI widgets | Aggregates (sum, count, avg) over the object, each conditional KPI scoped by the widget/dataset filter. Add compareTo: 'previousPeriod' | 'previousYear' on the widget for a one-line period-over-period delta. |
| Time-series chart | Date filters + categoryGranularity: 'day' | 'week' | 'month' | 'quarter' | 'year' for server-side bucketing — never bucket by hand on the client. Pair with compareTo for an aligned YoY overlay. |
| Matrix report | groupingsDown + groupingsAcross + dateGranularity: 'quarter' |
| Funnel summary | Multi-level grouping (owner -> stage) + aggregated measures |
| Operational filter | Prefer declarative operators ($ne, $nin, $gte) over hardcoded SQL |
For metadata app development, model analytics in report/dashboard metadata first; only fall back to custom query code when schema limits require it.
Most queries run at runtime (smoke-test them with os data query or a vitest
test), but query metadata — list-view filter specs and report/dashboard
datasets — is validated statically. After editing those, run:
os validate # schema + CEL predicates + widget/dataset bindings (no artifact)
# or: os build # the same gates, plus emits dist/
A dashboard widget whose dataset / dimensions / values don't resolve fails
here instead of rendering an empty chart (ADR-0021). In a scaffolded project the
gate is npm run validate. See objectstack-platform → Verify your work.
See references/_index.md for the full list of Zod
schemas (with one-line descriptions) — pointers into
node_modules/@objectstack/spec/src/. Always Read the source for exact field
shapes; do not rely on memory of property names.
Frequently asked questions
Expert instructions for constructing data queries using the ObjectStack Query DSL. This skill covers filter expressions, sorting, pagination, aggregation, full-text search, and the expand system for related records.
The source record exposes this install command: npx skills add https://github.com/objectstack-ai/objectstack --skill "skills/objectstack-query". Inspect the command and pinned source before running it.
Alternatives
narrative-io/narrative-skills-marketplace
Translate a fuzzy analytical question into a rigorous investigation plan. Interrogates the ask, grounds the plan in the available data dictionary, applies analytical best practices, and produces a structured brief of query specifications for a downstream query-writing skill. Plans, does not write SQL. Use when: "why did X drop", "is there a relationship between A and B", "who are our highest-value customers", "what's driving the change in Y", "investigate this trend", "design an analysis for", "
alirezarezvani/claude-skills
When the user wants to plan, design, or implement an A/B test or experiment. Also use when the user mentions "A/B test," "split test," "experiment," "test this change," "variant copy," "multivariate test," "hypothesis," "conversion experiment," "statistical significance," or "test this." For tracking implementation, see analytics-tracking.
aAAaqwq/AGI-Super-Team
When the user wants to plan, design, or implement an A/B test or experiment. Also use when the user mentions "A/B test," "split test," "experiment," "test this change," "variant copy," "multivariate test," "hypothesis," "conversion experiment," "statistical significance," or "test this." For tracking implementation, see analytics-tracking.
supabase/supabase
Use whenever code will build, return, fetch, or execute SQL that runs against a user's real Postgres database — even when the request reads like an ordinary feature or bug fix and never says "security," "injection," or "SafeSqlFragment." This covers: writing or editing any pg-meta function, query builder, or endpoint that builds/returns SQL for database objects (tables, views, functions, DB triggers, indexes, RLS policies); interpolating a schema/table/column/search/route-param value into SQL te