@objectstack/client 3.3.1 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -52,14 +52,14 @@ describe('ObjectStackClient (with Hono Server)', () => {
52
52
  if (protocol) {
53
53
  return await protocol.findData({ object: params.object, query: params.query || params.filters });
54
54
  }
55
- const records = await ql.find(params.object, { filter: params.filters });
55
+ const records = await ql.find(params.object, { where: params.filters });
56
56
  return { object: params.object, records, total: records.length };
57
57
  }
58
58
  if (method === 'find') {
59
59
  if (protocol) {
60
60
  return await protocol.findData({ object: params.object, query: params.query || params.filters });
61
61
  }
62
- const records = await ql.find(params.object, { filter: params.filters });
62
+ const records = await ql.find(params.object, { where: params.filters });
63
63
  return { object: params.object, records, total: records.length };
64
64
  }
65
65
  }
@@ -57,7 +57,7 @@ describe('ObjectStackClient (with MSW Plugin)', () => {
57
57
  return await protocol.findData({ object: params.object, query: params.query });
58
58
  }
59
59
  const queryOpts = params.query || {};
60
- const records = await ql.find(params.object, { filter: queryOpts.filters || queryOpts.filter });
60
+ const records = await ql.find(params.object, { where: queryOpts.filters || queryOpts.filter || queryOpts.where });
61
61
  return { object: params.object, records, total: records.length };
62
62
  }
63
63
  if (method === 'find') {
@@ -65,7 +65,7 @@ describe('ObjectStackClient (with MSW Plugin)', () => {
65
65
  return await protocol.findData({ object: params.object, query: params.query });
66
66
  }
67
67
  const queryOpts = params.query || {};
68
- const records = await ql.find(params.object, { filter: queryOpts.filters || queryOpts.filter });
68
+ const records = await ql.find(params.object, { where: queryOpts.filters || queryOpts.filter || queryOpts.where });
69
69
  return { object: params.object, records, total: records.length };
70
70
  }
71
71
  }
@@ -827,3 +827,73 @@ describe('ObjectStackClient.automation', () => {
827
827
  expect(client.capabilities!.search).toBe(true);
828
828
  });
829
829
  });
830
+
831
+ // ==========================================
832
+ // QueryOptionsV2 (Canonical Query Syntax) Tests
833
+ // ==========================================
834
+
835
+ describe('QueryOptionsV2 — canonical find()', () => {
836
+ it('should accept canonical field names (where, fields, orderBy, limit, offset)', async () => {
837
+ const { client, fetchMock } = createMockClient({
838
+ success: true,
839
+ data: { object: 'account', records: [], total: 0 }
840
+ });
841
+
842
+ await client.data.find('account', {
843
+ where: { status: 'active' },
844
+ fields: ['name', 'email'],
845
+ orderBy: ['-created_at'],
846
+ limit: 10,
847
+ offset: 5,
848
+ });
849
+
850
+ const url = fetchMock.mock.calls[0][0] as string;
851
+ // V2 canonical options are normalized to HTTP transport params
852
+ expect(url).toContain('top=10');
853
+ expect(url).toContain('skip=5');
854
+ expect(url).toContain('select=name%2Cemail');
855
+ expect(url).toContain('sort=-created_at');
856
+ // where → filter as JSON
857
+ expect(url).toContain('status=active');
858
+ });
859
+
860
+ it('should still accept legacy field names (filter, select, sort, top, skip)', async () => {
861
+ const { client, fetchMock } = createMockClient({
862
+ success: true,
863
+ data: { object: 'account', records: [], total: 0 }
864
+ });
865
+
866
+ await client.data.find('account', {
867
+ filter: { industry: 'Tech' },
868
+ select: ['name'],
869
+ sort: ['-revenue'],
870
+ top: 20,
871
+ skip: 0,
872
+ });
873
+
874
+ const url = fetchMock.mock.calls[0][0] as string;
875
+ expect(url).toContain('top=20');
876
+ expect(url).toContain('select=name');
877
+ expect(url).toContain('sort=-revenue');
878
+ expect(url).toContain('industry=Tech');
879
+ });
880
+ });
881
+
882
+ describe('QueryBuilder — offset() alias', () => {
883
+ it('should set offset via .offset() method', () => {
884
+ const q = createQuery('task')
885
+ .limit(10)
886
+ .offset(20)
887
+ .build();
888
+ expect(q.limit).toBe(10);
889
+ expect(q.offset).toBe(20);
890
+ });
891
+
892
+ it('should set offset via deprecated .skip() method', () => {
893
+ const q = createQuery('task')
894
+ .limit(10)
895
+ .skip(30)
896
+ .build();
897
+ expect(q.offset).toBe(30);
898
+ });
899
+ });
package/src/index.ts CHANGED
@@ -87,9 +87,17 @@ import {
87
87
  SubscribeResponse,
88
88
  UnsubscribeResponse,
89
89
  WellKnownCapabilities,
90
+ ApiRoutes,
90
91
  } from '@objectstack/spec/api';
91
92
  import { Logger, createLogger } from '@objectstack/core';
92
93
 
94
+ /**
95
+ * Route types that the client can resolve.
96
+ * Covers all keys from `ApiRoutes` (the discovery schema) plus
97
+ * client-specific virtual routes (`views`, `permissions`).
98
+ */
99
+ export type ApiRouteType = keyof ApiRoutes | 'views' | 'permissions';
100
+
93
101
  export interface ClientConfig {
94
102
  baseUrl: string;
95
103
  token?: string;
@@ -113,6 +121,16 @@ export interface ClientConfig {
113
121
  */
114
122
  export type DiscoveryResult = GetDiscoveryResponse;
115
123
 
124
+ /**
125
+ * @deprecated Use `data.query()` with standard QueryAST parameters instead.
126
+ * This interface uses legacy parameter names (filter/sort/top/skip) that
127
+ * require translation to QueryAST. Prefer QueryAST fields directly:
128
+ * - filter → where
129
+ * - select → fields
130
+ * - sort → orderBy
131
+ * - skip → offset
132
+ * - top → limit
133
+ */
116
134
  export interface QueryOptions {
117
135
  select?: string[]; // Simplified Selection
118
136
  /** @canonical Preferred filter parameter (singular). */
@@ -127,6 +145,37 @@ export interface QueryOptions {
127
145
  groupBy?: string[];
128
146
  }
129
147
 
148
+ /**
149
+ * Canonical query options using Spec protocol field names.
150
+ * This is the recommended interface for `data.find()` queries.
151
+ *
152
+ * Canonical field mapping (QueryAST-aligned):
153
+ * - `where` — filter conditions (replaces legacy `filter`/`filters`)
154
+ * - `fields` — field selection (replaces legacy `select`)
155
+ * - `orderBy` — sort definition (replaces legacy `sort`)
156
+ * - `limit` — max records (replaces legacy `top`)
157
+ * - `offset` — skip records (replaces legacy `skip`)
158
+ * - `expand` — relation loading (replaces legacy `populate`)
159
+ */
160
+ export interface QueryOptionsV2 {
161
+ /** Filter conditions (WHERE clause). Accepts MongoDB-style $op object or FilterCondition AST. */
162
+ where?: Record<string, any> | unknown[];
163
+ /** Fields to retrieve (SELECT clause). */
164
+ fields?: string[];
165
+ /** Sort definition (ORDER BY clause). */
166
+ orderBy?: string | string[] | SortNode[];
167
+ /** Maximum number of records to return (LIMIT). */
168
+ limit?: number;
169
+ /** Number of records to skip (OFFSET). */
170
+ offset?: number;
171
+ /** Relations to expand (JOIN / eager-load). */
172
+ expand?: Record<string, any> | string[];
173
+ /** Aggregation functions. */
174
+ aggregations?: AggregationNode[];
175
+ /** Group by fields. */
176
+ groupBy?: string[];
177
+ }
178
+
130
179
  export interface PaginatedResult<T = any> {
131
180
  /** Spec-compliant: array of matching records */
132
181
  records: T[];
@@ -228,10 +277,10 @@ export class ObjectStackClient {
228
277
  this.logger.debug('Standard discovery probe failed', { error: (e as Error).message });
229
278
  }
230
279
 
231
- // 2. Fallback to Legacy/Direct Path /api/v1
280
+ // 2. Fallback to Protocol-standard Discovery Path /api/v1/discovery
232
281
  if (!data) {
233
- const fallbackUrl = `${this.baseUrl}/api/v1`;
234
- this.logger.debug('Falling back to legacy discovery', { url: fallbackUrl });
282
+ const fallbackUrl = `${this.baseUrl}/api/v1/discovery`;
283
+ this.logger.debug('Falling back to standard discovery endpoint', { url: fallbackUrl });
235
284
  const res = await this.fetchImpl(fallbackUrl);
236
285
  if (!res.ok) {
237
286
  throw new Error(`Failed to connect to ${fallbackUrl}: ${res.statusText}`);
@@ -263,9 +312,20 @@ export class ObjectStackClient {
263
312
  * Well-known capability flags discovered from the server.
264
313
  * Returns undefined if the client has not yet connected or the server
265
314
  * did not include capabilities in its discovery response.
315
+ *
316
+ * The server may return capabilities in hierarchical format
317
+ * `{ key: { enabled: boolean } }` or flat boolean format `{ key: boolean }`.
318
+ * This getter normalizes both to flat `WellKnownCapabilities`.
266
319
  */
267
320
  get capabilities(): WellKnownCapabilities | undefined {
268
- return this.discoveryInfo?.capabilities;
321
+ const raw = this.discoveryInfo?.capabilities;
322
+ if (!raw) return undefined;
323
+ // Normalize: hierarchical { enabled: boolean } → flat boolean
324
+ const result: Record<string, boolean> = {};
325
+ for (const [key, value] of Object.entries(raw)) {
326
+ result[key] = typeof value === 'object' && value !== null ? !!(value as any).enabled : !!value;
327
+ }
328
+ return result as unknown as WellKnownCapabilities;
269
329
  }
270
330
 
271
331
  /**
@@ -1234,7 +1294,7 @@ export class ObjectStackClient {
1234
1294
  * List feed items for a record
1235
1295
  */
1236
1296
  list: async (object: string, recordId: string, options?: { type?: string; limit?: number; cursor?: string }): Promise<GetFeedResponse> => {
1237
- const route = this.getRoute('feed');
1297
+ const route = this.getRoute('data');
1238
1298
  const params = new URLSearchParams();
1239
1299
  if (options?.type) params.set('type', options.type);
1240
1300
  if (options?.limit) params.set('limit', String(options.limit));
@@ -1248,7 +1308,7 @@ export class ObjectStackClient {
1248
1308
  * Create a new feed item (comment, note, task, etc.)
1249
1309
  */
1250
1310
  create: async (object: string, recordId: string, data: { type: string; body?: string; mentions?: any[]; parentId?: string; visibility?: string }): Promise<CreateFeedItemResponse> => {
1251
- const route = this.getRoute('feed');
1311
+ const route = this.getRoute('data');
1252
1312
  const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed`, {
1253
1313
  method: 'POST',
1254
1314
  body: JSON.stringify(data)
@@ -1260,7 +1320,7 @@ export class ObjectStackClient {
1260
1320
  * Update an existing feed item
1261
1321
  */
1262
1322
  update: async (object: string, recordId: string, feedId: string, data: { body?: string; mentions?: any[]; visibility?: string }): Promise<UpdateFeedItemResponse> => {
1263
- const route = this.getRoute('feed');
1323
+ const route = this.getRoute('data');
1264
1324
  const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed/${encodeURIComponent(feedId)}`, {
1265
1325
  method: 'PUT',
1266
1326
  body: JSON.stringify(data)
@@ -1272,7 +1332,7 @@ export class ObjectStackClient {
1272
1332
  * Delete a feed item
1273
1333
  */
1274
1334
  delete: async (object: string, recordId: string, feedId: string): Promise<DeleteFeedItemResponse> => {
1275
- const route = this.getRoute('feed');
1335
+ const route = this.getRoute('data');
1276
1336
  const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed/${encodeURIComponent(feedId)}`, {
1277
1337
  method: 'DELETE'
1278
1338
  });
@@ -1283,7 +1343,7 @@ export class ObjectStackClient {
1283
1343
  * Add an emoji reaction to a feed item
1284
1344
  */
1285
1345
  addReaction: async (object: string, recordId: string, feedId: string, emoji: string): Promise<AddReactionResponse> => {
1286
- const route = this.getRoute('feed');
1346
+ const route = this.getRoute('data');
1287
1347
  const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed/${encodeURIComponent(feedId)}/reactions`, {
1288
1348
  method: 'POST',
1289
1349
  body: JSON.stringify({ emoji })
@@ -1295,7 +1355,7 @@ export class ObjectStackClient {
1295
1355
  * Remove an emoji reaction from a feed item
1296
1356
  */
1297
1357
  removeReaction: async (object: string, recordId: string, feedId: string, emoji: string): Promise<RemoveReactionResponse> => {
1298
- const route = this.getRoute('feed');
1358
+ const route = this.getRoute('data');
1299
1359
  const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed/${encodeURIComponent(feedId)}/reactions/${encodeURIComponent(emoji)}`, {
1300
1360
  method: 'DELETE'
1301
1361
  });
@@ -1306,7 +1366,7 @@ export class ObjectStackClient {
1306
1366
  * Pin a feed item to the top of the timeline
1307
1367
  */
1308
1368
  pin: async (object: string, recordId: string, feedId: string): Promise<PinFeedItemResponse> => {
1309
- const route = this.getRoute('feed');
1369
+ const route = this.getRoute('data');
1310
1370
  const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed/${encodeURIComponent(feedId)}/pin`, {
1311
1371
  method: 'POST'
1312
1372
  });
@@ -1317,7 +1377,7 @@ export class ObjectStackClient {
1317
1377
  * Unpin a feed item
1318
1378
  */
1319
1379
  unpin: async (object: string, recordId: string, feedId: string): Promise<UnpinFeedItemResponse> => {
1320
- const route = this.getRoute('feed');
1380
+ const route = this.getRoute('data');
1321
1381
  const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed/${encodeURIComponent(feedId)}/pin`, {
1322
1382
  method: 'DELETE'
1323
1383
  });
@@ -1328,7 +1388,7 @@ export class ObjectStackClient {
1328
1388
  * Star (bookmark) a feed item
1329
1389
  */
1330
1390
  star: async (object: string, recordId: string, feedId: string): Promise<StarFeedItemResponse> => {
1331
- const route = this.getRoute('feed');
1391
+ const route = this.getRoute('data');
1332
1392
  const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed/${encodeURIComponent(feedId)}/star`, {
1333
1393
  method: 'POST'
1334
1394
  });
@@ -1339,7 +1399,7 @@ export class ObjectStackClient {
1339
1399
  * Unstar a feed item
1340
1400
  */
1341
1401
  unstar: async (object: string, recordId: string, feedId: string): Promise<UnstarFeedItemResponse> => {
1342
- const route = this.getRoute('feed');
1402
+ const route = this.getRoute('data');
1343
1403
  const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/feed/${encodeURIComponent(feedId)}/star`, {
1344
1404
  method: 'DELETE'
1345
1405
  });
@@ -1350,7 +1410,7 @@ export class ObjectStackClient {
1350
1410
  * Search feed items
1351
1411
  */
1352
1412
  search: async (object: string, recordId: string, query: string, options?: { type?: string; actorId?: string; dateFrom?: string; dateTo?: string; limit?: number; cursor?: string }): Promise<SearchFeedResponse> => {
1353
- const route = this.getRoute('feed');
1413
+ const route = this.getRoute('data');
1354
1414
  const params = new URLSearchParams();
1355
1415
  params.set('query', query);
1356
1416
  if (options?.type) params.set('type', options.type);
@@ -1367,7 +1427,7 @@ export class ObjectStackClient {
1367
1427
  * Get field-level changelog for a record
1368
1428
  */
1369
1429
  getChangelog: async (object: string, recordId: string, options?: { field?: string; actorId?: string; dateFrom?: string; dateTo?: string; limit?: number; cursor?: string }): Promise<GetChangelogResponse> => {
1370
- const route = this.getRoute('feed');
1430
+ const route = this.getRoute('data');
1371
1431
  const params = new URLSearchParams();
1372
1432
  if (options?.field) params.set('field', options.field);
1373
1433
  if (options?.actorId) params.set('actorId', options.actorId);
@@ -1384,7 +1444,7 @@ export class ObjectStackClient {
1384
1444
  * Subscribe to record notifications
1385
1445
  */
1386
1446
  subscribe: async (object: string, recordId: string, options?: { events?: string[]; channels?: string[] }): Promise<SubscribeResponse> => {
1387
- const route = this.getRoute('feed');
1447
+ const route = this.getRoute('data');
1388
1448
  const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/subscribe`, {
1389
1449
  method: 'POST',
1390
1450
  body: JSON.stringify(options || {})
@@ -1396,7 +1456,7 @@ export class ObjectStackClient {
1396
1456
  * Unsubscribe from record notifications
1397
1457
  */
1398
1458
  unsubscribe: async (object: string, recordId: string): Promise<UnsubscribeResponse> => {
1399
- const route = this.getRoute('feed');
1459
+ const route = this.getRoute('data');
1400
1460
  const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/subscribe`, {
1401
1461
  method: 'DELETE'
1402
1462
  });
@@ -1423,34 +1483,56 @@ export class ObjectStackClient {
1423
1483
  return this.unwrapResponse<PaginatedResult<T>>(res);
1424
1484
  },
1425
1485
 
1426
- find: async <T = any>(object: string, options: QueryOptions = {}): Promise<PaginatedResult<T>> => {
1486
+ /**
1487
+ * @deprecated Use `data.query()` with standard QueryAST parameters instead.
1488
+ * This method uses legacy parameter names. Internally adapts to HTTP GET params.
1489
+ */
1490
+ find: async <T = any>(object: string, options: QueryOptions | QueryOptionsV2 = {}): Promise<PaginatedResult<T>> => {
1427
1491
  const route = this.getRoute('data');
1428
1492
  const queryParams = new URLSearchParams();
1429
-
1493
+
1494
+ // ── Normalize V2 canonical options → HTTP transport params ───
1495
+ // Detect V2 options by presence of canonical-only keys.
1496
+ const v2 = options as QueryOptionsV2;
1497
+ const normalizedOptions: QueryOptions = {} as QueryOptions;
1498
+ if ('where' in options || 'fields' in options || 'orderBy' in options || 'offset' in options) {
1499
+ // V2 canonical options detected — map to legacy HTTP transport keys
1500
+ if (v2.where) normalizedOptions.filter = v2.where as any;
1501
+ if (v2.fields) normalizedOptions.select = v2.fields;
1502
+ if (v2.orderBy) normalizedOptions.sort = v2.orderBy as any;
1503
+ if (v2.limit != null) normalizedOptions.top = v2.limit;
1504
+ if (v2.offset != null) normalizedOptions.skip = v2.offset;
1505
+ if (v2.aggregations) normalizedOptions.aggregations = v2.aggregations;
1506
+ if (v2.groupBy) normalizedOptions.groupBy = v2.groupBy;
1507
+ } else {
1508
+ // Legacy QueryOptions — pass through as-is
1509
+ Object.assign(normalizedOptions, options);
1510
+ }
1511
+
1430
1512
  // 1. Handle Pagination
1431
- if (options.top) queryParams.set('top', options.top.toString());
1432
- if (options.skip) queryParams.set('skip', options.skip.toString());
1513
+ if (normalizedOptions.top) queryParams.set('top', normalizedOptions.top.toString());
1514
+ if (normalizedOptions.skip) queryParams.set('skip', normalizedOptions.skip.toString());
1433
1515
 
1434
1516
  // 2. Handle Sort
1435
- if (options.sort) {
1517
+ if (normalizedOptions.sort) {
1436
1518
  // Check if it's AST
1437
- if (Array.isArray(options.sort) && typeof options.sort[0] === 'object') {
1438
- queryParams.set('sort', JSON.stringify(options.sort));
1519
+ if (Array.isArray(normalizedOptions.sort) && typeof normalizedOptions.sort[0] === 'object') {
1520
+ queryParams.set('sort', JSON.stringify(normalizedOptions.sort));
1439
1521
  } else {
1440
- const sortVal = Array.isArray(options.sort) ? options.sort.join(',') : options.sort;
1522
+ const sortVal = Array.isArray(normalizedOptions.sort) ? normalizedOptions.sort.join(',') : normalizedOptions.sort;
1441
1523
  queryParams.set('sort', sortVal as string);
1442
1524
  }
1443
1525
  }
1444
1526
 
1445
1527
  // 3. Handle Select
1446
- if (options.select) {
1447
- queryParams.set('select', options.select.join(','));
1528
+ if (normalizedOptions.select) {
1529
+ queryParams.set('select', normalizedOptions.select.join(','));
1448
1530
  }
1449
1531
 
1450
1532
  // 4. Handle Filters (Simple vs AST)
1451
1533
  // Canonical HTTP param name: `filter` (singular). `filters` (plural) is accepted
1452
1534
  // for backward compatibility but `filter` is the standard going forward.
1453
- const filterValue = options.filter ?? options.filters;
1535
+ const filterValue = normalizedOptions.filter ?? normalizedOptions.filters;
1454
1536
  if (filterValue) {
1455
1537
  // Detect AST filter format vs simple key-value map. AST filters use an array structure
1456
1538
  // with [field, operator, value] or [logicOp, ...nodes] shape (see isFilterAST from spec).
@@ -1469,11 +1551,11 @@ export class ObjectStackClient {
1469
1551
  }
1470
1552
 
1471
1553
  // 5. Handle Aggregations & GroupBy (Pass through as JSON if present)
1472
- if (options.aggregations) {
1473
- queryParams.set('aggregations', JSON.stringify(options.aggregations));
1554
+ if (normalizedOptions.aggregations) {
1555
+ queryParams.set('aggregations', JSON.stringify(normalizedOptions.aggregations));
1474
1556
  }
1475
- if (options.groupBy) {
1476
- queryParams.set('groupBy', options.groupBy.join(','));
1557
+ if (normalizedOptions.groupBy) {
1558
+ queryParams.set('groupBy', normalizedOptions.groupBy.join(','));
1477
1559
  }
1478
1560
 
1479
1561
  const res = await this.fetch(`${this.baseUrl}${route}/${object}?${queryParams.toString()}`);
@@ -1663,16 +1745,20 @@ export class ObjectStackClient {
1663
1745
  * Get the conventional route path for a given API endpoint type
1664
1746
  * ObjectStack uses standard conventions: /api/v1/data, /api/v1/meta, /api/v1/ui
1665
1747
  */
1666
- private getRoute(type: 'data' | 'metadata' | 'ui' | 'auth' | 'analytics' | 'storage' | 'automation' | 'packages' | 'permissions' | 'realtime' | 'workflow' | 'views' | 'notifications' | 'ai' | 'i18n' | 'feed'): string {
1667
- // 1. Use discovered routes if available
1668
- if (this.discoveryInfo?.routes && (this.discoveryInfo.routes as any)[type]) {
1669
- return (this.discoveryInfo.routes as any)[type];
1748
+ private getRoute(type: ApiRouteType): string {
1749
+ // 1. Use discovered routes if available (only for ApiRoutes keys, not client-specific keys)
1750
+ const routes = this.discoveryInfo?.routes;
1751
+ if (routes) {
1752
+ const key = type as keyof ApiRoutes;
1753
+ const discovered = routes[key];
1754
+ if (discovered) return discovered;
1670
1755
  }
1671
1756
 
1672
- // 2. Fallback to conventions
1673
- const routeMap: Record<string, string> = {
1757
+ // 2. Fallback to conventions (covers all ApiRoutes keys + client-specific virtual routes)
1758
+ const routeMap: Record<ApiRouteType, string> = {
1674
1759
  data: '/api/v1/data',
1675
1760
  metadata: '/api/v1/meta',
1761
+ discovery: '/api/v1/discovery',
1676
1762
  ui: '/api/v1/ui',
1677
1763
  auth: '/api/v1/auth',
1678
1764
  analytics: '/api/v1/analytics',
@@ -1686,7 +1772,8 @@ export class ObjectStackClient {
1686
1772
  notifications: '/api/v1/notifications',
1687
1773
  ai: '/api/v1/ai',
1688
1774
  i18n: '/api/v1/i18n',
1689
- feed: '/api/v1/data',
1775
+ feed: '/api/v1/feed',
1776
+ graphql: '/graphql',
1690
1777
  };
1691
1778
 
1692
1779
  return routeMap[type] || `/api/v1/${type}`;
@@ -236,13 +236,22 @@ export class QueryBuilder<T = any> {
236
236
  }
237
237
 
238
238
  /**
239
- * Skip records (for pagination)
239
+ * Skip records (for pagination).
240
+ * @deprecated Prefer `.offset()` for alignment with Spec canonical field names.
240
241
  */
241
242
  skip(count: number): this {
242
243
  this.query.offset = count;
243
244
  return this;
244
245
  }
245
246
 
247
+ /**
248
+ * Offset records (for pagination) — canonical alias for `.skip()`
249
+ */
250
+ offset(count: number): this {
251
+ this.query.offset = count;
252
+ return this;
253
+ }
254
+
246
255
  /**
247
256
  * Paginate results
248
257
  */
package/tsconfig.json CHANGED
@@ -3,7 +3,8 @@
3
3
  "compilerOptions": {
4
4
  "outDir": "./dist",
5
5
  "rootDir": "./src",
6
- "lib": ["ES2020", "DOM", "DOM.Iterable"]
6
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
7
+ "types": ["node"]
7
8
  },
8
9
  "include": ["src/**/*"],
9
10
  "exclude": ["node_modules", "dist", "**/*.test.ts"]