@object-ui/data-objectstack 3.1.5 → 3.3.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.
package/dist/index.js CHANGED
@@ -4,7 +4,72 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
4
4
 
5
5
  // src/index.ts
6
6
  import { ObjectStackClient } from "@objectstack/client";
7
- import { convertFiltersToAST } from "@object-ui/core";
7
+
8
+ // ../core/src/utils/filter-converter.ts
9
+ function convertOperatorToAST(operator) {
10
+ const operatorMap = {
11
+ "$eq": "=",
12
+ "$ne": "!=",
13
+ "$gt": ">",
14
+ "$gte": ">=",
15
+ "$lt": "<",
16
+ "$lte": "<=",
17
+ "$in": "in",
18
+ "$nin": "nin",
19
+ "$notin": "nin",
20
+ "$between": "between",
21
+ "$contains": "contains",
22
+ "$notContains": "notcontains",
23
+ "$notcontains": "notcontains",
24
+ "$startsWith": "startswith",
25
+ "$startswith": "startswith",
26
+ "$endsWith": "endswith",
27
+ "$endswith": "endswith"
28
+ };
29
+ return operatorMap[operator] || null;
30
+ }
31
+ function convertFiltersToAST(filter) {
32
+ const conditions = [];
33
+ for (const [field, value] of Object.entries(filter)) {
34
+ if (value === null || value === void 0) continue;
35
+ if (typeof value === "object" && !Array.isArray(value)) {
36
+ for (const [operator, operatorValue] of Object.entries(value)) {
37
+ if (operator === "$regex") {
38
+ console.warn(
39
+ `[ObjectUI] Warning: $regex operator is not fully supported. Converting to 'contains' which only supports substring matching, not regex patterns. Field: '${field}', Value: ${JSON.stringify(operatorValue)}. Consider using $contains or $startsWith instead.`
40
+ );
41
+ conditions.push([field, "contains", operatorValue]);
42
+ continue;
43
+ }
44
+ if (operator === "$null") {
45
+ conditions.push([field, operatorValue ? "is_null" : "is_not_null", true]);
46
+ continue;
47
+ }
48
+ if (operator === "$exists") {
49
+ conditions.push([field, operatorValue ? "is_not_null" : "is_null", true]);
50
+ continue;
51
+ }
52
+ const astOperator = convertOperatorToAST(operator);
53
+ if (astOperator) {
54
+ conditions.push([field, astOperator, operatorValue]);
55
+ } else {
56
+ throw new Error(
57
+ `[ObjectUI] Unknown filter operator '${operator}' for field '${field}'. Supported operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $between, $contains, $notContains, $startsWith, $endsWith, $null, $exists. If you need exact object matching, use the value directly without an operator.`
58
+ );
59
+ }
60
+ }
61
+ } else {
62
+ conditions.push([field, "=", value]);
63
+ }
64
+ }
65
+ if (conditions.length === 0) {
66
+ return filter;
67
+ }
68
+ if (conditions.length === 1) {
69
+ return conditions[0];
70
+ }
71
+ return ["and", ...conditions];
72
+ }
8
73
 
9
74
  // src/cache/MetadataCache.ts
10
75
  var MetadataCache = class {
@@ -17,16 +82,19 @@ var MetadataCache = class {
17
82
  */
18
83
  constructor(options = {}) {
19
84
  __publicField(this, "cache");
85
+ __publicField(this, "inflight");
20
86
  __publicField(this, "maxSize");
21
87
  __publicField(this, "ttl");
22
88
  __publicField(this, "stats");
23
89
  this.cache = /* @__PURE__ */ new Map();
90
+ this.inflight = /* @__PURE__ */ new Map();
24
91
  this.maxSize = options.maxSize || 100;
25
92
  this.ttl = options.ttl || 5 * 60 * 1e3;
26
93
  this.stats = {
27
94
  hits: 0,
28
95
  misses: 0,
29
- evictions: 0
96
+ evictions: 0,
97
+ coalesced: 0
30
98
  };
31
99
  }
32
100
  /**
@@ -52,10 +120,31 @@ var MetadataCache = class {
52
120
  this.cache.delete(key);
53
121
  }
54
122
  }
123
+ const existing = this.inflight.get(key);
124
+ if (existing) {
125
+ this.stats.coalesced++;
126
+ return existing;
127
+ }
55
128
  this.stats.misses++;
56
- const data = await fetcher();
129
+ const promise = (async () => {
130
+ try {
131
+ const data = await fetcher();
132
+ this.set(key, data);
133
+ return data;
134
+ } finally {
135
+ this.inflight.delete(key);
136
+ }
137
+ })();
138
+ this.inflight.set(key, promise);
139
+ return promise;
140
+ }
141
+ /**
142
+ * Prime the cache with a pre-fetched value. Useful when a bulk endpoint
143
+ * (e.g. list of all object schemas) returns data that would otherwise
144
+ * be fetched again per item.
145
+ */
146
+ prime(key, data) {
57
147
  this.set(key, data);
58
- return data;
59
148
  }
60
149
  /**
61
150
  * Set a value in the cache
@@ -102,10 +191,12 @@ var MetadataCache = class {
102
191
  */
103
192
  clear() {
104
193
  this.cache.clear();
194
+ this.inflight.clear();
105
195
  this.stats = {
106
196
  hits: 0,
107
197
  misses: 0,
108
- evictions: 0
198
+ evictions: 0,
199
+ coalesced: 0
109
200
  };
110
201
  }
111
202
  /**
@@ -122,6 +213,7 @@ var MetadataCache = class {
122
213
  hits: this.stats.hits,
123
214
  misses: this.stats.misses,
124
215
  evictions: this.stats.evictions,
216
+ coalesced: this.stats.coalesced,
125
217
  hitRate
126
218
  };
127
219
  }
@@ -173,9 +265,9 @@ var ObjectStackError = class extends Error {
173
265
  */
174
266
  constructor(message, code, statusCode, details) {
175
267
  super(message);
176
- this.code = code;
177
- this.statusCode = statusCode;
178
- this.details = details;
268
+ __publicField(this, "code", code);
269
+ __publicField(this, "statusCode", statusCode);
270
+ __publicField(this, "details", details);
179
271
  this.name = "ObjectStackError";
180
272
  if (Error.captureStackTrace) {
181
273
  Error.captureStackTrace(this, this.constructor);
@@ -229,9 +321,9 @@ var BulkOperationError = class extends ObjectStackError {
229
321
  ...details
230
322
  }
231
323
  );
232
- this.successCount = successCount;
233
- this.failureCount = failureCount;
234
- this.errors = errors;
324
+ __publicField(this, "successCount", successCount);
325
+ __publicField(this, "failureCount", failureCount);
326
+ __publicField(this, "errors", errors);
235
327
  this.name = "BulkOperationError";
236
328
  }
237
329
  /**
@@ -258,7 +350,7 @@ var ConnectionError = class extends ObjectStackError {
258
350
  statusCode || 503,
259
351
  { url, ...details }
260
352
  );
261
- this.url = url;
353
+ __publicField(this, "url", url);
262
354
  this.name = "ConnectionError";
263
355
  }
264
356
  };
@@ -293,8 +385,8 @@ var ValidationError = class extends ObjectStackError {
293
385
  ...details
294
386
  }
295
387
  );
296
- this.field = field;
297
- this.validationErrors = validationErrors;
388
+ __publicField(this, "field", field);
389
+ __publicField(this, "validationErrors", validationErrors);
298
390
  this.name = "ValidationError";
299
391
  }
300
392
  /**
@@ -351,7 +443,7 @@ function isErrorType(error, errorClass) {
351
443
  // src/cloud.ts
352
444
  var CloudOperations = class {
353
445
  constructor(getClient) {
354
- this.getClient = getClient;
446
+ __publicField(this, "getClient", getClient);
355
447
  }
356
448
  /**
357
449
  * Deploy an application to the cloud.
@@ -695,10 +787,26 @@ function calculateAutoLayout(items, canvasWidth, padding = 40, gap = 40) {
695
787
  }
696
788
 
697
789
  // src/index.ts
790
+ var discoveryCache = /* @__PURE__ */ new Map();
791
+ async function getSharedDiscovery(baseUrl, fetcher) {
792
+ const key = baseUrl || "<default>";
793
+ const cached = discoveryCache.get(key);
794
+ if (cached) return cached;
795
+ const p = fetcher().catch((err) => {
796
+ discoveryCache.delete(key);
797
+ throw err;
798
+ });
799
+ discoveryCache.set(key, p);
800
+ return p;
801
+ }
802
+ function clearSharedDiscoveryCache() {
803
+ discoveryCache.clear();
804
+ }
698
805
  var ObjectStackAdapter = class {
699
806
  constructor(config) {
700
807
  __publicField(this, "client");
701
808
  __publicField(this, "connected", false);
809
+ __publicField(this, "connectPromise", null);
702
810
  __publicField(this, "metadataCache");
703
811
  __publicField(this, "connectionState", "disconnected");
704
812
  __publicField(this, "connectionStateListeners", []);
@@ -724,10 +832,25 @@ var ObjectStackAdapter = class {
724
832
  * Call this before making requests or it will auto-connect on first request.
725
833
  */
726
834
  async connect() {
727
- if (!this.connected) {
728
- this.setConnectionState("connecting");
835
+ if (this.connected) return;
836
+ if (this.connectPromise) return this.connectPromise;
837
+ this.setConnectionState("connecting");
838
+ this.connectPromise = (async () => {
729
839
  try {
730
- await this.client.connect();
840
+ const baseUrl = this.baseUrl || "";
841
+ const discoveryUrl = baseUrl ? `${baseUrl.replace(/\/$/, "")}/api/v1/discovery` : "/api/v1/discovery";
842
+ const data = await getSharedDiscovery(baseUrl, async () => {
843
+ const res = await this.fetchImpl(discoveryUrl, {
844
+ method: "GET",
845
+ headers: this.token ? { Authorization: `Bearer ${this.token}` } : void 0
846
+ });
847
+ if (!res.ok) {
848
+ throw new Error(`discovery ${res.status} ${res.statusText}`);
849
+ }
850
+ const body = await res.json();
851
+ return body && typeof body.success === "boolean" && "data" in body ? body.data : body;
852
+ });
853
+ this.client.discoveryInfo = data;
731
854
  this.connected = true;
732
855
  this.reconnectAttempts = 0;
733
856
  this.setConnectionState("connected");
@@ -744,8 +867,11 @@ var ObjectStackAdapter = class {
744
867
  } else {
745
868
  throw connectionError;
746
869
  }
870
+ } finally {
871
+ this.connectPromise = null;
747
872
  }
748
- }
873
+ })();
874
+ return this.connectPromise;
749
875
  }
750
876
  /**
751
877
  * Attempt to reconnect to the server with exponential backoff
@@ -1086,8 +1212,11 @@ var ObjectStackAdapter = class {
1086
1212
  queryParams.set("sort", sortStr);
1087
1213
  }
1088
1214
  }
1089
- if (params.$filter) {
1090
- queryParams.set("filter", JSON.stringify(params.$filter));
1215
+ if (params.$filter !== void 0 && params.$filter !== null) {
1216
+ const isEmpty = Array.isArray(params.$filter) ? params.$filter.length === 0 : typeof params.$filter === "object" && Object.keys(params.$filter).length === 0;
1217
+ if (!isEmpty) {
1218
+ queryParams.set("filter", JSON.stringify(params.$filter));
1219
+ }
1091
1220
  }
1092
1221
  const baseUrl = this.baseUrl.replace(/\/$/, "");
1093
1222
  const qs = queryParams.toString();
@@ -1123,11 +1252,14 @@ var ObjectStackAdapter = class {
1123
1252
  if (params.$select) {
1124
1253
  options.select = params.$select;
1125
1254
  }
1126
- if (params.$filter) {
1127
- if (Array.isArray(params.$filter)) {
1128
- options.filters = params.$filter;
1129
- } else {
1130
- options.filters = convertFiltersToAST(params.$filter);
1255
+ if (params.$filter !== void 0 && params.$filter !== null) {
1256
+ const isEmpty = Array.isArray(params.$filter) ? params.$filter.length === 0 : typeof params.$filter === "object" && Object.keys(params.$filter).length === 0;
1257
+ if (!isEmpty) {
1258
+ if (Array.isArray(params.$filter)) {
1259
+ options.filters = params.$filter;
1260
+ } else {
1261
+ options.filters = convertFiltersToAST(params.$filter);
1262
+ }
1131
1263
  }
1132
1264
  }
1133
1265
  if (params.$orderby) {
@@ -1278,19 +1410,26 @@ var ObjectStackAdapter = class {
1278
1410
  async aggregate(resource, params) {
1279
1411
  await this.connect();
1280
1412
  try {
1413
+ const measureName = params.function === "count" ? "count" : `${params.field}_${params.function}`;
1281
1414
  const payload = {
1282
- object: resource,
1283
- measures: [{ field: params.field, function: params.function }],
1284
- dimensions: [params.groupBy]
1415
+ cube: resource,
1416
+ measures: [measureName],
1417
+ // When groupBy is '_all' no dimensions are needed (single-bucket).
1418
+ dimensions: params.groupBy && params.groupBy !== "_all" ? [params.groupBy] : []
1285
1419
  };
1286
1420
  if (params.filter) {
1287
1421
  payload.filters = params.filter;
1288
1422
  }
1289
1423
  const data = await this.client.analytics.query(payload);
1290
- if (Array.isArray(data)) return data;
1291
- if (data?.data && Array.isArray(data.data)) return data.data;
1292
- if (data?.results && Array.isArray(data.results)) return data.results;
1293
- return [];
1424
+ const rawRows = Array.isArray(data) ? data : data?.rows && Array.isArray(data.rows) ? data.rows : data?.data && Array.isArray(data.data) ? data.data : data?.data?.rows && Array.isArray(data.data.rows) ? data.data.rows : data?.results && Array.isArray(data.results) ? data.results : [];
1425
+ return rawRows.map((row) => {
1426
+ const mapped = { ...row };
1427
+ if (measureName !== params.field && measureName in mapped) {
1428
+ mapped[params.field] = mapped[measureName];
1429
+ delete mapped[measureName];
1430
+ }
1431
+ return mapped;
1432
+ });
1294
1433
  } catch {
1295
1434
  const result = await this.find(resource);
1296
1435
  const records = result.data || [];
@@ -1484,10 +1623,12 @@ export {
1484
1623
  SecurityManager,
1485
1624
  ValidationError,
1486
1625
  calculateAutoLayout,
1626
+ clearSharedDiscoveryCache,
1487
1627
  createDefaultCanvasConfig,
1488
1628
  createErrorFromResponse,
1489
1629
  createObjectStackAdapter,
1490
1630
  generateContractManifest,
1631
+ getSharedDiscovery,
1491
1632
  isErrorType,
1492
1633
  isObjectStackError,
1493
1634
  snapToGrid,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/data-objectstack",
3
- "version": "3.1.5",
3
+ "version": "3.3.1",
4
4
  "description": "ObjectStack Data Adapter for Object UI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -17,24 +17,49 @@
17
17
  "files": [
18
18
  "dist",
19
19
  "src",
20
- "README.md"
20
+ "README.md",
21
+ "CHANGELOG.md",
22
+ "LICENSE"
21
23
  ],
22
24
  "dependencies": {
23
- "@objectstack/client": "^3.3.0",
24
- "@object-ui/core": "3.1.5",
25
- "@object-ui/types": "3.1.5"
25
+ "@objectstack/client": "^4.0.4",
26
+ "@object-ui/core": "3.3.1",
27
+ "@object-ui/types": "3.3.1"
26
28
  },
27
29
  "devDependencies": {
28
30
  "tsup": "^8.5.1",
29
- "typescript": "^5.9.3",
30
- "vitest": "^4.1.0"
31
+ "typescript": "^6.0.3",
32
+ "vitest": "^4.1.5"
31
33
  },
32
34
  "publishConfig": {
33
35
  "access": "public"
34
36
  },
37
+ "keywords": [
38
+ "objectui",
39
+ "sdui",
40
+ "schema-driven-ui",
41
+ "react",
42
+ "tailwind",
43
+ "shadcn",
44
+ "objectstack",
45
+ "data-source",
46
+ "adapter",
47
+ "objectql",
48
+ "objectstack-client"
49
+ ],
50
+ "author": "ObjectStack Team <team@objectstack.ai>",
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "git+https://github.com/objectstack-ai/objectui.git",
54
+ "directory": "packages/data-objectstack"
55
+ },
56
+ "bugs": {
57
+ "url": "https://github.com/objectstack-ai/objectui/issues"
58
+ },
59
+ "homepage": "https://www.objectui.org/docs/guide/data-source",
35
60
  "scripts": {
36
- "build": "tsup src/index.ts --format cjs,esm --dts",
37
- "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
61
+ "build": "tsup",
62
+ "dev": "tsup --watch",
38
63
  "clean": "rm -rf dist",
39
64
  "type-check": "tsc --noEmit",
40
65
  "test": "vitest run",
@@ -0,0 +1,277 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
10
+ import { ObjectStackAdapter } from './index';
11
+
12
+ /**
13
+ * Tests for ObjectStackAdapter.aggregate() — verifies that the analytics
14
+ * query payload uses the correct string-based measure/dimension format
15
+ * expected by the backend analytics service (MemoryAnalyticsService).
16
+ *
17
+ * See: https://github.com/objectstack-ai/objectui/issues (measures format bug)
18
+ */
19
+ describe('ObjectStackAdapter aggregate()', () => {
20
+ let adapter: ObjectStackAdapter;
21
+ let mockAnalyticsQuery: ReturnType<typeof vi.fn>;
22
+
23
+ beforeEach(() => {
24
+ mockAnalyticsQuery = vi.fn().mockResolvedValue({ data: [] });
25
+
26
+ adapter = new ObjectStackAdapter({
27
+ baseUrl: 'http://localhost:3000',
28
+ autoReconnect: false,
29
+ });
30
+
31
+ // Inject mock client and mark as connected to bypass connect()
32
+ (adapter as any).client = {
33
+ data: {
34
+ find: vi.fn().mockResolvedValue({ records: [], total: 0 }),
35
+ },
36
+ analytics: {
37
+ query: mockAnalyticsQuery,
38
+ },
39
+ connect: vi.fn().mockResolvedValue(undefined),
40
+ discover: vi.fn().mockResolvedValue({ status: 'ok' }),
41
+ };
42
+ (adapter as any).connected = true;
43
+ });
44
+
45
+ it('should send measures as string array with field_function format for sum', async () => {
46
+ mockAnalyticsQuery.mockResolvedValue({ data: [] });
47
+
48
+ await adapter.aggregate('opportunity', {
49
+ field: 'amount',
50
+ function: 'sum',
51
+ groupBy: 'stage',
52
+ });
53
+
54
+ expect(mockAnalyticsQuery).toHaveBeenCalledWith({
55
+ cube: 'opportunity',
56
+ measures: ['amount_sum'],
57
+ dimensions: ['stage'],
58
+ });
59
+ });
60
+
61
+ it('should send measures as ["count"] for count aggregation', async () => {
62
+ mockAnalyticsQuery.mockResolvedValue({ data: [] });
63
+
64
+ await adapter.aggregate('opportunity', {
65
+ field: 'amount',
66
+ function: 'count',
67
+ groupBy: 'stage',
68
+ });
69
+
70
+ expect(mockAnalyticsQuery).toHaveBeenCalledWith({
71
+ cube: 'opportunity',
72
+ measures: ['count'],
73
+ dimensions: ['stage'],
74
+ });
75
+ });
76
+
77
+ it('should send measures as string for avg aggregation', async () => {
78
+ mockAnalyticsQuery.mockResolvedValue({ data: [] });
79
+
80
+ await adapter.aggregate('opportunity', {
81
+ field: 'amount',
82
+ function: 'avg',
83
+ groupBy: 'stage',
84
+ });
85
+
86
+ expect(mockAnalyticsQuery).toHaveBeenCalledWith({
87
+ cube: 'opportunity',
88
+ measures: ['amount_avg'],
89
+ dimensions: ['stage'],
90
+ });
91
+ });
92
+
93
+ it('should send empty dimensions when groupBy is _all', async () => {
94
+ mockAnalyticsQuery.mockResolvedValue({ data: [] });
95
+
96
+ await adapter.aggregate('opportunity', {
97
+ field: 'amount',
98
+ function: 'sum',
99
+ groupBy: '_all',
100
+ });
101
+
102
+ expect(mockAnalyticsQuery).toHaveBeenCalledWith({
103
+ cube: 'opportunity',
104
+ measures: ['amount_sum'],
105
+ dimensions: [],
106
+ });
107
+ });
108
+
109
+ it('should include filters in payload when provided', async () => {
110
+ const filter = [{ member: 'stage', operator: 'equals', values: ['Closed Won'] }];
111
+ mockAnalyticsQuery.mockResolvedValue({ data: [] });
112
+
113
+ await adapter.aggregate('opportunity', {
114
+ field: 'amount',
115
+ function: 'sum',
116
+ groupBy: 'stage',
117
+ filter,
118
+ });
119
+
120
+ expect(mockAnalyticsQuery).toHaveBeenCalledWith({
121
+ cube: 'opportunity',
122
+ measures: ['amount_sum'],
123
+ dimensions: ['stage'],
124
+ filters: filter,
125
+ });
126
+ });
127
+
128
+ it('should map measure key back to field name in response', async () => {
129
+ mockAnalyticsQuery.mockResolvedValue({
130
+ data: [
131
+ { stage: 'Prospect', amount_sum: 300 },
132
+ { stage: 'Closed Won', amount_sum: 500 },
133
+ ],
134
+ });
135
+
136
+ const result = await adapter.aggregate('opportunity', {
137
+ field: 'amount',
138
+ function: 'sum',
139
+ groupBy: 'stage',
140
+ });
141
+
142
+ expect(result).toEqual([
143
+ { stage: 'Prospect', amount: 300 },
144
+ { stage: 'Closed Won', amount: 500 },
145
+ ]);
146
+ });
147
+
148
+ it('should map count measure back to field name in response', async () => {
149
+ mockAnalyticsQuery.mockResolvedValue({
150
+ data: [
151
+ { stage: 'Prospect', count: 5 },
152
+ { stage: 'Closed Won', count: 3 },
153
+ ],
154
+ });
155
+
156
+ const result = await adapter.aggregate('opportunity', {
157
+ field: 'amount',
158
+ function: 'count',
159
+ groupBy: 'stage',
160
+ });
161
+
162
+ expect(result).toEqual([
163
+ { stage: 'Prospect', amount: 5 },
164
+ { stage: 'Closed Won', amount: 3 },
165
+ ]);
166
+ });
167
+
168
+ it('should handle direct array response from analytics', async () => {
169
+ mockAnalyticsQuery.mockResolvedValue([
170
+ { stage: 'Prospect', amount_sum: 300 },
171
+ ]);
172
+
173
+ const result = await adapter.aggregate('opportunity', {
174
+ field: 'amount',
175
+ function: 'sum',
176
+ groupBy: 'stage',
177
+ });
178
+
179
+ expect(result).toEqual([
180
+ { stage: 'Prospect', amount: 300 },
181
+ ]);
182
+ });
183
+
184
+ it('should handle results wrapper in response', async () => {
185
+ mockAnalyticsQuery.mockResolvedValue({
186
+ results: [
187
+ { stage: 'Prospect', amount_avg: 150 },
188
+ ],
189
+ });
190
+
191
+ const result = await adapter.aggregate('opportunity', {
192
+ field: 'amount',
193
+ function: 'avg',
194
+ groupBy: 'stage',
195
+ });
196
+
197
+ expect(result).toEqual([
198
+ { stage: 'Prospect', amount: 150 },
199
+ ]);
200
+ });
201
+
202
+ it('should extract rows from { rows: [...] } envelope (SDK unwraps outer success/data)', async () => {
203
+ mockAnalyticsQuery.mockResolvedValue({
204
+ rows: [
205
+ { stage: 'closed_won', expected_revenue_sum: 225000 },
206
+ { stage: 'negotiation', expected_revenue_sum: 36000 },
207
+ ],
208
+ fields: [
209
+ { name: 'stage', type: 'string' },
210
+ { name: 'expected_revenue_sum', type: 'number' },
211
+ ],
212
+ });
213
+
214
+ const result = await adapter.aggregate('opportunity', {
215
+ field: 'expected_revenue',
216
+ function: 'sum',
217
+ groupBy: 'stage',
218
+ });
219
+
220
+ expect(result).toEqual([
221
+ { stage: 'closed_won', expected_revenue: 225000 },
222
+ { stage: 'negotiation', expected_revenue: 36000 },
223
+ ]);
224
+ });
225
+
226
+ it('should extract rows from { data: { rows: [...] } } envelope (SDK does not unwrap)', async () => {
227
+ mockAnalyticsQuery.mockResolvedValue({
228
+ success: true,
229
+ data: {
230
+ rows: [
231
+ { stage: 'closed_won', expected_revenue_sum: 225000 },
232
+ { stage: 'negotiation', expected_revenue_sum: 36000 },
233
+ ],
234
+ fields: [
235
+ { name: 'stage', type: 'string' },
236
+ { name: 'expected_revenue_sum', type: 'number' },
237
+ ],
238
+ sql: '-- MongoDB Aggregation Pipeline',
239
+ },
240
+ });
241
+
242
+ const result = await adapter.aggregate('opportunity', {
243
+ field: 'expected_revenue',
244
+ function: 'sum',
245
+ groupBy: 'stage',
246
+ });
247
+
248
+ expect(result).toEqual([
249
+ { stage: 'closed_won', expected_revenue: 225000 },
250
+ { stage: 'negotiation', expected_revenue: 36000 },
251
+ ]);
252
+ });
253
+
254
+ it('should fall back to client-side aggregation when analytics endpoint fails', async () => {
255
+ mockAnalyticsQuery.mockRejectedValue(new Error('Analytics not available'));
256
+
257
+ // Mock find() to return records for client-side aggregation
258
+ (adapter as any).client.data.find = vi.fn().mockResolvedValue({
259
+ records: [
260
+ { stage: 'Prospect', amount: 100 },
261
+ { stage: 'Prospect', amount: 200 },
262
+ { stage: 'Closed Won', amount: 500 },
263
+ ],
264
+ total: 3,
265
+ });
266
+
267
+ const result = await adapter.aggregate('opportunity', {
268
+ field: 'amount',
269
+ function: 'sum',
270
+ groupBy: 'stage',
271
+ });
272
+
273
+ expect(result).toHaveLength(2);
274
+ expect(result.find((r: any) => r.stage === 'Prospect')?.amount).toBe(300);
275
+ expect(result.find((r: any) => r.stage === 'Closed Won')?.amount).toBe(500);
276
+ });
277
+ });