@object-ui/data-objectstack 3.0.3 → 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 +178 -21
- package/dist/index.d.cts +28 -1
- package/dist/index.d.ts +28 -1
- package/dist/index.js +178 -21
- package/package.json +4 -4
- package/src/expand.test.ts +245 -0
- package/src/index.ts +213 -24
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
24
|
-
"@object-ui/core": "3.0
|
|
25
|
-
"@object-ui/types": "3.0
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
//
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
272
|
-
const
|
|
273
|
-
|
|
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,
|
|
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.
|