@object-ui/data-objectstack 3.0.2 → 3.1.0

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.cjs CHANGED
@@ -749,6 +749,7 @@ var ObjectStackAdapter = class {
749
749
  __publicField(this, "reconnectAttempts", 0);
750
750
  __publicField(this, "baseUrl");
751
751
  __publicField(this, "token");
752
+ __publicField(this, "fetchImpl");
752
753
  this.client = new import_client.ObjectStackClient(config);
753
754
  this.metadataCache = new MetadataCache(config.cache);
754
755
  this.autoReconnect = config.autoReconnect ?? true;
@@ -756,6 +757,7 @@ var ObjectStackAdapter = class {
756
757
  this.reconnectDelay = config.reconnectDelay ?? 1e3;
757
758
  this.baseUrl = config.baseUrl;
758
759
  this.token = config.token;
760
+ this.fetchImpl = config.fetch || globalThis.fetch.bind(globalThis);
759
761
  }
760
762
  /**
761
763
  * Ensure the client is connected to the server.
@@ -868,34 +870,40 @@ var ObjectStackAdapter = class {
868
870
  */
869
871
  async find(resource, params) {
870
872
  await this.connect();
873
+ if (params?.$expand && params.$expand.length > 0) {
874
+ const result2 = await this.rawFindWithPopulate(resource, params);
875
+ return this.normalizeQueryResult(result2, params);
876
+ }
871
877
  const queryOptions = this.convertQueryParams(params);
872
878
  const result = await this.client.data.find(resource, queryOptions);
873
- if (Array.isArray(result)) {
874
- return {
875
- data: result,
876
- total: result.length,
877
- page: 1,
878
- pageSize: result.length,
879
- hasMore: false
880
- };
881
- }
882
- const resultObj = result;
883
- const records = resultObj.records || resultObj.value || [];
884
- const total = resultObj.total ?? resultObj.count ?? records.length;
885
- return {
886
- data: records,
887
- total,
888
- // Calculate page number safely
889
- page: params?.$skip && params.$top ? Math.floor(params.$skip / params.$top) + 1 : 1,
890
- pageSize: params?.$top,
891
- hasMore: params?.$top ? records.length === params.$top : false
892
- };
879
+ return this.normalizeQueryResult(result, params);
893
880
  }
894
881
  /**
895
882
  * Find a single record by ID.
896
883
  */
897
- async findOne(resource, id, _params) {
884
+ async findOne(resource, id, params) {
898
885
  await this.connect();
886
+ if (params?.$expand && params.$expand.length > 0) {
887
+ try {
888
+ const findParams = {
889
+ ...params,
890
+ $filter: { _id: String(id) },
891
+ $top: 1
892
+ };
893
+ const result = await this.rawFindWithPopulate(resource, findParams);
894
+ if (Array.isArray(result)) {
895
+ return result[0] || null;
896
+ }
897
+ const resultObj = result;
898
+ const records = resultObj.records || resultObj.value || [];
899
+ return records[0] || null;
900
+ } catch (error) {
901
+ if (error?.status === 404) {
902
+ return null;
903
+ }
904
+ throw error;
905
+ }
906
+ }
899
907
  try {
900
908
  const result = await this.client.data.get(resource, String(id));
901
909
  return result.record;
@@ -1060,6 +1068,92 @@ var ObjectStackAdapter = class {
1060
1068
  );
1061
1069
  }
1062
1070
  }
1071
+ /**
1072
+ * Normalize the result from data.find() or data.query() into a consistent QueryResult.
1073
+ */
1074
+ normalizeQueryResult(result, params) {
1075
+ if (Array.isArray(result)) {
1076
+ return {
1077
+ data: result,
1078
+ total: result.length,
1079
+ page: 1,
1080
+ pageSize: result.length,
1081
+ hasMore: false
1082
+ };
1083
+ }
1084
+ const resultObj = result;
1085
+ const records = resultObj.records || resultObj.value || [];
1086
+ const total = resultObj.total ?? resultObj.count ?? records.length;
1087
+ return {
1088
+ data: records,
1089
+ total,
1090
+ // Calculate page number safely
1091
+ page: params?.$skip && params.$top ? Math.floor(params.$skip / params.$top) + 1 : 1,
1092
+ pageSize: params?.$top,
1093
+ hasMore: params?.$top ? records.length === params.$top : false
1094
+ };
1095
+ }
1096
+ /**
1097
+ * Make a raw GET request to the data API with `populate` as a URL query param.
1098
+ * Used when $expand is needed, since the client SDK's data.find() does not
1099
+ * support populate/expand. The server's REST API routes GET /data/:object
1100
+ * to findData({ object, query: req.query }) which processes `populate`.
1101
+ */
1102
+ async rawFindWithPopulate(resource, params) {
1103
+ const queryParams = new URLSearchParams();
1104
+ if (params.$expand && params.$expand.length > 0) {
1105
+ queryParams.set("populate", params.$expand.join(","));
1106
+ }
1107
+ if (params.$top !== void 0) {
1108
+ queryParams.set("top", String(params.$top));
1109
+ }
1110
+ if (params.$skip !== void 0) {
1111
+ queryParams.set("skip", String(params.$skip));
1112
+ }
1113
+ if (params.$select && params.$select.length > 0) {
1114
+ queryParams.set("select", params.$select.join(","));
1115
+ }
1116
+ if (params.$orderby) {
1117
+ if (Array.isArray(params.$orderby)) {
1118
+ const sortStr = params.$orderby.map((item) => {
1119
+ if (typeof item === "string") return item;
1120
+ const field = item.field;
1121
+ const order = item.order || "asc";
1122
+ return order === "desc" ? `-${field}` : field;
1123
+ }).join(",");
1124
+ queryParams.set("sort", sortStr);
1125
+ } else {
1126
+ const sortStr = Object.entries(params.$orderby).map(([field, order]) => order === "desc" ? `-${field}` : field).join(",");
1127
+ queryParams.set("sort", sortStr);
1128
+ }
1129
+ }
1130
+ if (params.$filter) {
1131
+ queryParams.set("filter", JSON.stringify(params.$filter));
1132
+ }
1133
+ const baseUrl = this.baseUrl.replace(/\/$/, "");
1134
+ const qs = queryParams.toString();
1135
+ const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
1136
+ const dataPath = hasApiVersionSuffix ? "/data" : "/api/v1/data";
1137
+ const url = `${baseUrl}${dataPath}/${resource}${qs ? `?${qs}` : ""}`;
1138
+ const headers = {
1139
+ "Content-Type": "application/json"
1140
+ };
1141
+ if (this.token) {
1142
+ headers["Authorization"] = `Bearer ${this.token}`;
1143
+ }
1144
+ const res = await this.fetchImpl(url, { method: "GET", headers });
1145
+ if (!res.ok) {
1146
+ const errorBody = await res.json().catch(() => ({ message: res.statusText }));
1147
+ const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText);
1148
+ err.status = res.status;
1149
+ throw err;
1150
+ }
1151
+ const body = await res.json();
1152
+ if (body && typeof body.success === "boolean" && "data" in body) {
1153
+ return body.data;
1154
+ }
1155
+ return body;
1156
+ }
1063
1157
  /**
1064
1158
  * Convert ObjectUI QueryParams to ObjectStack QueryOptions.
1065
1159
  * Maps OData-style conventions to ObjectStack conventions.
@@ -1215,6 +1309,69 @@ var ObjectStackAdapter = class {
1215
1309
  return null;
1216
1310
  }
1217
1311
  }
1312
+ /**
1313
+ * Perform server-side aggregation via the ObjectStack analytics API.
1314
+ * Uses `this.client.analytics.query()` from @objectstack/client to leverage
1315
+ * the SDK's built-in auth, headers, and fetch configuration.
1316
+ * Falls back to client-side aggregation via find() if the analytics endpoint
1317
+ * is not available.
1318
+ */
1319
+ async aggregate(resource, params) {
1320
+ await this.connect();
1321
+ try {
1322
+ const payload = {
1323
+ object: resource,
1324
+ measures: [{ field: params.field, function: params.function }],
1325
+ dimensions: [params.groupBy]
1326
+ };
1327
+ if (params.filter) {
1328
+ payload.filters = params.filter;
1329
+ }
1330
+ const data = await this.client.analytics.query(payload);
1331
+ if (Array.isArray(data)) return data;
1332
+ if (data?.data && Array.isArray(data.data)) return data.data;
1333
+ if (data?.results && Array.isArray(data.results)) return data.results;
1334
+ return [];
1335
+ } catch {
1336
+ const result = await this.find(resource);
1337
+ const records = result.data || [];
1338
+ if (records.length === 0) return [];
1339
+ return this.aggregateClientSide(records, params);
1340
+ }
1341
+ }
1342
+ /** Client-side aggregation fallback */
1343
+ aggregateClientSide(records, params) {
1344
+ const { field, function: aggFn, groupBy } = params;
1345
+ const groups = {};
1346
+ for (const record of records) {
1347
+ const key = String(record[groupBy] ?? "Unknown");
1348
+ if (!groups[key]) groups[key] = [];
1349
+ groups[key].push(record);
1350
+ }
1351
+ return Object.entries(groups).map(([key, group]) => {
1352
+ const values = group.map((r) => Number(r[field]) || 0);
1353
+ let result;
1354
+ switch (aggFn) {
1355
+ case "count":
1356
+ result = group.length;
1357
+ break;
1358
+ case "avg":
1359
+ result = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
1360
+ break;
1361
+ case "min":
1362
+ result = values.length > 0 ? Math.min(...values) : 0;
1363
+ break;
1364
+ case "max":
1365
+ result = values.length > 0 ? Math.max(...values) : 0;
1366
+ break;
1367
+ case "sum":
1368
+ default:
1369
+ result = values.reduce((a, b) => a + b, 0);
1370
+ break;
1371
+ }
1372
+ return { [groupBy]: key, [field]: result };
1373
+ });
1374
+ }
1218
1375
  /**
1219
1376
  * Get multiple metadata items from ObjectStack.
1220
1377
  * Uses v3.0.0 metadata API pattern: getItems for batch retrieval.
package/dist/index.d.cts CHANGED
@@ -711,6 +711,7 @@ declare class ObjectStackAdapter<T = unknown> implements DataSource<T> {
711
711
  private reconnectAttempts;
712
712
  private baseUrl;
713
713
  private token?;
714
+ private fetchImpl;
714
715
  constructor(config: {
715
716
  baseUrl: string;
716
717
  token?: string;
@@ -764,7 +765,7 @@ declare class ObjectStackAdapter<T = unknown> implements DataSource<T> {
764
765
  /**
765
766
  * Find a single record by ID.
766
767
  */
767
- findOne(resource: string, id: string | number, _params?: QueryParams): Promise<T | null>;
768
+ findOne(resource: string, id: string | number, params?: QueryParams): Promise<T | null>;
768
769
  /**
769
770
  * Create a new record.
770
771
  */
@@ -787,6 +788,17 @@ declare class ObjectStackAdapter<T = unknown> implements DataSource<T> {
787
788
  * @returns Promise resolving to array of results
788
789
  */
789
790
  bulk(resource: string, operation: 'create' | 'update' | 'delete', data: Partial<T>[]): Promise<T[]>;
791
+ /**
792
+ * Normalize the result from data.find() or data.query() into a consistent QueryResult.
793
+ */
794
+ private normalizeQueryResult;
795
+ /**
796
+ * Make a raw GET request to the data API with `populate` as a URL query param.
797
+ * Used when $expand is needed, since the client SDK's data.find() does not
798
+ * support populate/expand. The server's REST API routes GET /data/:object
799
+ * to findData({ object, query: req.query }) which processes `populate`.
800
+ */
801
+ private rawFindWithPopulate;
790
802
  /**
791
803
  * Convert ObjectUI QueryParams to ObjectStack QueryOptions.
792
804
  * Maps OData-style conventions to ObjectStack conventions.
@@ -841,6 +853,21 @@ declare class ObjectStackAdapter<T = unknown> implements DataSource<T> {
841
853
  * Returns null if the server doesn't support page metadata.
842
854
  */
843
855
  getPage(pageId: string): Promise<unknown | null>;
856
+ /**
857
+ * Perform server-side aggregation via the ObjectStack analytics API.
858
+ * Uses `this.client.analytics.query()` from @objectstack/client to leverage
859
+ * the SDK's built-in auth, headers, and fetch configuration.
860
+ * Falls back to client-side aggregation via find() if the analytics endpoint
861
+ * is not available.
862
+ */
863
+ aggregate(resource: string, params: {
864
+ field: string;
865
+ function: string;
866
+ groupBy: string;
867
+ filter?: any;
868
+ }): Promise<any[]>;
869
+ /** Client-side aggregation fallback */
870
+ private aggregateClientSide;
844
871
  /**
845
872
  * Get multiple metadata items from ObjectStack.
846
873
  * Uses v3.0.0 metadata API pattern: getItems for batch retrieval.
package/dist/index.d.ts CHANGED
@@ -711,6 +711,7 @@ declare class ObjectStackAdapter<T = unknown> implements DataSource<T> {
711
711
  private reconnectAttempts;
712
712
  private baseUrl;
713
713
  private token?;
714
+ private fetchImpl;
714
715
  constructor(config: {
715
716
  baseUrl: string;
716
717
  token?: string;
@@ -764,7 +765,7 @@ declare class ObjectStackAdapter<T = unknown> implements DataSource<T> {
764
765
  /**
765
766
  * Find a single record by ID.
766
767
  */
767
- findOne(resource: string, id: string | number, _params?: QueryParams): Promise<T | null>;
768
+ findOne(resource: string, id: string | number, params?: QueryParams): Promise<T | null>;
768
769
  /**
769
770
  * Create a new record.
770
771
  */
@@ -787,6 +788,17 @@ declare class ObjectStackAdapter<T = unknown> implements DataSource<T> {
787
788
  * @returns Promise resolving to array of results
788
789
  */
789
790
  bulk(resource: string, operation: 'create' | 'update' | 'delete', data: Partial<T>[]): Promise<T[]>;
791
+ /**
792
+ * Normalize the result from data.find() or data.query() into a consistent QueryResult.
793
+ */
794
+ private normalizeQueryResult;
795
+ /**
796
+ * Make a raw GET request to the data API with `populate` as a URL query param.
797
+ * Used when $expand is needed, since the client SDK's data.find() does not
798
+ * support populate/expand. The server's REST API routes GET /data/:object
799
+ * to findData({ object, query: req.query }) which processes `populate`.
800
+ */
801
+ private rawFindWithPopulate;
790
802
  /**
791
803
  * Convert ObjectUI QueryParams to ObjectStack QueryOptions.
792
804
  * Maps OData-style conventions to ObjectStack conventions.
@@ -841,6 +853,21 @@ declare class ObjectStackAdapter<T = unknown> implements DataSource<T> {
841
853
  * Returns null if the server doesn't support page metadata.
842
854
  */
843
855
  getPage(pageId: string): Promise<unknown | null>;
856
+ /**
857
+ * Perform server-side aggregation via the ObjectStack analytics API.
858
+ * Uses `this.client.analytics.query()` from @objectstack/client to leverage
859
+ * the SDK's built-in auth, headers, and fetch configuration.
860
+ * Falls back to client-side aggregation via find() if the analytics endpoint
861
+ * is not available.
862
+ */
863
+ aggregate(resource: string, params: {
864
+ field: string;
865
+ function: string;
866
+ groupBy: string;
867
+ filter?: any;
868
+ }): Promise<any[]>;
869
+ /** Client-side aggregation fallback */
870
+ private aggregateClientSide;
844
871
  /**
845
872
  * Get multiple metadata items from ObjectStack.
846
873
  * Uses v3.0.0 metadata API pattern: getItems for batch retrieval.
package/dist/index.js CHANGED
@@ -709,6 +709,7 @@ var ObjectStackAdapter = class {
709
709
  __publicField(this, "reconnectAttempts", 0);
710
710
  __publicField(this, "baseUrl");
711
711
  __publicField(this, "token");
712
+ __publicField(this, "fetchImpl");
712
713
  this.client = new ObjectStackClient(config);
713
714
  this.metadataCache = new MetadataCache(config.cache);
714
715
  this.autoReconnect = config.autoReconnect ?? true;
@@ -716,6 +717,7 @@ var ObjectStackAdapter = class {
716
717
  this.reconnectDelay = config.reconnectDelay ?? 1e3;
717
718
  this.baseUrl = config.baseUrl;
718
719
  this.token = config.token;
720
+ this.fetchImpl = config.fetch || globalThis.fetch.bind(globalThis);
719
721
  }
720
722
  /**
721
723
  * Ensure the client is connected to the server.
@@ -828,34 +830,40 @@ var ObjectStackAdapter = class {
828
830
  */
829
831
  async find(resource, params) {
830
832
  await this.connect();
833
+ if (params?.$expand && params.$expand.length > 0) {
834
+ const result2 = await this.rawFindWithPopulate(resource, params);
835
+ return this.normalizeQueryResult(result2, params);
836
+ }
831
837
  const queryOptions = this.convertQueryParams(params);
832
838
  const result = await this.client.data.find(resource, queryOptions);
833
- if (Array.isArray(result)) {
834
- return {
835
- data: result,
836
- total: result.length,
837
- page: 1,
838
- pageSize: result.length,
839
- hasMore: false
840
- };
841
- }
842
- const resultObj = result;
843
- const records = resultObj.records || resultObj.value || [];
844
- const total = resultObj.total ?? resultObj.count ?? records.length;
845
- return {
846
- data: records,
847
- total,
848
- // Calculate page number safely
849
- page: params?.$skip && params.$top ? Math.floor(params.$skip / params.$top) + 1 : 1,
850
- pageSize: params?.$top,
851
- hasMore: params?.$top ? records.length === params.$top : false
852
- };
839
+ return this.normalizeQueryResult(result, params);
853
840
  }
854
841
  /**
855
842
  * Find a single record by ID.
856
843
  */
857
- async findOne(resource, id, _params) {
844
+ async findOne(resource, id, params) {
858
845
  await this.connect();
846
+ if (params?.$expand && params.$expand.length > 0) {
847
+ try {
848
+ const findParams = {
849
+ ...params,
850
+ $filter: { _id: String(id) },
851
+ $top: 1
852
+ };
853
+ const result = await this.rawFindWithPopulate(resource, findParams);
854
+ if (Array.isArray(result)) {
855
+ return result[0] || null;
856
+ }
857
+ const resultObj = result;
858
+ const records = resultObj.records || resultObj.value || [];
859
+ return records[0] || null;
860
+ } catch (error) {
861
+ if (error?.status === 404) {
862
+ return null;
863
+ }
864
+ throw error;
865
+ }
866
+ }
859
867
  try {
860
868
  const result = await this.client.data.get(resource, String(id));
861
869
  return result.record;
@@ -1020,6 +1028,92 @@ var ObjectStackAdapter = class {
1020
1028
  );
1021
1029
  }
1022
1030
  }
1031
+ /**
1032
+ * Normalize the result from data.find() or data.query() into a consistent QueryResult.
1033
+ */
1034
+ normalizeQueryResult(result, params) {
1035
+ if (Array.isArray(result)) {
1036
+ return {
1037
+ data: result,
1038
+ total: result.length,
1039
+ page: 1,
1040
+ pageSize: result.length,
1041
+ hasMore: false
1042
+ };
1043
+ }
1044
+ const resultObj = result;
1045
+ const records = resultObj.records || resultObj.value || [];
1046
+ const total = resultObj.total ?? resultObj.count ?? records.length;
1047
+ return {
1048
+ data: records,
1049
+ total,
1050
+ // Calculate page number safely
1051
+ page: params?.$skip && params.$top ? Math.floor(params.$skip / params.$top) + 1 : 1,
1052
+ pageSize: params?.$top,
1053
+ hasMore: params?.$top ? records.length === params.$top : false
1054
+ };
1055
+ }
1056
+ /**
1057
+ * Make a raw GET request to the data API with `populate` as a URL query param.
1058
+ * Used when $expand is needed, since the client SDK's data.find() does not
1059
+ * support populate/expand. The server's REST API routes GET /data/:object
1060
+ * to findData({ object, query: req.query }) which processes `populate`.
1061
+ */
1062
+ async rawFindWithPopulate(resource, params) {
1063
+ const queryParams = new URLSearchParams();
1064
+ if (params.$expand && params.$expand.length > 0) {
1065
+ queryParams.set("populate", params.$expand.join(","));
1066
+ }
1067
+ if (params.$top !== void 0) {
1068
+ queryParams.set("top", String(params.$top));
1069
+ }
1070
+ if (params.$skip !== void 0) {
1071
+ queryParams.set("skip", String(params.$skip));
1072
+ }
1073
+ if (params.$select && params.$select.length > 0) {
1074
+ queryParams.set("select", params.$select.join(","));
1075
+ }
1076
+ if (params.$orderby) {
1077
+ if (Array.isArray(params.$orderby)) {
1078
+ const sortStr = params.$orderby.map((item) => {
1079
+ if (typeof item === "string") return item;
1080
+ const field = item.field;
1081
+ const order = item.order || "asc";
1082
+ return order === "desc" ? `-${field}` : field;
1083
+ }).join(",");
1084
+ queryParams.set("sort", sortStr);
1085
+ } else {
1086
+ const sortStr = Object.entries(params.$orderby).map(([field, order]) => order === "desc" ? `-${field}` : field).join(",");
1087
+ queryParams.set("sort", sortStr);
1088
+ }
1089
+ }
1090
+ if (params.$filter) {
1091
+ queryParams.set("filter", JSON.stringify(params.$filter));
1092
+ }
1093
+ const baseUrl = this.baseUrl.replace(/\/$/, "");
1094
+ const qs = queryParams.toString();
1095
+ const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
1096
+ const dataPath = hasApiVersionSuffix ? "/data" : "/api/v1/data";
1097
+ const url = `${baseUrl}${dataPath}/${resource}${qs ? `?${qs}` : ""}`;
1098
+ const headers = {
1099
+ "Content-Type": "application/json"
1100
+ };
1101
+ if (this.token) {
1102
+ headers["Authorization"] = `Bearer ${this.token}`;
1103
+ }
1104
+ const res = await this.fetchImpl(url, { method: "GET", headers });
1105
+ if (!res.ok) {
1106
+ const errorBody = await res.json().catch(() => ({ message: res.statusText }));
1107
+ const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText);
1108
+ err.status = res.status;
1109
+ throw err;
1110
+ }
1111
+ const body = await res.json();
1112
+ if (body && typeof body.success === "boolean" && "data" in body) {
1113
+ return body.data;
1114
+ }
1115
+ return body;
1116
+ }
1023
1117
  /**
1024
1118
  * Convert ObjectUI QueryParams to ObjectStack QueryOptions.
1025
1119
  * Maps OData-style conventions to ObjectStack conventions.
@@ -1175,6 +1269,69 @@ var ObjectStackAdapter = class {
1175
1269
  return null;
1176
1270
  }
1177
1271
  }
1272
+ /**
1273
+ * Perform server-side aggregation via the ObjectStack analytics API.
1274
+ * Uses `this.client.analytics.query()` from @objectstack/client to leverage
1275
+ * the SDK's built-in auth, headers, and fetch configuration.
1276
+ * Falls back to client-side aggregation via find() if the analytics endpoint
1277
+ * is not available.
1278
+ */
1279
+ async aggregate(resource, params) {
1280
+ await this.connect();
1281
+ try {
1282
+ const payload = {
1283
+ object: resource,
1284
+ measures: [{ field: params.field, function: params.function }],
1285
+ dimensions: [params.groupBy]
1286
+ };
1287
+ if (params.filter) {
1288
+ payload.filters = params.filter;
1289
+ }
1290
+ const data = await this.client.analytics.query(payload);
1291
+ if (Array.isArray(data)) return data;
1292
+ if (data?.data && Array.isArray(data.data)) return data.data;
1293
+ if (data?.results && Array.isArray(data.results)) return data.results;
1294
+ return [];
1295
+ } catch {
1296
+ const result = await this.find(resource);
1297
+ const records = result.data || [];
1298
+ if (records.length === 0) return [];
1299
+ return this.aggregateClientSide(records, params);
1300
+ }
1301
+ }
1302
+ /** Client-side aggregation fallback */
1303
+ aggregateClientSide(records, params) {
1304
+ const { field, function: aggFn, groupBy } = params;
1305
+ const groups = {};
1306
+ for (const record of records) {
1307
+ const key = String(record[groupBy] ?? "Unknown");
1308
+ if (!groups[key]) groups[key] = [];
1309
+ groups[key].push(record);
1310
+ }
1311
+ return Object.entries(groups).map(([key, group]) => {
1312
+ const values = group.map((r) => Number(r[field]) || 0);
1313
+ let result;
1314
+ switch (aggFn) {
1315
+ case "count":
1316
+ result = group.length;
1317
+ break;
1318
+ case "avg":
1319
+ result = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
1320
+ break;
1321
+ case "min":
1322
+ result = values.length > 0 ? Math.min(...values) : 0;
1323
+ break;
1324
+ case "max":
1325
+ result = values.length > 0 ? Math.max(...values) : 0;
1326
+ break;
1327
+ case "sum":
1328
+ default:
1329
+ result = values.reduce((a, b) => a + b, 0);
1330
+ break;
1331
+ }
1332
+ return { [groupBy]: key, [field]: result };
1333
+ });
1334
+ }
1178
1335
  /**
1179
1336
  * Get multiple metadata items from ObjectStack.
1180
1337
  * Uses v3.0.0 metadata API pattern: getItems for batch retrieval.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/data-objectstack",
3
- "version": "3.0.2",
3
+ "version": "3.1.0",
4
4
  "description": "ObjectStack Data Adapter for Object UI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,9 +20,9 @@
20
20
  "README.md"
21
21
  ],
22
22
  "dependencies": {
23
- "@objectstack/client": "^3.0.4",
24
- "@object-ui/core": "3.0.2",
25
- "@object-ui/types": "3.0.2"
23
+ "@objectstack/client": "^3.2.0",
24
+ "@object-ui/core": "3.1.0",
25
+ "@object-ui/types": "3.1.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "tsup": "^8.5.1",
@@ -0,0 +1,245 @@
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
+ // We test the adapter's $expand handling.
13
+ // When $expand is present, the adapter makes a raw GET request to the REST API
14
+ // with `populate` as a URL query param (since the client SDK's data.find()
15
+ // QueryOptions does not support populate/expand).
16
+ // The key scenarios:
17
+ // 1. find() with $expand → raw GET /api/v1/data/:object?populate=...
18
+ // 2. find() without $expand → client.data.find() (GET) as before
19
+ // 3. findOne() with $expand → raw GET /api/v1/data/:object?filter={_id:...}&populate=...
20
+ // 4. findOne() without $expand → client.data.get() as before
21
+
22
+ describe('ObjectStackAdapter $expand support', () => {
23
+ let adapter: ObjectStackAdapter;
24
+ let mockClient: any;
25
+ let mockFetch: ReturnType<typeof vi.fn>;
26
+
27
+ beforeEach(() => {
28
+ // Create a mock fetch that returns a successful response
29
+ mockFetch = vi.fn().mockResolvedValue({
30
+ ok: true,
31
+ json: () => Promise.resolve({ records: [], total: 0 }),
32
+ });
33
+
34
+ adapter = new ObjectStackAdapter({
35
+ baseUrl: 'http://localhost:3000',
36
+ autoReconnect: false,
37
+ fetch: mockFetch,
38
+ });
39
+
40
+ // Mock the internal client after construction
41
+ mockClient = {
42
+ data: {
43
+ find: vi.fn().mockResolvedValue({ records: [], total: 0 }),
44
+ query: vi.fn().mockResolvedValue({ records: [], total: 0 }),
45
+ get: vi.fn().mockResolvedValue({ record: { _id: '1', name: 'Test' } }),
46
+ },
47
+ connect: vi.fn().mockResolvedValue(undefined),
48
+ discover: vi.fn().mockResolvedValue({ status: 'ok' }),
49
+ };
50
+
51
+ // Inject mock client and mark as connected to bypass connect()
52
+ (adapter as any).client = mockClient;
53
+ (adapter as any).connected = true;
54
+ });
55
+
56
+ describe('find() with $expand', () => {
57
+ it('should make a raw GET request with populate query param when $expand is present', async () => {
58
+ mockFetch.mockResolvedValue({
59
+ ok: true,
60
+ json: () => Promise.resolve({
61
+ records: [{ _id: '1', name: 'Order 1', customer: { _id: '2', name: 'Alice' } }],
62
+ total: 1,
63
+ }),
64
+ });
65
+
66
+ const result = await adapter.find('order', {
67
+ $top: 10,
68
+ $expand: ['customer', 'account'],
69
+ });
70
+
71
+ // Should use raw fetch, not client.data.query or client.data.find
72
+ expect(mockFetch).toHaveBeenCalled();
73
+ const fetchUrl = mockFetch.mock.calls[0][0] as string;
74
+ expect(fetchUrl).toContain('/api/v1/data/order');
75
+ expect(fetchUrl).toContain('populate=customer%2Caccount');
76
+ expect(fetchUrl).toContain('top=10');
77
+ expect(mockClient.data.find).not.toHaveBeenCalled();
78
+ expect(result.data).toHaveLength(1);
79
+ expect(result.data[0].customer).toEqual({ _id: '2', name: 'Alice' });
80
+ });
81
+
82
+ it('should pass filters and sort as query params', async () => {
83
+ mockFetch.mockResolvedValue({
84
+ ok: true,
85
+ json: () => Promise.resolve({ records: [], total: 0 }),
86
+ });
87
+
88
+ await adapter.find('order', {
89
+ $filter: { status: 'active' },
90
+ $orderby: [{ field: 'name', order: 'asc' }],
91
+ $top: 50,
92
+ $skip: 10,
93
+ $expand: ['customer'],
94
+ });
95
+
96
+ expect(mockFetch).toHaveBeenCalled();
97
+ const fetchUrl = mockFetch.mock.calls[0][0] as string;
98
+ expect(fetchUrl).toContain('populate=customer');
99
+ expect(fetchUrl).toContain('top=50');
100
+ expect(fetchUrl).toContain('skip=10');
101
+ expect(fetchUrl).toContain('sort=name');
102
+ expect(fetchUrl).toContain('filter=');
103
+ });
104
+
105
+ it('should use data.find() when $expand is not present', async () => {
106
+ mockClient.data.find.mockResolvedValue({ records: [{ _id: '1', name: 'Test' }], total: 1 });
107
+
108
+ const result = await adapter.find('order', { $top: 10 });
109
+
110
+ expect(mockClient.data.find).toHaveBeenCalled();
111
+ expect(result.data).toHaveLength(1);
112
+ });
113
+
114
+ it('should use data.find() when $expand is an empty array', async () => {
115
+ mockClient.data.find.mockResolvedValue({ records: [], total: 0 });
116
+
117
+ await adapter.find('order', { $top: 10, $expand: [] });
118
+
119
+ expect(mockClient.data.find).toHaveBeenCalled();
120
+ });
121
+ });
122
+
123
+ describe('findOne() with $expand', () => {
124
+ it('should make a raw GET request with _id filter and populate when $expand is present', async () => {
125
+ mockFetch.mockResolvedValue({
126
+ ok: true,
127
+ json: () => Promise.resolve({
128
+ records: [{ _id: 'order-1', name: 'Order 1', customer: { _id: '2', name: 'Alice' } }],
129
+ }),
130
+ });
131
+
132
+ const result = await adapter.findOne('order', 'order-1', {
133
+ $expand: ['customer', 'account'],
134
+ });
135
+
136
+ expect(mockFetch).toHaveBeenCalled();
137
+ const fetchUrl = mockFetch.mock.calls[0][0] as string;
138
+ expect(fetchUrl).toContain('/api/v1/data/order');
139
+ expect(fetchUrl).toContain('populate=customer%2Caccount');
140
+ expect(fetchUrl).toContain('top=1');
141
+ expect(fetchUrl).toContain('filter=');
142
+ // Verify the filter contains _id
143
+ const filterParam = new URL(fetchUrl).searchParams.get('filter');
144
+ expect(filterParam).toBeTruthy();
145
+ const parsedFilter = JSON.parse(filterParam!);
146
+ expect(parsedFilter).toEqual({ _id: 'order-1' });
147
+ expect(mockClient.data.get).not.toHaveBeenCalled();
148
+ expect(result).toEqual({ _id: 'order-1', name: 'Order 1', customer: { _id: '2', name: 'Alice' } });
149
+ });
150
+
151
+ it('should return null when raw request returns no records', async () => {
152
+ mockFetch.mockResolvedValue({
153
+ ok: true,
154
+ json: () => Promise.resolve({ records: [] }),
155
+ });
156
+
157
+ const result = await adapter.findOne('order', 'nonexistent', {
158
+ $expand: ['customer'],
159
+ });
160
+
161
+ expect(result).toBeNull();
162
+ });
163
+
164
+ it('should use data.get() when $expand is not present', async () => {
165
+ mockClient.data.get.mockResolvedValue({ record: { _id: '1', name: 'Test' } });
166
+
167
+ const result = await adapter.findOne('order', '1');
168
+
169
+ expect(mockClient.data.get).toHaveBeenCalledWith('order', '1');
170
+ expect(result).toEqual({ _id: '1', name: 'Test' });
171
+ });
172
+
173
+ it('should return null for 404 errors without $expand', async () => {
174
+ mockClient.data.get.mockRejectedValue({ status: 404 });
175
+
176
+ const result = await adapter.findOne('order', 'nonexistent');
177
+
178
+ expect(result).toBeNull();
179
+ });
180
+ });
181
+
182
+ describe('raw request format', () => {
183
+ it('should include Authorization header when token is set', async () => {
184
+ mockFetch.mockResolvedValue({
185
+ ok: true,
186
+ json: () => Promise.resolve({ records: [], total: 0 }),
187
+ });
188
+
189
+ (adapter as any).token = 'test-token';
190
+
191
+ await adapter.find('order', {
192
+ $expand: ['customer'],
193
+ });
194
+
195
+ expect(mockFetch).toHaveBeenCalled();
196
+ const fetchInit = mockFetch.mock.calls[0][1];
197
+ expect(fetchInit.headers.Authorization).toBe('Bearer test-token');
198
+ });
199
+
200
+ it('should unwrap response envelope with success/data wrapper', async () => {
201
+ mockFetch.mockResolvedValue({
202
+ ok: true,
203
+ json: () => Promise.resolve({
204
+ success: true,
205
+ data: {
206
+ records: [{ _id: '1', name: 'Order' }],
207
+ total: 1,
208
+ },
209
+ }),
210
+ });
211
+
212
+ const result = await adapter.find('order', {
213
+ $expand: ['customer'],
214
+ });
215
+
216
+ expect(result.data).toHaveLength(1);
217
+ });
218
+
219
+ it('should not double /api/v1 when baseUrl already includes it', async () => {
220
+ mockFetch.mockResolvedValue({
221
+ ok: true,
222
+ json: () => Promise.resolve({ records: [], total: 0 }),
223
+ });
224
+
225
+ // Create adapter with /api/v1 in baseUrl
226
+ const apiAdapter = new ObjectStackAdapter({
227
+ baseUrl: 'http://localhost:3000/api/v1',
228
+ autoReconnect: false,
229
+ fetch: mockFetch,
230
+ });
231
+ (apiAdapter as any).client = mockClient;
232
+ (apiAdapter as any).connected = true;
233
+
234
+ await apiAdapter.find('order', {
235
+ $expand: ['customer'],
236
+ });
237
+
238
+ expect(mockFetch).toHaveBeenCalled();
239
+ const fetchUrl = mockFetch.mock.calls[0][0] as string;
240
+ // Should be /api/v1/data/order not /api/v1/api/v1/data/order
241
+ expect(fetchUrl).toBe('http://localhost:3000/api/v1/data/order?populate=customer');
242
+ expect(fetchUrl).not.toContain('/api/v1/api/v1');
243
+ });
244
+ });
245
+ });
package/src/index.ts CHANGED
@@ -98,6 +98,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
98
98
  private reconnectAttempts: number = 0;
99
99
  private baseUrl: string;
100
100
  private token?: string;
101
+ private fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
101
102
 
102
103
  constructor(config: {
103
104
  baseUrl: string;
@@ -118,6 +119,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
118
119
  this.reconnectDelay = config.reconnectDelay ?? 1000;
119
120
  this.baseUrl = config.baseUrl;
120
121
  this.token = config.token;
122
+ this.fetchImpl = config.fetch || globalThis.fetch.bind(globalThis);
121
123
  }
122
124
 
123
125
  /**
@@ -254,39 +256,54 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
254
256
  async find(resource: string, params?: QueryParams): Promise<QueryResult<T>> {
255
257
  await this.connect();
256
258
 
257
- const queryOptions = this.convertQueryParams(params);
258
- const result: unknown = await this.client.data.find<T>(resource, queryOptions);
259
-
260
- // Handle legacy/raw array response (e.g. from some mock servers or non-OData endpoints)
261
- if (Array.isArray(result)) {
262
- return {
263
- data: result,
264
- total: result.length,
265
- page: 1,
266
- pageSize: result.length,
267
- hasMore: false,
268
- };
259
+ // When $expand is requested, use a raw GET request to the REST API with
260
+ // `populate` as a URL query param. The server's REST plugin routes
261
+ // GET /data/:object to protocol.findData({ object, query: req.query }),
262
+ // which parses `populate` (comma-separated) into an array for lookup expansion.
263
+ // We use a raw request because the client SDK's data.find() QueryOptions
264
+ // interface does not include populate/expand fields.
265
+ if (params?.$expand && params.$expand.length > 0) {
266
+ const result = await this.rawFindWithPopulate(resource, params);
267
+ return this.normalizeQueryResult(result, params);
269
268
  }
270
269
 
271
- const resultObj = result as { records?: T[]; total?: number; value?: T[]; count?: number };
272
- const records = resultObj.records || resultObj.value || [];
273
- const total = resultObj.total ?? resultObj.count ?? records.length;
274
- return {
275
- data: records,
276
- total,
277
- // Calculate page number safely
278
- page: params?.$skip && params.$top ? Math.floor(params.$skip / params.$top) + 1 : 1,
279
- pageSize: params?.$top,
280
- hasMore: params?.$top ? records.length === params.$top : false,
281
- };
270
+ const queryOptions = this.convertQueryParams(params);
271
+ const result: unknown = await this.client.data.find<T>(resource, queryOptions);
272
+ return this.normalizeQueryResult(result, params);
282
273
  }
283
274
 
284
275
  /**
285
276
  * Find a single record by ID.
286
277
  */
287
- async findOne(resource: string, id: string | number, _params?: QueryParams): Promise<T | null> {
278
+ async findOne(resource: string, id: string | number, params?: QueryParams): Promise<T | null> {
288
279
  await this.connect();
289
280
 
281
+ // When $expand is requested, use a raw GET request with a filter by _id
282
+ // and populate. The installed server v3.0.10's getData() does not support
283
+ // expand/populate, so we route through findData which does.
284
+ if (params?.$expand && params.$expand.length > 0) {
285
+ try {
286
+ const findParams: QueryParams = {
287
+ ...params,
288
+ $filter: { _id: String(id) },
289
+ $top: 1,
290
+ };
291
+ const result = await this.rawFindWithPopulate(resource, findParams);
292
+ // Handle array responses (some servers return data as flat arrays)
293
+ if (Array.isArray(result)) {
294
+ return result[0] || null;
295
+ }
296
+ const resultObj = result as { records?: T[]; value?: T[] };
297
+ const records = resultObj.records || resultObj.value || [];
298
+ return records[0] || null;
299
+ } catch (error: unknown) {
300
+ if ((error as Record<string, unknown>)?.status === 404) {
301
+ return null;
302
+ }
303
+ throw error;
304
+ }
305
+ }
306
+
290
307
  try {
291
308
  const result = await this.client.data.get<T>(resource, String(id));
292
309
  return result.record;
@@ -490,6 +507,115 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
490
507
  }
491
508
  }
492
509
 
510
+ /**
511
+ * Normalize the result from data.find() or data.query() into a consistent QueryResult.
512
+ */
513
+ private normalizeQueryResult(result: unknown, params?: QueryParams): QueryResult<T> {
514
+ // Handle legacy/raw array response (e.g. from some mock servers or non-OData endpoints)
515
+ if (Array.isArray(result)) {
516
+ return {
517
+ data: result,
518
+ total: result.length,
519
+ page: 1,
520
+ pageSize: result.length,
521
+ hasMore: false,
522
+ };
523
+ }
524
+
525
+ const resultObj = result as { records?: T[]; total?: number; value?: T[]; count?: number };
526
+ const records = resultObj.records || resultObj.value || [];
527
+ const total = resultObj.total ?? resultObj.count ?? records.length;
528
+ return {
529
+ data: records,
530
+ total,
531
+ // Calculate page number safely
532
+ page: params?.$skip && params.$top ? Math.floor(params.$skip / params.$top) + 1 : 1,
533
+ pageSize: params?.$top,
534
+ hasMore: params?.$top ? records.length === params.$top : false,
535
+ };
536
+ }
537
+
538
+ /**
539
+ * Make a raw GET request to the data API with `populate` as a URL query param.
540
+ * Used when $expand is needed, since the client SDK's data.find() does not
541
+ * support populate/expand. The server's REST API routes GET /data/:object
542
+ * to findData({ object, query: req.query }) which processes `populate`.
543
+ */
544
+ private async rawFindWithPopulate(resource: string, params: QueryParams): Promise<unknown> {
545
+ const queryParams = new URLSearchParams();
546
+
547
+ // Populate: comma-separated field names for lookup expansion
548
+ if (params.$expand && params.$expand.length > 0) {
549
+ queryParams.set('populate', params.$expand.join(','));
550
+ }
551
+
552
+ // Pagination
553
+ if (params.$top !== undefined) {
554
+ queryParams.set('top', String(params.$top));
555
+ }
556
+ if (params.$skip !== undefined) {
557
+ queryParams.set('skip', String(params.$skip));
558
+ }
559
+
560
+ // Selection
561
+ if (params.$select && params.$select.length > 0) {
562
+ queryParams.set('select', params.$select.join(','));
563
+ }
564
+
565
+ // Sorting
566
+ if (params.$orderby) {
567
+ if (Array.isArray(params.$orderby)) {
568
+ const sortStr = params.$orderby.map(item => {
569
+ if (typeof item === 'string') return item;
570
+ const field = item.field;
571
+ const order = item.order || 'asc';
572
+ return order === 'desc' ? `-${field}` : field;
573
+ }).join(',');
574
+ queryParams.set('sort', sortStr);
575
+ } else {
576
+ const sortStr = Object.entries(params.$orderby)
577
+ .map(([field, order]) => order === 'desc' ? `-${field}` : field)
578
+ .join(',');
579
+ queryParams.set('sort', sortStr);
580
+ }
581
+ }
582
+
583
+ // Filter
584
+ if (params.$filter) {
585
+ queryParams.set('filter', JSON.stringify(params.$filter));
586
+ }
587
+
588
+ const baseUrl = this.baseUrl.replace(/\/$/, '');
589
+ const qs = queryParams.toString();
590
+ // Avoid doubling /api/v1 if baseUrl already includes it
591
+ const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
592
+ const dataPath = hasApiVersionSuffix ? '/data' : '/api/v1/data';
593
+ const url = `${baseUrl}${dataPath}/${resource}${qs ? `?${qs}` : ''}`;
594
+
595
+ const headers: Record<string, string> = {
596
+ 'Content-Type': 'application/json',
597
+ };
598
+ if (this.token) {
599
+ headers['Authorization'] = `Bearer ${this.token}`;
600
+ }
601
+
602
+ const res = await this.fetchImpl(url, { method: 'GET', headers });
603
+
604
+ if (!res.ok) {
605
+ const errorBody = await res.json().catch(() => ({ message: res.statusText }));
606
+ const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText) as any;
607
+ err.status = res.status;
608
+ throw err;
609
+ }
610
+
611
+ const body = await res.json();
612
+ // Unwrap standard response envelope { success, data }
613
+ if (body && typeof body.success === 'boolean' && 'data' in body) {
614
+ return body.data;
615
+ }
616
+ return body;
617
+ }
618
+
493
619
  /**
494
620
  * Convert ObjectUI QueryParams to ObjectStack QueryOptions.
495
621
  * Maps OData-style conventions to ObjectStack conventions.
@@ -690,6 +816,69 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
690
816
  }
691
817
  }
692
818
 
819
+ /**
820
+ * Perform server-side aggregation via the ObjectStack analytics API.
821
+ * Uses `this.client.analytics.query()` from @objectstack/client to leverage
822
+ * the SDK's built-in auth, headers, and fetch configuration.
823
+ * Falls back to client-side aggregation via find() if the analytics endpoint
824
+ * is not available.
825
+ */
826
+ async aggregate(resource: string, params: { field: string; function: string; groupBy: string; filter?: any }): Promise<any[]> {
827
+ await this.connect();
828
+
829
+ try {
830
+ const payload: Record<string, unknown> = {
831
+ object: resource,
832
+ measures: [{ field: params.field, function: params.function }],
833
+ dimensions: [params.groupBy],
834
+ };
835
+ if (params.filter) {
836
+ payload.filters = params.filter;
837
+ }
838
+
839
+ const data = await this.client.analytics.query(payload);
840
+ if (Array.isArray(data)) return data;
841
+ if (data?.data && Array.isArray(data.data)) return data.data;
842
+ if (data?.results && Array.isArray(data.results)) return data.results;
843
+ return [];
844
+ } catch {
845
+ // If the analytics endpoint is not available, fall back to
846
+ // find() + client-side aggregation
847
+ const result = await this.find(resource as any);
848
+ const records = result.data || [];
849
+ if (records.length === 0) return [];
850
+
851
+ return this.aggregateClientSide(records, params);
852
+ }
853
+ }
854
+
855
+ /** Client-side aggregation fallback */
856
+ private aggregateClientSide(records: any[], params: { field: string; function: string; groupBy: string }): any[] {
857
+ const { field, function: aggFn, groupBy } = params;
858
+ const groups: Record<string, any[]> = {};
859
+
860
+ for (const record of records) {
861
+ const key = String(record[groupBy] ?? 'Unknown');
862
+ if (!groups[key]) groups[key] = [];
863
+ groups[key].push(record);
864
+ }
865
+
866
+ return Object.entries(groups).map(([key, group]) => {
867
+ const values = group.map(r => Number(r[field]) || 0);
868
+ let result: number;
869
+
870
+ switch (aggFn) {
871
+ case 'count': result = group.length; break;
872
+ case 'avg': result = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0; break;
873
+ case 'min': result = values.length > 0 ? Math.min(...values) : 0; break;
874
+ case 'max': result = values.length > 0 ? Math.max(...values) : 0; break;
875
+ case 'sum': default: result = values.reduce((a, b) => a + b, 0); break;
876
+ }
877
+
878
+ return { [groupBy]: key, [field]: result };
879
+ });
880
+ }
881
+
693
882
  /**
694
883
  * Get multiple metadata items from ObjectStack.
695
884
  * Uses v3.0.0 metadata API pattern: getItems for batch retrieval.