From 3791f83603145a1b4c43204551796ebda9b9d6ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 03:54:00 +0000 Subject: [PATCH 1/6] Initial plan From c463c2334d2b49671eee4149d57a7bd5c0558a05 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 04:02:45 +0000 Subject: [PATCH 2/6] feat(client): add QueryOptionsV2 and update find() for canonical query syntax - Add QueryOptionsV2 interface with canonical field names (where, fields, orderBy, limit, offset, expand) - Update find() to accept both QueryOptions and QueryOptionsV2 with normalization logic - Add offset() method to QueryBuilder as canonical alias for skip() - Deprecate skip() in favor of offset() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com> --- packages/client/src/index.ts | 79 ++++++++++++++++++++++------ packages/client/src/query-builder.ts | 11 +++- 2 files changed, 74 insertions(+), 16 deletions(-) diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index c580e2028..e319d4f48 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -145,6 +145,37 @@ export interface QueryOptions { groupBy?: string[]; } +/** + * Canonical query options using Spec protocol field names. + * This is the recommended interface for `data.find()` queries. + * + * Canonical field mapping (QueryAST-aligned): + * - `where` — filter conditions (replaces legacy `filter`/`filters`) + * - `fields` — field selection (replaces legacy `select`) + * - `orderBy` — sort definition (replaces legacy `sort`) + * - `limit` — max records (replaces legacy `top`) + * - `offset` — skip records (replaces legacy `skip`) + * - `expand` — relation loading (replaces legacy `populate`) + */ +export interface QueryOptionsV2 { + /** Filter conditions (WHERE clause). Accepts MongoDB-style $op object or FilterCondition AST. */ + where?: Record | unknown[]; + /** Fields to retrieve (SELECT clause). */ + fields?: string[]; + /** Sort definition (ORDER BY clause). */ + orderBy?: string | string[] | SortNode[]; + /** Maximum number of records to return (LIMIT). */ + limit?: number; + /** Number of records to skip (OFFSET). */ + offset?: number; + /** Relations to expand (JOIN / eager-load). */ + expand?: Record | string[]; + /** Aggregation functions. */ + aggregations?: AggregationNode[]; + /** Group by fields. */ + groupBy?: string[]; +} + export interface PaginatedResult { /** Spec-compliant: array of matching records */ records: T[]; @@ -1445,34 +1476,52 @@ export class ObjectStackClient { * @deprecated Use `data.query()` with standard QueryAST parameters instead. * This method uses legacy parameter names. Internally adapts to HTTP GET params. */ - find: async (object: string, options: QueryOptions = {}): Promise> => { + find: async (object: string, options: QueryOptions | QueryOptionsV2 = {}): Promise> => { const route = this.getRoute('data'); const queryParams = new URLSearchParams(); - + + // ── Normalize V2 canonical options → HTTP transport params ─── + // Detect V2 options by presence of canonical-only keys. + const v2 = options as QueryOptionsV2; + const normalizedOptions: QueryOptions = {} as QueryOptions; + if ('where' in options || 'fields' in options || 'orderBy' in options || 'offset' in options) { + // V2 canonical options detected — map to legacy HTTP transport keys + if (v2.where) normalizedOptions.filter = v2.where as any; + if (v2.fields) normalizedOptions.select = v2.fields; + if (v2.orderBy) normalizedOptions.sort = v2.orderBy as any; + if (v2.limit != null) normalizedOptions.top = v2.limit; + if (v2.offset != null) normalizedOptions.skip = v2.offset; + if (v2.aggregations) normalizedOptions.aggregations = v2.aggregations; + if (v2.groupBy) normalizedOptions.groupBy = v2.groupBy; + } else { + // Legacy QueryOptions — pass through as-is + Object.assign(normalizedOptions, options); + } + // 1. Handle Pagination - if (options.top) queryParams.set('top', options.top.toString()); - if (options.skip) queryParams.set('skip', options.skip.toString()); + if (normalizedOptions.top) queryParams.set('top', normalizedOptions.top.toString()); + if (normalizedOptions.skip) queryParams.set('skip', normalizedOptions.skip.toString()); // 2. Handle Sort - if (options.sort) { + if (normalizedOptions.sort) { // Check if it's AST - if (Array.isArray(options.sort) && typeof options.sort[0] === 'object') { - queryParams.set('sort', JSON.stringify(options.sort)); + if (Array.isArray(normalizedOptions.sort) && typeof normalizedOptions.sort[0] === 'object') { + queryParams.set('sort', JSON.stringify(normalizedOptions.sort)); } else { - const sortVal = Array.isArray(options.sort) ? options.sort.join(',') : options.sort; + const sortVal = Array.isArray(normalizedOptions.sort) ? normalizedOptions.sort.join(',') : normalizedOptions.sort; queryParams.set('sort', sortVal as string); } } // 3. Handle Select - if (options.select) { - queryParams.set('select', options.select.join(',')); + if (normalizedOptions.select) { + queryParams.set('select', normalizedOptions.select.join(',')); } // 4. Handle Filters (Simple vs AST) // Canonical HTTP param name: `filter` (singular). `filters` (plural) is accepted // for backward compatibility but `filter` is the standard going forward. - const filterValue = options.filter ?? options.filters; + const filterValue = normalizedOptions.filter ?? normalizedOptions.filters; if (filterValue) { // Detect AST filter format vs simple key-value map. AST filters use an array structure // with [field, operator, value] or [logicOp, ...nodes] shape (see isFilterAST from spec). @@ -1491,11 +1540,11 @@ export class ObjectStackClient { } // 5. Handle Aggregations & GroupBy (Pass through as JSON if present) - if (options.aggregations) { - queryParams.set('aggregations', JSON.stringify(options.aggregations)); + if (normalizedOptions.aggregations) { + queryParams.set('aggregations', JSON.stringify(normalizedOptions.aggregations)); } - if (options.groupBy) { - queryParams.set('groupBy', options.groupBy.join(',')); + if (normalizedOptions.groupBy) { + queryParams.set('groupBy', normalizedOptions.groupBy.join(',')); } const res = await this.fetch(`${this.baseUrl}${route}/${object}?${queryParams.toString()}`); diff --git a/packages/client/src/query-builder.ts b/packages/client/src/query-builder.ts index 70d89e2a9..58a6a145f 100644 --- a/packages/client/src/query-builder.ts +++ b/packages/client/src/query-builder.ts @@ -236,13 +236,22 @@ export class QueryBuilder { } /** - * Skip records (for pagination) + * Skip records (for pagination). + * @deprecated Prefer `.offset()` for alignment with Spec canonical field names. */ skip(count: number): this { this.query.offset = count; return this; } + /** + * Offset records (for pagination) — canonical alias for `.skip()` + */ + offset(count: number): this { + this.query.offset = count; + return this; + } + /** * Paginate results */ From 111bbc085f15318d5b27f217d7e053f02b692004 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 04:09:30 +0000 Subject: [PATCH 3/6] feat: unify query syntax to Spec canonical format across SDK, hooks, studio, and docs - Add QueryOptionsV2 interface with canonical field names (where, fields, orderBy, limit, offset, expand) - Update find() to accept both QueryOptions and QueryOptionsV2 - Add offset() alias to QueryBuilder, deprecate skip() - Update React hooks (useQuery, usePagination, useInfiniteQuery) to support canonical names with legacy fallbacks - Update Studio QueryBuilder buildQueryJson to output canonical format - Add HTTP dispatcher normalization of transport params to canonical QueryAST - Update client-sdk.mdx documentation with canonical examples - Update client README.md query options documentation - Update http-protocol.mdx with canonical mapping table Agent-Logs-Url: https://github.com/objectstack-ai/spec/sessions/d243fae5-51a7-4440-926f-46f040744cb0 Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com> --- apps/studio/src/components/QueryBuilder.tsx | 12 +- content/docs/guides/client-sdk.mdx | 41 ++++--- .../docs/protocol/objectos/http-protocol.mdx | 24 ++-- packages/client-react/src/data-hooks.tsx | 109 ++++++++++++------ packages/client/README.md | 15 ++- packages/runtime/src/http-dispatcher.ts | 39 ++++++- 6 files changed, 166 insertions(+), 74 deletions(-) diff --git a/apps/studio/src/components/QueryBuilder.tsx b/apps/studio/src/components/QueryBuilder.tsx index 3ff620540..bddaa2241 100644 --- a/apps/studio/src/components/QueryBuilder.tsx +++ b/apps/studio/src/components/QueryBuilder.tsx @@ -83,11 +83,11 @@ function buildQueryJson(objectName: string, state: QueryState): Record = { object: objectName }; if (state.selectedFields.length > 0) { - query.select = state.selectedFields; + query.fields = state.selectedFields; } if (state.filters.length > 0) { - query.filters = state.filters.map((f) => ({ + query.where = state.filters.map((f) => ({ field: f.field, operator: f.operator, value: f.value, @@ -95,14 +95,14 @@ function buildQueryJson(objectName: string, state: QueryState): Record 0) { - query.sort = state.sorts.map((s) => ({ + query.orderBy = state.sorts.map((s) => ({ field: s.field, - direction: s.direction, + order: s.direction, })); } - if (state.limit > 0) query.top = state.limit; - if (state.offset > 0) query.skip = state.offset; + if (state.limit > 0) query.limit = state.limit; + if (state.offset > 0) query.offset = state.offset; return query; } diff --git a/content/docs/guides/client-sdk.mdx b/content/docs/guides/client-sdk.mdx index c68c218e8..a0bba51e7 100644 --- a/content/docs/guides/client-sdk.mdx +++ b/content/docs/guides/client-sdk.mdx @@ -44,10 +44,10 @@ async function main() { // 3. Query data const tasks = await client.data.find('todo_task', { - select: ['subject', 'priority'], - filters: ['priority', '>=', 2], - sort: ['-priority'], - top: 10 + fields: ['subject', 'priority'], + where: { priority: { $gte: 2 } }, + orderBy: ['-priority'], + limit: 10 }); // 4. Create a record @@ -161,11 +161,11 @@ const listView = await client.meta.getView('account', 'list'); ```typescript // Find with query options const accounts = await client.data.find('account', { - select: ['name', 'industry', 'revenue'], - filters: ['industry', '=', 'Technology'], - sort: ['-revenue'], - top: 20, - skip: 0, + fields: ['name', 'industry', 'revenue'], + where: { industry: 'Technology' }, + orderBy: ['-revenue'], + limit: 20, + offset: 0, }); // Get by ID @@ -359,15 +359,20 @@ const filter = createFilter() ## Query Options -The `find` method accepts an options object: +The `find` method accepts an options object with **canonical** (recommended) field names: | Property | Type | Description | Example | |:---------|:-----|:------------|:--------| -| `select` | `string[]` | Fields to retrieve | `['name', 'email']` | -| `filters` | `Map` or `AST` | Filter criteria | `['status', '=', 'active']` | -| `sort` | `string` or `string[]` | Sort order | `['-created_at']` | -| `top` | `number` | Limit records | `20` | -| `skip` | `number` | Offset for pagination | `0` | +| `where` | `Object` or `FilterCondition` | Filter conditions (WHERE clause) | `{ status: 'active' }` | +| `fields` | `string[]` | Fields to retrieve (SELECT) | `['name', 'email']` | +| `orderBy` | `string` or `string[]` or `SortNode[]` | Sort order (ORDER BY) | `['-created_at']` | +| `limit` | `number` | Max records to return (LIMIT) | `20` | +| `offset` | `number` | Records to skip (OFFSET) | `0` | +| `expand` | `Record` or `string[]` | Relation loading (JOIN) | `{ owner: {} }` | + + +**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`. + ### Batch Options @@ -457,9 +462,9 @@ function App() { // 2. Use hooks in child components function AccountList() { const { data, isLoading, error, refetch } = useQuery('account', { - filters: ['status', '=', 'active'], - sort: ['-created_at'], - top: 20, + where: { status: 'active' }, + orderBy: ['-created_at'], + limit: 20, }); if (isLoading) return
Loading...
; diff --git a/content/docs/protocol/objectos/http-protocol.mdx b/content/docs/protocol/objectos/http-protocol.mdx index a30fbd5e0..97b1cee36 100644 --- a/content/docs/protocol/objectos/http-protocol.mdx +++ b/content/docs/protocol/objectos/http-protocol.mdx @@ -127,14 +127,22 @@ GET /{base_path}/{object_name} **Query Parameters:** -| Parameter | Type | Description | Example | -|-----------|------|-------------|---------| -| `select` | string | Comma-separated field list | `id,name,status` | -| `filter` | JSON | Filter criteria (see Filtering section) | `{"status":"active"}` | -| `sort` | string | Sort fields (prefix `-` for desc) | `-created_at,name` | -| `page` | number | Page number (1-indexed) | `2` | -| `per_page` | number | Items per page (max from limits) | `50` | -| `include` | string | Related objects to embed | `assignee,comments` | +| Parameter | Type | Description | Canonical Equivalent | Example | +|-----------|------|-------------|---------------------|---------| +| `select` | string | Comma-separated field list | `fields` | `id,name,status` | +| `filter` | JSON | Filter criteria (see Filtering section) | `where` | `{"status":"active"}` | +| `sort` | string | Sort fields (prefix `-` for desc) | `orderBy` | `-created_at,name` | +| `top` | number | Max records to return | `limit` | `25` | +| `skip` | number | Records to skip (offset) | `offset` | `50` | +| `expand` | string | Related objects to embed | `expand` | `assignee,comments` | +| `page` | number | Page number (1-indexed) | — | `2` | +| `per_page` | number | Items per page (max from limits) | — | `50` | + +> **Transport → Protocol normalization:** The HTTP dispatcher normalizes transport-level +> parameter names to Spec canonical (QueryAST) field names before forwarding to the +> broker layer: `filter`→`where`, `select`→`fields`, `sort`→`orderBy`, `top`→`limit`, +> `skip`→`offset`. The deprecated `filters` (plural) parameter is also accepted and +> normalized to `where`. **Example Request:** ```http diff --git a/packages/client-react/src/data-hooks.tsx b/packages/client-react/src/data-hooks.tsx index 77e7a81b2..8afb18bb2 100644 --- a/packages/client-react/src/data-hooks.tsx +++ b/packages/client-react/src/data-hooks.tsx @@ -13,20 +13,47 @@ import { useClient } from './context'; /** * Query options for useQuery hook + * + * Supports both **canonical** (Spec protocol) and **legacy** field names. + * Canonical names are preferred; legacy names are accepted for backward + * compatibility and will be removed in a future major release. + * + * | Canonical | Legacy (deprecated) | + * |-----------|---------------------| + * | `where` | `filters` | + * | `fields` | `select` | + * | `orderBy` | `sort` | + * | `limit` | `top` | + * | `offset` | `skip` | */ export interface UseQueryOptions { /** Query AST or simplified query options */ query?: Partial; - /** Simple field selection */ + + // ── Canonical (Spec protocol) field names ────────────────────────── + /** Filter conditions (WHERE clause). */ + where?: FilterCondition; + /** Fields to retrieve (SELECT clause). */ + fields?: string[]; + /** Sort definition (ORDER BY clause). */ + orderBy?: string | string[]; + /** Maximum number of records to return (LIMIT). */ + limit?: number; + /** Number of records to skip (OFFSET). */ + offset?: number; + + // ── Legacy field names (deprecated) ──────────────────────────────── + /** @deprecated Use `fields` instead. */ select?: string[]; - /** Simple filters */ + /** @deprecated Use `where` instead. */ filters?: FilterCondition; - /** Sort configuration */ + /** @deprecated Use `orderBy` instead. */ sort?: string | string[]; - /** Limit results */ + /** @deprecated Use `limit` instead. */ top?: number; - /** Skip results (for pagination) */ + /** @deprecated Use `offset` instead. */ skip?: number; + /** Enable/disable automatic query execution */ enabled?: boolean; /** Refetch interval in milliseconds */ @@ -60,9 +87,9 @@ export interface UseQueryResult { * ```tsx * function TaskList() { * const { data, isLoading, error, refetch } = useQuery('todo_task', { - * select: ['id', 'subject', 'priority'], - * sort: ['-created_at'], - * top: 20 + * fields: ['id', 'subject', 'priority'], + * orderBy: ['-created_at'], + * limit: 20 * }); * * if (isLoading) return
Loading...
; @@ -91,17 +118,23 @@ export function useQuery( const { query, - select, - filters, - sort, - top, - skip, + // Canonical names take precedence over legacy names + where, fields, orderBy, limit, offset, + // Legacy names (deprecated fallbacks) + select, filters, sort, top, skip, enabled = true, refetchInterval, onSuccess, onError } = options; + // Resolve canonical vs legacy: canonical wins when both are provided + const resolvedFields = fields ?? select; + const resolvedWhere = where ?? filters; + const resolvedSort = orderBy ?? sort; + const resolvedLimit = limit ?? top; + const resolvedOffset = offset ?? skip; + const fetchData = useCallback(async (isRefetch = false) => { if (!enabled) return; @@ -119,13 +152,13 @@ export function useQuery( // Use advanced query API result = await client.data.query(object, query); } else { - // Use simplified find API + // Use canonical QueryOptionsV2 for the find call result = await client.data.find(object, { - select, - filters: filters as any, - sort, - top, - skip + where: resolvedWhere as any, + fields: resolvedFields, + orderBy: resolvedSort, + limit: resolvedLimit, + offset: resolvedOffset, }); } @@ -139,7 +172,7 @@ export function useQuery( setIsLoading(false); setIsRefetching(false); } - }, [client, object, query, select, filters, sort, top, skip, enabled, onSuccess, onError]); + }, [client, object, query, resolvedFields, resolvedWhere, resolvedSort, resolvedLimit, resolvedOffset, enabled, onSuccess, onError]); // Initial fetch and dependency-based refetch useEffect(() => { @@ -319,7 +352,7 @@ export function useMutation( /** * Pagination options for usePagination hook */ -export interface UsePaginationOptions extends Omit, 'top' | 'skip'> { +export interface UsePaginationOptions extends Omit, 'top' | 'skip' | 'limit' | 'offset'> { /** Page size */ pageSize?: number; /** Initial page (1-based) */ @@ -365,7 +398,7 @@ export interface UsePaginationResult extends UseQueryResult { * hasPreviousPage * } = usePagination('todo_task', { * pageSize: 10, - * sort: ['-created_at'] + * orderBy: ['-created_at'] * }); * * return ( @@ -388,8 +421,8 @@ export function usePagination( const queryResult = useQuery(object, { ...queryOptions, - top: pageSize, - skip: (page - 1) * pageSize + limit: pageSize, + offset: (page - 1) * pageSize }); const totalCount = queryResult.data?.total || 0; @@ -430,7 +463,7 @@ export function usePagination( /** * Infinite query options for useInfiniteQuery hook */ -export interface UseInfiniteQueryOptions extends Omit, 'skip'> { +export interface UseInfiniteQueryOptions extends Omit, 'skip' | 'offset'> { /** Page size for each fetch */ pageSize?: number; /** Get next page parameter */ @@ -473,7 +506,7 @@ export interface UseInfiniteQueryResult { * isFetchingNextPage * } = useInfiniteQuery('todo_task', { * pageSize: 20, - * sort: ['-created_at'] + * orderBy: ['-created_at'] * }); * * return ( @@ -498,14 +531,20 @@ export function useInfiniteQuery( pageSize = 20, // getNextPageParam is reserved for future use query, - select, - filters, - sort, + // Canonical names take precedence over legacy names + where, fields, orderBy, + // Legacy names (deprecated fallbacks) + select, filters, sort, enabled = true, onSuccess, onError } = options; + // Resolve canonical vs legacy: canonical wins + const resolvedFields = fields ?? select; + const resolvedWhere = where ?? filters; + const resolvedSort = orderBy ?? sort; + const [pages, setPages] = useState[]>([]); const [isLoading, setIsLoading] = useState(true); const [isFetchingNextPage, setIsFetchingNextPage] = useState(false); @@ -531,11 +570,11 @@ export function useInfiniteQuery( }); } else { result = await client.data.find(object, { - select, - filters: filters as any, - sort, - top: pageSize, - skip + where: resolvedWhere as any, + fields: resolvedFields, + orderBy: resolvedSort, + limit: pageSize, + offset: skip, }); } @@ -559,7 +598,7 @@ export function useInfiniteQuery( setIsLoading(false); setIsFetchingNextPage(false); } - }, [client, object, query, select, filters, sort, pageSize, onSuccess, onError]); + }, [client, object, query, resolvedFields, resolvedWhere, resolvedSort, pageSize, onSuccess, onError]); // Initial fetch useEffect(() => { diff --git a/packages/client/README.md b/packages/client/README.md index 0c720fc32..f8c9fde7f 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -147,12 +147,15 @@ Initializes the client by fetching the system discovery manifest from `/api/v1`. - `setDefault(id, object)`: Set a view as default for an object. ### Query Options -The `find` method accepts an options object: -- `select`: Array of field names to retrieve. -- `filters`: Simple key-value map OR Filter AST `['field', 'op', 'value']`. -- `sort`: Sort string (`'name'`) or array `['-created_at', 'name']`. -- `top`: Limit number of records. -- `skip`: Offset for pagination. +The `find` method accepts an options object with canonical field names: +- `where`: Filter conditions (WHERE clause). Accepts object or FilterCondition AST. +- `fields`: Array of field names to retrieve (SELECT clause). +- `orderBy`: Sort definition — `'name'`, `['-created_at', 'name']`, or `SortNode[]`. +- `limit`: Maximum number of records to return (LIMIT). +- `offset`: Number of records to skip (OFFSET). +- `expand`: Relations to expand (JOIN / eager-load). + +> **Deprecated aliases** (accepted for backward compatibility): `select` → `fields`, `filter`/`filters` → `where`, `sort` → `orderBy`, `top` → `limit`, `skip` → `offset`. ### Batch Options Batch operations support the following options: diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 890989748..91f9542d8 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -585,8 +585,45 @@ export class HttpDispatcher { } else { // GET /data/:object (List) if (m === 'GET') { + // ── Normalize HTTP transport params → Spec canonical (QueryAST) ── + // HTTP GET query params use transport-level names (filter, sort, top, + // skip, select, expand) which are normalized here to canonical + // QueryAST field names (where, orderBy, limit, offset, fields, + // expand) before forwarding to the broker layer. + // The protocol.ts findData() method performs a deeper normalization + // pass, but pre-normalizing here ensures the broker always receives + // Spec-canonical keys. + const normalized: Record = { ...query }; + + // filter/filters → where (@deprecated filter is still the canonical HTTP param) + if (normalized.filter != null || normalized.filters != null) { + normalized.where = normalized.where ?? normalized.filter ?? normalized.filters; + delete normalized.filter; + delete normalized.filters; + } + // select → fields + if (normalized.select != null && normalized.fields == null) { + normalized.fields = normalized.select; + delete normalized.select; + } + // sort → orderBy + if (normalized.sort != null && normalized.orderBy == null) { + normalized.orderBy = normalized.sort; + delete normalized.sort; + } + // top → limit + if (normalized.top != null && normalized.limit == null) { + normalized.limit = normalized.top; + delete normalized.top; + } + // skip → offset + if (normalized.skip != null && normalized.offset == null) { + normalized.offset = normalized.skip; + delete normalized.skip; + } + // Spec: broker returns FindDataResponse = { object, records, total?, hasMore? } - const result = await broker.call('data.query', { object: objectName, query }, { request: context.request }); + const result = await broker.call('data.query', { object: objectName, query: normalized }, { request: context.request }); return { handled: true, response: this.success(result) }; } From 9b4212a6d739b39a10fee028775d0dade807e306 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 04:13:34 +0000 Subject: [PATCH 4/6] test: add canonical QueryOptionsV2 and offset() alias tests, update ROADMAP.md Agent-Logs-Url: https://github.com/objectstack-ai/spec/sessions/d243fae5-51a7-4440-926f-46f040744cb0 Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com> --- ROADMAP.md | 3 +- packages/client/src/client.test.ts | 70 ++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5883fed30..78975587d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # ObjectStack Protocol — Road Map -> **Last Updated:** 2026-02-28 +> **Last Updated:** 2026-03-27 > **Current Version:** v3.0.11 > **Status:** Protocol Specification Complete · Runtime Implementation In Progress @@ -129,6 +129,7 @@ This strategy ensures rapid iteration while maintaining a clear path to producti | 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. | | 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. | | 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. | +| **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. | --- diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index d895cfcd7..f704433de 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -827,3 +827,73 @@ describe('ObjectStackClient.automation', () => { expect(client.capabilities!.search).toBe(true); }); }); + +// ========================================== +// QueryOptionsV2 (Canonical Query Syntax) Tests +// ========================================== + +describe('QueryOptionsV2 — canonical find()', () => { + it('should accept canonical field names (where, fields, orderBy, limit, offset)', async () => { + const { client, fetchMock } = createMockClient({ + success: true, + data: { object: 'account', records: [], total: 0 } + }); + + await client.data.find('account', { + where: { status: 'active' }, + fields: ['name', 'email'], + orderBy: ['-created_at'], + limit: 10, + offset: 5, + }); + + const url = fetchMock.mock.calls[0][0] as string; + // V2 canonical options are normalized to HTTP transport params + expect(url).toContain('top=10'); + expect(url).toContain('skip=5'); + expect(url).toContain('select=name%2Cemail'); + expect(url).toContain('sort=-created_at'); + // where → filter as JSON + expect(url).toContain('status=active'); + }); + + it('should still accept legacy field names (filter, select, sort, top, skip)', async () => { + const { client, fetchMock } = createMockClient({ + success: true, + data: { object: 'account', records: [], total: 0 } + }); + + await client.data.find('account', { + filter: { industry: 'Tech' }, + select: ['name'], + sort: ['-revenue'], + top: 20, + skip: 0, + }); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain('top=20'); + expect(url).toContain('select=name'); + expect(url).toContain('sort=-revenue'); + expect(url).toContain('industry=Tech'); + }); +}); + +describe('QueryBuilder — offset() alias', () => { + it('should set offset via .offset() method', () => { + const q = createQuery('task') + .limit(10) + .offset(20) + .build(); + expect(q.limit).toBe(10); + expect(q.offset).toBe(20); + }); + + it('should set offset via deprecated .skip() method', () => { + const q = createQuery('task') + .limit(10) + .skip(30) + .build(); + expect(q.offset).toBe(30); + }); +}); From 9b40ee72ed8def0eecff4eace2daba7bb1d4df82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 04:14:45 +0000 Subject: [PATCH 5/6] fix: address code review comments - clarify HTTP transport vs QueryAST naming in dispatcher Agent-Logs-Url: https://github.com/objectstack-ai/spec/sessions/d243fae5-51a7-4440-926f-46f040744cb0 Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com> --- packages/runtime/src/http-dispatcher.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 91f9542d8..b80214cf8 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -595,7 +595,11 @@ export class HttpDispatcher { // Spec-canonical keys. const normalized: Record = { ...query }; - // filter/filters → where (@deprecated filter is still the canonical HTTP param) + // filter/filters → where + // Note: `filter` is the canonical HTTP *transport* parameter name + // (see HttpFindQueryParamsSchema). It is normalized here to the + // canonical *QueryAST* field name `where` before broker dispatch. + // `filters` (plural) is a deprecated alias for `filter`. if (normalized.filter != null || normalized.filters != null) { normalized.where = normalized.where ?? normalized.filter ?? normalized.filters; delete normalized.filter; From 30f69dbacb4a85546dde878f5d6f4a7da0ebfd30 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 05:16:23 +0000 Subject: [PATCH 6/6] fix: align getDiscovery capabilities with DiscoverySchema hierarchical format The DiscoverySchema.capabilities expects Record (hierarchical format), but ObjectStackProtocolImplementation.getDiscovery() was returning WellKnownCapabilities (flat booleans), causing TS2416. - Convert flat WellKnownCapabilities booleans to { enabled: boolean } objects in protocol.ts getDiscovery() - Update discovery tests to expect hierarchical format - Update client capabilities getter to normalize both hierarchical and flat formats back to WellKnownCapabilities for backward compatibility Agent-Logs-Url: https://github.com/objectstack-ai/spec/sessions/069cea55-358d-4b17-b1b8-505aaa03ccb6 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/client/src/index.ts | 13 ++++- .../objectql/src/protocol-discovery.test.ts | 48 +++++++++---------- packages/objectql/src/protocol.ts | 12 ++++- 3 files changed, 46 insertions(+), 27 deletions(-) diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index e319d4f48..842bc2259 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -312,9 +312,20 @@ export class ObjectStackClient { * Well-known capability flags discovered from the server. * Returns undefined if the client has not yet connected or the server * did not include capabilities in its discovery response. + * + * The server may return capabilities in hierarchical format + * `{ key: { enabled: boolean } }` or flat boolean format `{ key: boolean }`. + * This getter normalizes both to flat `WellKnownCapabilities`. */ get capabilities(): WellKnownCapabilities | undefined { - return this.discoveryInfo?.capabilities; + const raw = this.discoveryInfo?.capabilities; + if (!raw) return undefined; + // Normalize: hierarchical { enabled: boolean } → flat boolean + const result: Record = {}; + for (const [key, value] of Object.entries(raw)) { + result[key] = typeof value === 'object' && value !== null ? !!(value as any).enabled : !!value; + } + return result as unknown as WellKnownCapabilities; } /** diff --git a/packages/objectql/src/protocol-discovery.test.ts b/packages/objectql/src/protocol-discovery.test.ts index a2970608f..9309b88d5 100644 --- a/packages/objectql/src/protocol-discovery.test.ts +++ b/packages/objectql/src/protocol-discovery.test.ts @@ -148,14 +148,14 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => expect(discovery.capabilities).toBeDefined(); // workflow is registered but doesn't map to a well-known capability directly expect(discovery.services.workflow.enabled).toBe(true); - // All well-known capabilities should be false since workflow doesn't map to any - expect(discovery.capabilities!.feed).toBe(false); - expect(discovery.capabilities!.comments).toBe(false); - expect(discovery.capabilities!.automation).toBe(false); - expect(discovery.capabilities!.cron).toBe(false); - expect(discovery.capabilities!.search).toBe(false); - expect(discovery.capabilities!.export).toBe(false); - expect(discovery.capabilities!.chunkedUpload).toBe(false); + // All well-known capabilities should be disabled since workflow doesn't map to any + expect(discovery.capabilities!.feed).toEqual({ enabled: false }); + expect(discovery.capabilities!.comments).toEqual({ enabled: false }); + expect(discovery.capabilities!.automation).toEqual({ enabled: false }); + expect(discovery.capabilities!.cron).toEqual({ enabled: false }); + expect(discovery.capabilities!.search).toEqual({ enabled: false }); + expect(discovery.capabilities!.export).toEqual({ enabled: false }); + expect(discovery.capabilities!.chunkedUpload).toEqual({ enabled: false }); }); it('should set all capabilities to false when no services are registered', async () => { @@ -163,13 +163,13 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => const discovery = await protocol.getDiscovery(); expect(discovery.capabilities).toBeDefined(); - expect(discovery.capabilities!.feed).toBe(false); - expect(discovery.capabilities!.comments).toBe(false); - expect(discovery.capabilities!.automation).toBe(false); - expect(discovery.capabilities!.cron).toBe(false); - expect(discovery.capabilities!.search).toBe(false); - expect(discovery.capabilities!.export).toBe(false); - expect(discovery.capabilities!.chunkedUpload).toBe(false); + expect(discovery.capabilities!.feed).toEqual({ enabled: false }); + expect(discovery.capabilities!.comments).toEqual({ enabled: false }); + expect(discovery.capabilities!.automation).toEqual({ enabled: false }); + expect(discovery.capabilities!.cron).toEqual({ enabled: false }); + expect(discovery.capabilities!.search).toEqual({ enabled: false }); + expect(discovery.capabilities!.export).toEqual({ enabled: false }); + expect(discovery.capabilities!.chunkedUpload).toEqual({ enabled: false }); }); it('should dynamically set capabilities based on registered services', async () => { @@ -182,13 +182,13 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); const discovery = await protocol.getDiscovery(); - expect(discovery.capabilities!.feed).toBe(true); - expect(discovery.capabilities!.comments).toBe(true); - expect(discovery.capabilities!.automation).toBe(true); - expect(discovery.capabilities!.cron).toBe(false); - expect(discovery.capabilities!.search).toBe(true); - expect(discovery.capabilities!.export).toBe(true); - expect(discovery.capabilities!.chunkedUpload).toBe(true); + expect(discovery.capabilities!.feed).toEqual({ enabled: true }); + expect(discovery.capabilities!.comments).toEqual({ enabled: true }); + expect(discovery.capabilities!.automation).toEqual({ enabled: true }); + expect(discovery.capabilities!.cron).toEqual({ enabled: false }); + expect(discovery.capabilities!.search).toEqual({ enabled: true }); + expect(discovery.capabilities!.export).toEqual({ enabled: true }); + expect(discovery.capabilities!.chunkedUpload).toEqual({ enabled: true }); }); it('should enable cron capability when job service is registered', async () => { @@ -198,7 +198,7 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); const discovery = await protocol.getDiscovery(); - expect(discovery.capabilities!.cron).toBe(true); + expect(discovery.capabilities!.cron).toEqual({ enabled: true }); }); it('should enable export capability when queue service is registered', async () => { @@ -208,6 +208,6 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); const discovery = await protocol.getDiscovery(); - expect(discovery.capabilities!.export).toBe(true); + expect(discovery.capabilities!.export).toEqual({ enabled: true }); }); }); diff --git a/packages/objectql/src/protocol.ts b/packages/objectql/src/protocol.ts index 39de38dca..5a1b0b3d1 100644 --- a/packages/objectql/src/protocol.ts +++ b/packages/objectql/src/protocol.ts @@ -151,8 +151,10 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { ...optionalRoutes, }; - // Build well-known capabilities from registered services - const capabilities: WellKnownCapabilities = { + // Build well-known capabilities from registered services. + // DiscoverySchema defines capabilities as Record + // (hierarchical format). We also keep a flat WellKnownCapabilities for backward compat. + const wellKnown: WellKnownCapabilities = { feed: registeredServices.has('feed'), comments: registeredServices.has('feed'), automation: registeredServices.has('automation'), @@ -162,6 +164,12 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { chunkedUpload: registeredServices.has('file-storage'), }; + // Convert flat booleans → hierarchical capability objects + const capabilities: Record = {}; + for (const [key, enabled] of Object.entries(wellKnown)) { + capabilities[key] = { enabled }; + } + return { version: '1.0', apiName: 'ObjectStack API',