Skip to content

Commit 5b9ad92

Browse files
authored
Merge pull request #990 from objectstack-ai/copilot/unify-api-query-syntax-spec-format
feat: unify API query syntax to Spec canonical format across all layers
2 parents 925ca06 + 30f69db commit 5b9ad92

File tree

12 files changed

+362
-118
lines changed

12 files changed

+362
-118
lines changed

ROADMAP.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ObjectStack Protocol — Road Map
22

3-
> **Last Updated:** 2026-02-28
3+
> **Last Updated:** 2026-03-27
44
> **Current Version:** v3.0.11
55
> **Status:** Protocol Specification Complete · Runtime Implementation In Progress
66
@@ -129,6 +129,7 @@ This strategy ensures rapid iteration while maintaining a clear path to producti
129129
| Dispatcher async `getService` bug fix || All `getService`/`getObjectQLService` calls in `http-dispatcher.ts` now properly `await` async service factories. Covers `handleAnalytics`, `handleAuth`, `handleStorage`, `handleAutomation`, `handleMetadata`, `handleUi`, `handlePackages`. All 7 framework adapters (Express, Fastify, Hono, Next.js, SvelteKit, NestJS, Nuxt) updated to use `getServiceAsync()` for auth service resolution. |
130130
| Analytics `getMetadata``getMeta` naming fix || `handleAnalytics` in `http-dispatcher.ts` called `getMetadata({ request })` which didn't match the `IAnalyticsService` contract (`getMeta(cubeName?: string)`). Renamed to `getMeta()` and aligned call signature. Updated test mocks accordingly. |
131131
| Unified ID/audit/tenant field naming || Eliminated `_id`/`modified_at`/`modified_by`/`space_id` from protocol layer. All protocol code uses `id`, `updated_at`, `updated_by`, `tenant_id` per `SystemFieldName`. Storage-layer (NoSQL driver internals) retains `_id` for MongoDB/Mingo compat. |
132+
| **Query syntax canonical unification** || All layers (Client SDK, React Hooks, Studio QueryBuilder, HTTP Dispatcher, docs) unified to Spec canonical field names (`where`/`fields`/`orderBy`/`limit`/`offset`/`expand`). `QueryOptionsV2` interface added. Legacy names (`filter`/`select`/`sort`/`top`/`skip`) accepted with `@deprecated` markers. HTTP Dispatcher normalizes transport params to canonical QueryAST before broker dispatch. |
132133

133134
---
134135

apps/studio/src/components/QueryBuilder.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,26 +83,26 @@ function buildQueryJson(objectName: string, state: QueryState): Record<string, u
8383
const query: Record<string, unknown> = { object: objectName };
8484

8585
if (state.selectedFields.length > 0) {
86-
query.select = state.selectedFields;
86+
query.fields = state.selectedFields;
8787
}
8888

8989
if (state.filters.length > 0) {
90-
query.filters = state.filters.map((f) => ({
90+
query.where = state.filters.map((f) => ({
9191
field: f.field,
9292
operator: f.operator,
9393
value: f.value,
9494
}));
9595
}
9696

9797
if (state.sorts.length > 0) {
98-
query.sort = state.sorts.map((s) => ({
98+
query.orderBy = state.sorts.map((s) => ({
9999
field: s.field,
100-
direction: s.direction,
100+
order: s.direction,
101101
}));
102102
}
103103

104-
if (state.limit > 0) query.top = state.limit;
105-
if (state.offset > 0) query.skip = state.offset;
104+
if (state.limit > 0) query.limit = state.limit;
105+
if (state.offset > 0) query.offset = state.offset;
106106

107107
return query;
108108
}

content/docs/guides/client-sdk.mdx

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,10 @@ async function main() {
4444

4545
// 3. Query data
4646
const tasks = await client.data.find('todo_task', {
47-
select: ['subject', 'priority'],
48-
filters: ['priority', '>=', 2],
49-
sort: ['-priority'],
50-
top: 10
47+
fields: ['subject', 'priority'],
48+
where: { priority: { $gte: 2 } },
49+
orderBy: ['-priority'],
50+
limit: 10
5151
});
5252

5353
// 4. Create a record
@@ -161,11 +161,11 @@ const listView = await client.meta.getView('account', 'list');
161161
```typescript
162162
// Find with query options
163163
const accounts = await client.data.find('account', {
164-
select: ['name', 'industry', 'revenue'],
165-
filters: ['industry', '=', 'Technology'],
166-
sort: ['-revenue'],
167-
top: 20,
168-
skip: 0,
164+
fields: ['name', 'industry', 'revenue'],
165+
where: { industry: 'Technology' },
166+
orderBy: ['-revenue'],
167+
limit: 20,
168+
offset: 0,
169169
});
170170

171171
// Get by ID
@@ -359,15 +359,20 @@ const filter = createFilter()
359359

360360
## Query Options
361361

362-
The `find` method accepts an options object:
362+
The `find` method accepts an options object with **canonical** (recommended) field names:
363363

364364
| Property | Type | Description | Example |
365365
|:---------|:-----|:------------|:--------|
366-
| `select` | `string[]` | Fields to retrieve | `['name', 'email']` |
367-
| `filters` | `Map` or `AST` | Filter criteria | `['status', '=', 'active']` |
368-
| `sort` | `string` or `string[]` | Sort order | `['-created_at']` |
369-
| `top` | `number` | Limit records | `20` |
370-
| `skip` | `number` | Offset for pagination | `0` |
366+
| `where` | `Object` or `FilterCondition` | Filter conditions (WHERE clause) | `{ status: 'active' }` |
367+
| `fields` | `string[]` | Fields to retrieve (SELECT) | `['name', 'email']` |
368+
| `orderBy` | `string` or `string[]` or `SortNode[]` | Sort order (ORDER BY) | `['-created_at']` |
369+
| `limit` | `number` | Max records to return (LIMIT) | `20` |
370+
| `offset` | `number` | Records to skip (OFFSET) | `0` |
371+
| `expand` | `Record<string, any>` or `string[]` | Relation loading (JOIN) | `{ owner: {} }` |
372+
373+
<Callout type="warn">
374+
**Deprecated aliases:** The following legacy field names are still accepted for backward compatibility but will be removed in a future major version: `select``fields`, `filter`/`filters``where`, `sort``orderBy`, `top``limit`, `skip``offset`.
375+
</Callout>
371376

372377
### Batch Options
373378

@@ -457,9 +462,9 @@ function App() {
457462
// 2. Use hooks in child components
458463
function AccountList() {
459464
const { data, isLoading, error, refetch } = useQuery('account', {
460-
filters: ['status', '=', 'active'],
461-
sort: ['-created_at'],
462-
top: 20,
465+
where: { status: 'active' },
466+
orderBy: ['-created_at'],
467+
limit: 20,
463468
});
464469

465470
if (isLoading) return <div>Loading...</div>;

content/docs/protocol/objectos/http-protocol.mdx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -127,14 +127,22 @@ GET /{base_path}/{object_name}
127127

128128
**Query Parameters:**
129129

130-
| Parameter | Type | Description | Example |
131-
|-----------|------|-------------|---------|
132-
| `select` | string | Comma-separated field list | `id,name,status` |
133-
| `filter` | JSON | Filter criteria (see Filtering section) | `{"status":"active"}` |
134-
| `sort` | string | Sort fields (prefix `-` for desc) | `-created_at,name` |
135-
| `page` | number | Page number (1-indexed) | `2` |
136-
| `per_page` | number | Items per page (max from limits) | `50` |
137-
| `include` | string | Related objects to embed | `assignee,comments` |
130+
| Parameter | Type | Description | Canonical Equivalent | Example |
131+
|-----------|------|-------------|---------------------|---------|
132+
| `select` | string | Comma-separated field list | `fields` | `id,name,status` |
133+
| `filter` | JSON | Filter criteria (see Filtering section) | `where` | `{"status":"active"}` |
134+
| `sort` | string | Sort fields (prefix `-` for desc) | `orderBy` | `-created_at,name` |
135+
| `top` | number | Max records to return | `limit` | `25` |
136+
| `skip` | number | Records to skip (offset) | `offset` | `50` |
137+
| `expand` | string | Related objects to embed | `expand` | `assignee,comments` |
138+
| `page` | number | Page number (1-indexed) || `2` |
139+
| `per_page` | number | Items per page (max from limits) || `50` |
140+
141+
> **Transport → Protocol normalization:** The HTTP dispatcher normalizes transport-level
142+
> parameter names to Spec canonical (QueryAST) field names before forwarding to the
143+
> broker layer: `filter``where`, `select``fields`, `sort``orderBy`, `top``limit`,
144+
> `skip``offset`. The deprecated `filters` (plural) parameter is also accepted and
145+
> normalized to `where`.
138146
139147
**Example Request:**
140148
```http

packages/client-react/src/data-hooks.tsx

Lines changed: 74 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,47 @@ import { useClient } from './context';
1313

1414
/**
1515
* Query options for useQuery hook
16+
*
17+
* Supports both **canonical** (Spec protocol) and **legacy** field names.
18+
* Canonical names are preferred; legacy names are accepted for backward
19+
* compatibility and will be removed in a future major release.
20+
*
21+
* | Canonical | Legacy (deprecated) |
22+
* |-----------|---------------------|
23+
* | `where` | `filters` |
24+
* | `fields` | `select` |
25+
* | `orderBy` | `sort` |
26+
* | `limit` | `top` |
27+
* | `offset` | `skip` |
1628
*/
1729
export interface UseQueryOptions<T = any> {
1830
/** Query AST or simplified query options */
1931
query?: Partial<QueryAST>;
20-
/** Simple field selection */
32+
33+
// ── Canonical (Spec protocol) field names ──────────────────────────
34+
/** Filter conditions (WHERE clause). */
35+
where?: FilterCondition;
36+
/** Fields to retrieve (SELECT clause). */
37+
fields?: string[];
38+
/** Sort definition (ORDER BY clause). */
39+
orderBy?: string | string[];
40+
/** Maximum number of records to return (LIMIT). */
41+
limit?: number;
42+
/** Number of records to skip (OFFSET). */
43+
offset?: number;
44+
45+
// ── Legacy field names (deprecated) ────────────────────────────────
46+
/** @deprecated Use `fields` instead. */
2147
select?: string[];
22-
/** Simple filters */
48+
/** @deprecated Use `where` instead. */
2349
filters?: FilterCondition;
24-
/** Sort configuration */
50+
/** @deprecated Use `orderBy` instead. */
2551
sort?: string | string[];
26-
/** Limit results */
52+
/** @deprecated Use `limit` instead. */
2753
top?: number;
28-
/** Skip results (for pagination) */
54+
/** @deprecated Use `offset` instead. */
2955
skip?: number;
56+
3057
/** Enable/disable automatic query execution */
3158
enabled?: boolean;
3259
/** Refetch interval in milliseconds */
@@ -60,9 +87,9 @@ export interface UseQueryResult<T = any> {
6087
* ```tsx
6188
* function TaskList() {
6289
* const { data, isLoading, error, refetch } = useQuery('todo_task', {
63-
* select: ['id', 'subject', 'priority'],
64-
* sort: ['-created_at'],
65-
* top: 20
90+
* fields: ['id', 'subject', 'priority'],
91+
* orderBy: ['-created_at'],
92+
* limit: 20
6693
* });
6794
*
6895
* if (isLoading) return <div>Loading...</div>;
@@ -91,17 +118,23 @@ export function useQuery<T = any>(
91118

92119
const {
93120
query,
94-
select,
95-
filters,
96-
sort,
97-
top,
98-
skip,
121+
// Canonical names take precedence over legacy names
122+
where, fields, orderBy, limit, offset,
123+
// Legacy names (deprecated fallbacks)
124+
select, filters, sort, top, skip,
99125
enabled = true,
100126
refetchInterval,
101127
onSuccess,
102128
onError
103129
} = options;
104130

131+
// Resolve canonical vs legacy: canonical wins when both are provided
132+
const resolvedFields = fields ?? select;
133+
const resolvedWhere = where ?? filters;
134+
const resolvedSort = orderBy ?? sort;
135+
const resolvedLimit = limit ?? top;
136+
const resolvedOffset = offset ?? skip;
137+
105138
const fetchData = useCallback(async (isRefetch = false) => {
106139
if (!enabled) return;
107140

@@ -119,13 +152,13 @@ export function useQuery<T = any>(
119152
// Use advanced query API
120153
result = await client.data.query<T>(object, query);
121154
} else {
122-
// Use simplified find API
155+
// Use canonical QueryOptionsV2 for the find call
123156
result = await client.data.find<T>(object, {
124-
select,
125-
filters: filters as any,
126-
sort,
127-
top,
128-
skip
157+
where: resolvedWhere as any,
158+
fields: resolvedFields,
159+
orderBy: resolvedSort,
160+
limit: resolvedLimit,
161+
offset: resolvedOffset,
129162
});
130163
}
131164

@@ -139,7 +172,7 @@ export function useQuery<T = any>(
139172
setIsLoading(false);
140173
setIsRefetching(false);
141174
}
142-
}, [client, object, query, select, filters, sort, top, skip, enabled, onSuccess, onError]);
175+
}, [client, object, query, resolvedFields, resolvedWhere, resolvedSort, resolvedLimit, resolvedOffset, enabled, onSuccess, onError]);
143176

144177
// Initial fetch and dependency-based refetch
145178
useEffect(() => {
@@ -319,7 +352,7 @@ export function useMutation<TData = any, TVariables = any>(
319352
/**
320353
* Pagination options for usePagination hook
321354
*/
322-
export interface UsePaginationOptions<T = any> extends Omit<UseQueryOptions<T>, 'top' | 'skip'> {
355+
export interface UsePaginationOptions<T = any> extends Omit<UseQueryOptions<T>, 'top' | 'skip' | 'limit' | 'offset'> {
323356
/** Page size */
324357
pageSize?: number;
325358
/** Initial page (1-based) */
@@ -365,7 +398,7 @@ export interface UsePaginationResult<T = any> extends UseQueryResult<T> {
365398
* hasPreviousPage
366399
* } = usePagination('todo_task', {
367400
* pageSize: 10,
368-
* sort: ['-created_at']
401+
* orderBy: ['-created_at']
369402
* });
370403
*
371404
* return (
@@ -388,8 +421,8 @@ export function usePagination<T = any>(
388421

389422
const queryResult = useQuery<T>(object, {
390423
...queryOptions,
391-
top: pageSize,
392-
skip: (page - 1) * pageSize
424+
limit: pageSize,
425+
offset: (page - 1) * pageSize
393426
});
394427

395428
const totalCount = queryResult.data?.total || 0;
@@ -430,7 +463,7 @@ export function usePagination<T = any>(
430463
/**
431464
* Infinite query options for useInfiniteQuery hook
432465
*/
433-
export interface UseInfiniteQueryOptions<T = any> extends Omit<UseQueryOptions<T>, 'skip'> {
466+
export interface UseInfiniteQueryOptions<T = any> extends Omit<UseQueryOptions<T>, 'skip' | 'offset'> {
434467
/** Page size for each fetch */
435468
pageSize?: number;
436469
/** Get next page parameter */
@@ -473,7 +506,7 @@ export interface UseInfiniteQueryResult<T = any> {
473506
* isFetchingNextPage
474507
* } = useInfiniteQuery('todo_task', {
475508
* pageSize: 20,
476-
* sort: ['-created_at']
509+
* orderBy: ['-created_at']
477510
* });
478511
*
479512
* return (
@@ -498,14 +531,20 @@ export function useInfiniteQuery<T = any>(
498531
pageSize = 20,
499532
// getNextPageParam is reserved for future use
500533
query,
501-
select,
502-
filters,
503-
sort,
534+
// Canonical names take precedence over legacy names
535+
where, fields, orderBy,
536+
// Legacy names (deprecated fallbacks)
537+
select, filters, sort,
504538
enabled = true,
505539
onSuccess,
506540
onError
507541
} = options;
508542

543+
// Resolve canonical vs legacy: canonical wins
544+
const resolvedFields = fields ?? select;
545+
const resolvedWhere = where ?? filters;
546+
const resolvedSort = orderBy ?? sort;
547+
509548
const [pages, setPages] = useState<PaginatedResult<T>[]>([]);
510549
const [isLoading, setIsLoading] = useState(true);
511550
const [isFetchingNextPage, setIsFetchingNextPage] = useState(false);
@@ -531,11 +570,11 @@ export function useInfiniteQuery<T = any>(
531570
});
532571
} else {
533572
result = await client.data.find<T>(object, {
534-
select,
535-
filters: filters as any,
536-
sort,
537-
top: pageSize,
538-
skip
573+
where: resolvedWhere as any,
574+
fields: resolvedFields,
575+
orderBy: resolvedSort,
576+
limit: pageSize,
577+
offset: skip,
539578
});
540579
}
541580

@@ -559,7 +598,7 @@ export function useInfiniteQuery<T = any>(
559598
setIsLoading(false);
560599
setIsFetchingNextPage(false);
561600
}
562-
}, [client, object, query, select, filters, sort, pageSize, onSuccess, onError]);
601+
}, [client, object, query, resolvedFields, resolvedWhere, resolvedSort, pageSize, onSuccess, onError]);
563602

564603
// Initial fetch
565604
useEffect(() => {

packages/client/README.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -147,12 +147,15 @@ Initializes the client by fetching the system discovery manifest from `/api/v1`.
147147
- `setDefault(id, object)`: Set a view as default for an object.
148148

149149
### Query Options
150-
The `find` method accepts an options object:
151-
- `select`: Array of field names to retrieve.
152-
- `filters`: Simple key-value map OR Filter AST `['field', 'op', 'value']`.
153-
- `sort`: Sort string (`'name'`) or array `['-created_at', 'name']`.
154-
- `top`: Limit number of records.
155-
- `skip`: Offset for pagination.
150+
The `find` method accepts an options object with canonical field names:
151+
- `where`: Filter conditions (WHERE clause). Accepts object or FilterCondition AST.
152+
- `fields`: Array of field names to retrieve (SELECT clause).
153+
- `orderBy`: Sort definition — `'name'`, `['-created_at', 'name']`, or `SortNode[]`.
154+
- `limit`: Maximum number of records to return (LIMIT).
155+
- `offset`: Number of records to skip (OFFSET).
156+
- `expand`: Relations to expand (JOIN / eager-load).
157+
158+
> **Deprecated aliases** (accepted for backward compatibility): `select``fields`, `filter`/`filters``where`, `sort``orderBy`, `top``limit`, `skip``offset`.
156159
157160
### Batch Options
158161
Batch operations support the following options:

0 commit comments

Comments
 (0)