@object-ui/data-objectstack 3.3.0 → 3.3.2
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/CHANGELOG.md +125 -0
- package/README.md +20 -1
- package/dist/index.cjs +152 -16
- package/dist/index.d.cts +12 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.js +150 -16
- package/package.json +34 -9
- package/src/cache/MetadataCache.test.ts +51 -0
- package/src/cache/MetadataCache.ts +37 -8
- package/src/connection.test.ts +55 -8
- package/src/index.ts +97 -17
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# @object-ui/data-objectstack
|
|
2
|
+
|
|
3
|
+
## 3.3.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- @object-ui/types@3.3.2
|
|
8
|
+
- @object-ui/core@3.3.2
|
|
9
|
+
|
|
10
|
+
## 3.3.1
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- @object-ui/types@3.3.1
|
|
15
|
+
- @object-ui/core@3.3.1
|
|
16
|
+
|
|
17
|
+
## 3.3.0
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- @object-ui/types@3.3.0
|
|
22
|
+
- @object-ui/core@3.3.0
|
|
23
|
+
|
|
24
|
+
## 3.2.0
|
|
25
|
+
|
|
26
|
+
### Patch Changes
|
|
27
|
+
|
|
28
|
+
- @object-ui/types@3.2.0
|
|
29
|
+
- @object-ui/core@3.2.0
|
|
30
|
+
|
|
31
|
+
## 3.1.5
|
|
32
|
+
|
|
33
|
+
### Patch Changes
|
|
34
|
+
|
|
35
|
+
- @object-ui/types@3.1.5
|
|
36
|
+
- @object-ui/core@3.1.5
|
|
37
|
+
|
|
38
|
+
## 3.1.4
|
|
39
|
+
|
|
40
|
+
### Patch Changes
|
|
41
|
+
|
|
42
|
+
- @object-ui/types@3.1.4
|
|
43
|
+
- @object-ui/core@3.1.4
|
|
44
|
+
|
|
45
|
+
## 3.1.3
|
|
46
|
+
|
|
47
|
+
### Patch Changes
|
|
48
|
+
|
|
49
|
+
- @object-ui/types@3.1.3
|
|
50
|
+
- @object-ui/core@3.1.3
|
|
51
|
+
|
|
52
|
+
## 3.1.2
|
|
53
|
+
|
|
54
|
+
### Patch Changes
|
|
55
|
+
|
|
56
|
+
- @object-ui/types@3.1.2
|
|
57
|
+
- @object-ui/core@3.1.2
|
|
58
|
+
|
|
59
|
+
## 3.1.1
|
|
60
|
+
|
|
61
|
+
### Patch Changes
|
|
62
|
+
|
|
63
|
+
- Updated dependencies
|
|
64
|
+
- @object-ui/types@3.1.1
|
|
65
|
+
- @object-ui/core@3.1.1
|
|
66
|
+
|
|
67
|
+
## 3.0.3
|
|
68
|
+
|
|
69
|
+
### Patch Changes
|
|
70
|
+
|
|
71
|
+
- @object-ui/types@3.0.3
|
|
72
|
+
- @object-ui/core@3.0.3
|
|
73
|
+
|
|
74
|
+
## 3.0.2
|
|
75
|
+
|
|
76
|
+
### Patch Changes
|
|
77
|
+
|
|
78
|
+
- @object-ui/types@3.0.2
|
|
79
|
+
- @object-ui/core@3.0.2
|
|
80
|
+
|
|
81
|
+
## 3.0.1
|
|
82
|
+
|
|
83
|
+
### Patch Changes
|
|
84
|
+
|
|
85
|
+
- @object-ui/types@3.0.1
|
|
86
|
+
- @object-ui/core@3.0.1
|
|
87
|
+
|
|
88
|
+
## 3.0.0
|
|
89
|
+
|
|
90
|
+
### Minor Changes
|
|
91
|
+
|
|
92
|
+
- 87979c3: Upgrade to @objectstack v3.0.0 and console bundle optimization
|
|
93
|
+
- Upgraded all @objectstack/\* packages from ^2.0.7 to ^3.0.0
|
|
94
|
+
- Breaking change migrations: Hub → Cloud namespace, definePlugin removed, PaginatedResult.value → .records, PaginatedResult.count → .total, client.meta.getObject() → client.meta.getItem()
|
|
95
|
+
- Console bundle optimization: split monolithic 3.7 MB chunk into 17 granular cacheable chunks (95% main entry reduction)
|
|
96
|
+
- Added gzip + brotli pre-compression via vite-plugin-compression2
|
|
97
|
+
- Lazy MSW loading for build:server (~150 KB gzip saved)
|
|
98
|
+
- Added bundle analysis with rollup-plugin-visualizer
|
|
99
|
+
|
|
100
|
+
### Patch Changes
|
|
101
|
+
|
|
102
|
+
- Updated dependencies [87979c3]
|
|
103
|
+
- @object-ui/types@3.0.0
|
|
104
|
+
- @object-ui/core@3.0.0
|
|
105
|
+
|
|
106
|
+
## 2.0.0
|
|
107
|
+
|
|
108
|
+
### Major Changes
|
|
109
|
+
|
|
110
|
+
- b859617: Release v1.0.0 — unify all package versions to 1.0.0
|
|
111
|
+
|
|
112
|
+
### Patch Changes
|
|
113
|
+
|
|
114
|
+
- Updated dependencies [b859617]
|
|
115
|
+
- @object-ui/types@2.0.0
|
|
116
|
+
- @object-ui/core@2.0.0
|
|
117
|
+
|
|
118
|
+
## 0.3.1
|
|
119
|
+
|
|
120
|
+
### Patch Changes
|
|
121
|
+
|
|
122
|
+
- Maintenance release - Documentation and build improvements
|
|
123
|
+
- Updated dependencies
|
|
124
|
+
- @object-ui/types@0.3.1
|
|
125
|
+
- @object-ui/core@0.3.1
|
package/README.md
CHANGED
|
@@ -356,6 +356,25 @@ dataSource.clearCache();
|
|
|
356
356
|
dataSource.invalidateCache('users');
|
|
357
357
|
```
|
|
358
358
|
|
|
359
|
+
<!-- release-metadata:v3.3.0 -->
|
|
360
|
+
|
|
361
|
+
## Compatibility
|
|
362
|
+
|
|
363
|
+
- **Node.js:** ≥ 18
|
|
364
|
+
- **TypeScript:** ≥ 5.0 (strict mode)
|
|
365
|
+
- **`@objectstack/spec`:** ^3.3.0
|
|
366
|
+
- **`@objectstack/client`:** ^3.3.0
|
|
367
|
+
- **Tailwind CSS:** ≥ 3.4 (for packages with UI)
|
|
368
|
+
|
|
369
|
+
## Links
|
|
370
|
+
|
|
371
|
+
- 📚 [Documentation](https://www.objectui.org/docs/guide/data-source)
|
|
372
|
+
- 📦 [npm package](https://www.npmjs.com/package/@object-ui/data-objectstack)
|
|
373
|
+
- 📝 [Changelog](./CHANGELOG.md)
|
|
374
|
+
- 🐛 [Report an issue](https://github.com/objectstack-ai/objectui/issues)
|
|
375
|
+
- 🤝 [Contributing Guide](https://github.com/objectstack-ai/objectui/blob/main/CONTRIBUTING.md)
|
|
376
|
+
- 🗺️ [Roadmap](https://github.com/objectstack-ai/objectui/blob/main/ROADMAP.md)
|
|
377
|
+
|
|
359
378
|
## License
|
|
360
379
|
|
|
361
|
-
MIT
|
|
380
|
+
MIT — see [LICENSE](./LICENSE).
|
package/dist/index.cjs
CHANGED
|
@@ -33,10 +33,12 @@ __export(index_exports, {
|
|
|
33
33
|
SecurityManager: () => SecurityManager,
|
|
34
34
|
ValidationError: () => ValidationError,
|
|
35
35
|
calculateAutoLayout: () => calculateAutoLayout,
|
|
36
|
+
clearSharedDiscoveryCache: () => clearSharedDiscoveryCache,
|
|
36
37
|
createDefaultCanvasConfig: () => createDefaultCanvasConfig,
|
|
37
38
|
createErrorFromResponse: () => createErrorFromResponse,
|
|
38
39
|
createObjectStackAdapter: () => createObjectStackAdapter,
|
|
39
40
|
generateContractManifest: () => generateContractManifest,
|
|
41
|
+
getSharedDiscovery: () => getSharedDiscovery,
|
|
40
42
|
isErrorType: () => isErrorType,
|
|
41
43
|
isObjectStackError: () => isObjectStackError,
|
|
42
44
|
snapToGrid: () => snapToGrid,
|
|
@@ -44,7 +46,72 @@ __export(index_exports, {
|
|
|
44
46
|
});
|
|
45
47
|
module.exports = __toCommonJS(index_exports);
|
|
46
48
|
var import_client = require("@objectstack/client");
|
|
47
|
-
|
|
49
|
+
|
|
50
|
+
// ../core/src/utils/filter-converter.ts
|
|
51
|
+
function convertOperatorToAST(operator) {
|
|
52
|
+
const operatorMap = {
|
|
53
|
+
"$eq": "=",
|
|
54
|
+
"$ne": "!=",
|
|
55
|
+
"$gt": ">",
|
|
56
|
+
"$gte": ">=",
|
|
57
|
+
"$lt": "<",
|
|
58
|
+
"$lte": "<=",
|
|
59
|
+
"$in": "in",
|
|
60
|
+
"$nin": "nin",
|
|
61
|
+
"$notin": "nin",
|
|
62
|
+
"$between": "between",
|
|
63
|
+
"$contains": "contains",
|
|
64
|
+
"$notContains": "notcontains",
|
|
65
|
+
"$notcontains": "notcontains",
|
|
66
|
+
"$startsWith": "startswith",
|
|
67
|
+
"$startswith": "startswith",
|
|
68
|
+
"$endsWith": "endswith",
|
|
69
|
+
"$endswith": "endswith"
|
|
70
|
+
};
|
|
71
|
+
return operatorMap[operator] || null;
|
|
72
|
+
}
|
|
73
|
+
function convertFiltersToAST(filter) {
|
|
74
|
+
const conditions = [];
|
|
75
|
+
for (const [field, value] of Object.entries(filter)) {
|
|
76
|
+
if (value === null || value === void 0) continue;
|
|
77
|
+
if (typeof value === "object" && !Array.isArray(value)) {
|
|
78
|
+
for (const [operator, operatorValue] of Object.entries(value)) {
|
|
79
|
+
if (operator === "$regex") {
|
|
80
|
+
console.warn(
|
|
81
|
+
`[ObjectUI] Warning: $regex operator is not fully supported. Converting to 'contains' which only supports substring matching, not regex patterns. Field: '${field}', Value: ${JSON.stringify(operatorValue)}. Consider using $contains or $startsWith instead.`
|
|
82
|
+
);
|
|
83
|
+
conditions.push([field, "contains", operatorValue]);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (operator === "$null") {
|
|
87
|
+
conditions.push([field, operatorValue ? "is_null" : "is_not_null", true]);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (operator === "$exists") {
|
|
91
|
+
conditions.push([field, operatorValue ? "is_not_null" : "is_null", true]);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const astOperator = convertOperatorToAST(operator);
|
|
95
|
+
if (astOperator) {
|
|
96
|
+
conditions.push([field, astOperator, operatorValue]);
|
|
97
|
+
} else {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`[ObjectUI] Unknown filter operator '${operator}' for field '${field}'. Supported operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $between, $contains, $notContains, $startsWith, $endsWith, $null, $exists. If you need exact object matching, use the value directly without an operator.`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
conditions.push([field, "=", value]);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (conditions.length === 0) {
|
|
108
|
+
return filter;
|
|
109
|
+
}
|
|
110
|
+
if (conditions.length === 1) {
|
|
111
|
+
return conditions[0];
|
|
112
|
+
}
|
|
113
|
+
return ["and", ...conditions];
|
|
114
|
+
}
|
|
48
115
|
|
|
49
116
|
// src/cache/MetadataCache.ts
|
|
50
117
|
var MetadataCache = class {
|
|
@@ -57,16 +124,19 @@ var MetadataCache = class {
|
|
|
57
124
|
*/
|
|
58
125
|
constructor(options = {}) {
|
|
59
126
|
__publicField(this, "cache");
|
|
127
|
+
__publicField(this, "inflight");
|
|
60
128
|
__publicField(this, "maxSize");
|
|
61
129
|
__publicField(this, "ttl");
|
|
62
130
|
__publicField(this, "stats");
|
|
63
131
|
this.cache = /* @__PURE__ */ new Map();
|
|
132
|
+
this.inflight = /* @__PURE__ */ new Map();
|
|
64
133
|
this.maxSize = options.maxSize || 100;
|
|
65
134
|
this.ttl = options.ttl || 5 * 60 * 1e3;
|
|
66
135
|
this.stats = {
|
|
67
136
|
hits: 0,
|
|
68
137
|
misses: 0,
|
|
69
|
-
evictions: 0
|
|
138
|
+
evictions: 0,
|
|
139
|
+
coalesced: 0
|
|
70
140
|
};
|
|
71
141
|
}
|
|
72
142
|
/**
|
|
@@ -92,10 +162,31 @@ var MetadataCache = class {
|
|
|
92
162
|
this.cache.delete(key);
|
|
93
163
|
}
|
|
94
164
|
}
|
|
165
|
+
const existing = this.inflight.get(key);
|
|
166
|
+
if (existing) {
|
|
167
|
+
this.stats.coalesced++;
|
|
168
|
+
return existing;
|
|
169
|
+
}
|
|
95
170
|
this.stats.misses++;
|
|
96
|
-
const
|
|
171
|
+
const promise = (async () => {
|
|
172
|
+
try {
|
|
173
|
+
const data = await fetcher();
|
|
174
|
+
this.set(key, data);
|
|
175
|
+
return data;
|
|
176
|
+
} finally {
|
|
177
|
+
this.inflight.delete(key);
|
|
178
|
+
}
|
|
179
|
+
})();
|
|
180
|
+
this.inflight.set(key, promise);
|
|
181
|
+
return promise;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Prime the cache with a pre-fetched value. Useful when a bulk endpoint
|
|
185
|
+
* (e.g. list of all object schemas) returns data that would otherwise
|
|
186
|
+
* be fetched again per item.
|
|
187
|
+
*/
|
|
188
|
+
prime(key, data) {
|
|
97
189
|
this.set(key, data);
|
|
98
|
-
return data;
|
|
99
190
|
}
|
|
100
191
|
/**
|
|
101
192
|
* Set a value in the cache
|
|
@@ -142,10 +233,12 @@ var MetadataCache = class {
|
|
|
142
233
|
*/
|
|
143
234
|
clear() {
|
|
144
235
|
this.cache.clear();
|
|
236
|
+
this.inflight.clear();
|
|
145
237
|
this.stats = {
|
|
146
238
|
hits: 0,
|
|
147
239
|
misses: 0,
|
|
148
|
-
evictions: 0
|
|
240
|
+
evictions: 0,
|
|
241
|
+
coalesced: 0
|
|
149
242
|
};
|
|
150
243
|
}
|
|
151
244
|
/**
|
|
@@ -162,6 +255,7 @@ var MetadataCache = class {
|
|
|
162
255
|
hits: this.stats.hits,
|
|
163
256
|
misses: this.stats.misses,
|
|
164
257
|
evictions: this.stats.evictions,
|
|
258
|
+
coalesced: this.stats.coalesced,
|
|
165
259
|
hitRate
|
|
166
260
|
};
|
|
167
261
|
}
|
|
@@ -735,10 +829,26 @@ function calculateAutoLayout(items, canvasWidth, padding = 40, gap = 40) {
|
|
|
735
829
|
}
|
|
736
830
|
|
|
737
831
|
// src/index.ts
|
|
832
|
+
var discoveryCache = /* @__PURE__ */ new Map();
|
|
833
|
+
async function getSharedDiscovery(baseUrl, fetcher) {
|
|
834
|
+
const key = baseUrl || "<default>";
|
|
835
|
+
const cached = discoveryCache.get(key);
|
|
836
|
+
if (cached) return cached;
|
|
837
|
+
const p = fetcher().catch((err) => {
|
|
838
|
+
discoveryCache.delete(key);
|
|
839
|
+
throw err;
|
|
840
|
+
});
|
|
841
|
+
discoveryCache.set(key, p);
|
|
842
|
+
return p;
|
|
843
|
+
}
|
|
844
|
+
function clearSharedDiscoveryCache() {
|
|
845
|
+
discoveryCache.clear();
|
|
846
|
+
}
|
|
738
847
|
var ObjectStackAdapter = class {
|
|
739
848
|
constructor(config) {
|
|
740
849
|
__publicField(this, "client");
|
|
741
850
|
__publicField(this, "connected", false);
|
|
851
|
+
__publicField(this, "connectPromise", null);
|
|
742
852
|
__publicField(this, "metadataCache");
|
|
743
853
|
__publicField(this, "connectionState", "disconnected");
|
|
744
854
|
__publicField(this, "connectionStateListeners", []);
|
|
@@ -764,10 +874,25 @@ var ObjectStackAdapter = class {
|
|
|
764
874
|
* Call this before making requests or it will auto-connect on first request.
|
|
765
875
|
*/
|
|
766
876
|
async connect() {
|
|
767
|
-
if (
|
|
768
|
-
|
|
877
|
+
if (this.connected) return;
|
|
878
|
+
if (this.connectPromise) return this.connectPromise;
|
|
879
|
+
this.setConnectionState("connecting");
|
|
880
|
+
this.connectPromise = (async () => {
|
|
769
881
|
try {
|
|
770
|
-
|
|
882
|
+
const baseUrl = this.baseUrl || "";
|
|
883
|
+
const discoveryUrl = baseUrl ? `${baseUrl.replace(/\/$/, "")}/api/v1/discovery` : "/api/v1/discovery";
|
|
884
|
+
const data = await getSharedDiscovery(baseUrl, async () => {
|
|
885
|
+
const res = await this.fetchImpl(discoveryUrl, {
|
|
886
|
+
method: "GET",
|
|
887
|
+
headers: this.token ? { Authorization: `Bearer ${this.token}` } : void 0
|
|
888
|
+
});
|
|
889
|
+
if (!res.ok) {
|
|
890
|
+
throw new Error(`discovery ${res.status} ${res.statusText}`);
|
|
891
|
+
}
|
|
892
|
+
const body = await res.json();
|
|
893
|
+
return body && typeof body.success === "boolean" && "data" in body ? body.data : body;
|
|
894
|
+
});
|
|
895
|
+
this.client.discoveryInfo = data;
|
|
771
896
|
this.connected = true;
|
|
772
897
|
this.reconnectAttempts = 0;
|
|
773
898
|
this.setConnectionState("connected");
|
|
@@ -784,8 +909,11 @@ var ObjectStackAdapter = class {
|
|
|
784
909
|
} else {
|
|
785
910
|
throw connectionError;
|
|
786
911
|
}
|
|
912
|
+
} finally {
|
|
913
|
+
this.connectPromise = null;
|
|
787
914
|
}
|
|
788
|
-
}
|
|
915
|
+
})();
|
|
916
|
+
return this.connectPromise;
|
|
789
917
|
}
|
|
790
918
|
/**
|
|
791
919
|
* Attempt to reconnect to the server with exponential backoff
|
|
@@ -1126,8 +1254,11 @@ var ObjectStackAdapter = class {
|
|
|
1126
1254
|
queryParams.set("sort", sortStr);
|
|
1127
1255
|
}
|
|
1128
1256
|
}
|
|
1129
|
-
if (params.$filter) {
|
|
1130
|
-
|
|
1257
|
+
if (params.$filter !== void 0 && params.$filter !== null) {
|
|
1258
|
+
const isEmpty = Array.isArray(params.$filter) ? params.$filter.length === 0 : typeof params.$filter === "object" && Object.keys(params.$filter).length === 0;
|
|
1259
|
+
if (!isEmpty) {
|
|
1260
|
+
queryParams.set("filter", JSON.stringify(params.$filter));
|
|
1261
|
+
}
|
|
1131
1262
|
}
|
|
1132
1263
|
const baseUrl = this.baseUrl.replace(/\/$/, "");
|
|
1133
1264
|
const qs = queryParams.toString();
|
|
@@ -1163,11 +1294,14 @@ var ObjectStackAdapter = class {
|
|
|
1163
1294
|
if (params.$select) {
|
|
1164
1295
|
options.select = params.$select;
|
|
1165
1296
|
}
|
|
1166
|
-
if (params.$filter) {
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1297
|
+
if (params.$filter !== void 0 && params.$filter !== null) {
|
|
1298
|
+
const isEmpty = Array.isArray(params.$filter) ? params.$filter.length === 0 : typeof params.$filter === "object" && Object.keys(params.$filter).length === 0;
|
|
1299
|
+
if (!isEmpty) {
|
|
1300
|
+
if (Array.isArray(params.$filter)) {
|
|
1301
|
+
options.filters = params.$filter;
|
|
1302
|
+
} else {
|
|
1303
|
+
options.filters = convertFiltersToAST(params.$filter);
|
|
1304
|
+
}
|
|
1171
1305
|
}
|
|
1172
1306
|
}
|
|
1173
1307
|
if (params.$orderby) {
|
|
@@ -1532,10 +1666,12 @@ function createObjectStackAdapter(config) {
|
|
|
1532
1666
|
SecurityManager,
|
|
1533
1667
|
ValidationError,
|
|
1534
1668
|
calculateAutoLayout,
|
|
1669
|
+
clearSharedDiscoveryCache,
|
|
1535
1670
|
createDefaultCanvasConfig,
|
|
1536
1671
|
createErrorFromResponse,
|
|
1537
1672
|
createObjectStackAdapter,
|
|
1538
1673
|
generateContractManifest,
|
|
1674
|
+
getSharedDiscovery,
|
|
1539
1675
|
isErrorType,
|
|
1540
1676
|
isObjectStackError,
|
|
1541
1677
|
snapToGrid,
|
package/dist/index.d.cts
CHANGED
|
@@ -156,6 +156,8 @@ interface CacheStats {
|
|
|
156
156
|
hits: number;
|
|
157
157
|
misses: number;
|
|
158
158
|
evictions: number;
|
|
159
|
+
/** Number of concurrent fetches that were coalesced onto an in-flight request. */
|
|
160
|
+
coalesced: number;
|
|
159
161
|
hitRate: number;
|
|
160
162
|
}
|
|
161
163
|
|
|
@@ -638,6 +640,14 @@ declare function calculateAutoLayout(items: Array<{
|
|
|
638
640
|
y: number;
|
|
639
641
|
}>;
|
|
640
642
|
|
|
643
|
+
/**
|
|
644
|
+
* Fetch the server `discovery` document once per (baseUrl) and reuse the
|
|
645
|
+
* resulting Promise. Used by `ObjectStackAdapter.connect()` (and any caller
|
|
646
|
+
* that wants the discovery payload without spinning up a new client).
|
|
647
|
+
*/
|
|
648
|
+
declare function getSharedDiscovery(baseUrl: string, fetcher: () => Promise<unknown>): Promise<unknown>;
|
|
649
|
+
/** Test/dev helper to drop the cache (e.g. on logout or origin change). */
|
|
650
|
+
declare function clearSharedDiscoveryCache(): void;
|
|
641
651
|
/**
|
|
642
652
|
* Connection state for monitoring
|
|
643
653
|
*/
|
|
@@ -701,6 +711,7 @@ type BatchProgressListener = (event: BatchProgressEvent) => void;
|
|
|
701
711
|
declare class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
702
712
|
private client;
|
|
703
713
|
private connected;
|
|
714
|
+
private connectPromise;
|
|
704
715
|
private metadataCache;
|
|
705
716
|
private connectionState;
|
|
706
717
|
private connectionStateListeners;
|
|
@@ -954,4 +965,4 @@ declare function createObjectStackAdapter<T = unknown>(config: {
|
|
|
954
965
|
reconnectDelay?: number;
|
|
955
966
|
}): DataSource<T>;
|
|
956
967
|
|
|
957
|
-
export { type AuditEventType, type AuditLogConfig, type AuditLogEntry, AuthenticationError, type BatchProgressEvent, type BatchProgressListener, BulkOperationError, type CSPConfig, type CacheStats, type CloudDeploymentConfig, type CloudHostingConfig, type CloudMarketplaceEntry, CloudOperations, ConnectionError, type ConnectionState, type ConnectionStateEvent, type ConnectionStateListener, type ContractValidationError, type ContractValidationResult, type DataMaskingConfig, type DataMaskingRule, type EmailIntegrationConfig, type IntegrationConfig, IntegrationManager, type IntegrationProvider, type IntegrationTrigger, MetadataNotFoundError, ObjectStackAdapter, ObjectStackError, type PluginAPIContract, type PluginContract, type PluginExport, SecurityManager, type SecurityPolicy, type SlackIntegrationConfig, type StudioCanvasConfig, type StudioColorPalette, type StudioPropertyEditor, type StudioShadowPreset, type StudioThemeBuilderConfig, type StudioTypographyPreset, ValidationError, type WebhookIntegrationConfig, calculateAutoLayout, createDefaultCanvasConfig, createErrorFromResponse, createObjectStackAdapter, generateContractManifest, isErrorType, isObjectStackError, snapToGrid, validatePluginContract };
|
|
968
|
+
export { type AuditEventType, type AuditLogConfig, type AuditLogEntry, AuthenticationError, type BatchProgressEvent, type BatchProgressListener, BulkOperationError, type CSPConfig, type CacheStats, type CloudDeploymentConfig, type CloudHostingConfig, type CloudMarketplaceEntry, CloudOperations, ConnectionError, type ConnectionState, type ConnectionStateEvent, type ConnectionStateListener, type ContractValidationError, type ContractValidationResult, type DataMaskingConfig, type DataMaskingRule, type EmailIntegrationConfig, type IntegrationConfig, IntegrationManager, type IntegrationProvider, type IntegrationTrigger, MetadataNotFoundError, ObjectStackAdapter, ObjectStackError, type PluginAPIContract, type PluginContract, type PluginExport, SecurityManager, type SecurityPolicy, type SlackIntegrationConfig, type StudioCanvasConfig, type StudioColorPalette, type StudioPropertyEditor, type StudioShadowPreset, type StudioThemeBuilderConfig, type StudioTypographyPreset, ValidationError, type WebhookIntegrationConfig, calculateAutoLayout, clearSharedDiscoveryCache, createDefaultCanvasConfig, createErrorFromResponse, createObjectStackAdapter, generateContractManifest, getSharedDiscovery, isErrorType, isObjectStackError, snapToGrid, validatePluginContract };
|
package/dist/index.d.ts
CHANGED
|
@@ -156,6 +156,8 @@ interface CacheStats {
|
|
|
156
156
|
hits: number;
|
|
157
157
|
misses: number;
|
|
158
158
|
evictions: number;
|
|
159
|
+
/** Number of concurrent fetches that were coalesced onto an in-flight request. */
|
|
160
|
+
coalesced: number;
|
|
159
161
|
hitRate: number;
|
|
160
162
|
}
|
|
161
163
|
|
|
@@ -638,6 +640,14 @@ declare function calculateAutoLayout(items: Array<{
|
|
|
638
640
|
y: number;
|
|
639
641
|
}>;
|
|
640
642
|
|
|
643
|
+
/**
|
|
644
|
+
* Fetch the server `discovery` document once per (baseUrl) and reuse the
|
|
645
|
+
* resulting Promise. Used by `ObjectStackAdapter.connect()` (and any caller
|
|
646
|
+
* that wants the discovery payload without spinning up a new client).
|
|
647
|
+
*/
|
|
648
|
+
declare function getSharedDiscovery(baseUrl: string, fetcher: () => Promise<unknown>): Promise<unknown>;
|
|
649
|
+
/** Test/dev helper to drop the cache (e.g. on logout or origin change). */
|
|
650
|
+
declare function clearSharedDiscoveryCache(): void;
|
|
641
651
|
/**
|
|
642
652
|
* Connection state for monitoring
|
|
643
653
|
*/
|
|
@@ -701,6 +711,7 @@ type BatchProgressListener = (event: BatchProgressEvent) => void;
|
|
|
701
711
|
declare class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
702
712
|
private client;
|
|
703
713
|
private connected;
|
|
714
|
+
private connectPromise;
|
|
704
715
|
private metadataCache;
|
|
705
716
|
private connectionState;
|
|
706
717
|
private connectionStateListeners;
|
|
@@ -954,4 +965,4 @@ declare function createObjectStackAdapter<T = unknown>(config: {
|
|
|
954
965
|
reconnectDelay?: number;
|
|
955
966
|
}): DataSource<T>;
|
|
956
967
|
|
|
957
|
-
export { type AuditEventType, type AuditLogConfig, type AuditLogEntry, AuthenticationError, type BatchProgressEvent, type BatchProgressListener, BulkOperationError, type CSPConfig, type CacheStats, type CloudDeploymentConfig, type CloudHostingConfig, type CloudMarketplaceEntry, CloudOperations, ConnectionError, type ConnectionState, type ConnectionStateEvent, type ConnectionStateListener, type ContractValidationError, type ContractValidationResult, type DataMaskingConfig, type DataMaskingRule, type EmailIntegrationConfig, type IntegrationConfig, IntegrationManager, type IntegrationProvider, type IntegrationTrigger, MetadataNotFoundError, ObjectStackAdapter, ObjectStackError, type PluginAPIContract, type PluginContract, type PluginExport, SecurityManager, type SecurityPolicy, type SlackIntegrationConfig, type StudioCanvasConfig, type StudioColorPalette, type StudioPropertyEditor, type StudioShadowPreset, type StudioThemeBuilderConfig, type StudioTypographyPreset, ValidationError, type WebhookIntegrationConfig, calculateAutoLayout, createDefaultCanvasConfig, createErrorFromResponse, createObjectStackAdapter, generateContractManifest, isErrorType, isObjectStackError, snapToGrid, validatePluginContract };
|
|
968
|
+
export { type AuditEventType, type AuditLogConfig, type AuditLogEntry, AuthenticationError, type BatchProgressEvent, type BatchProgressListener, BulkOperationError, type CSPConfig, type CacheStats, type CloudDeploymentConfig, type CloudHostingConfig, type CloudMarketplaceEntry, CloudOperations, ConnectionError, type ConnectionState, type ConnectionStateEvent, type ConnectionStateListener, type ContractValidationError, type ContractValidationResult, type DataMaskingConfig, type DataMaskingRule, type EmailIntegrationConfig, type IntegrationConfig, IntegrationManager, type IntegrationProvider, type IntegrationTrigger, MetadataNotFoundError, ObjectStackAdapter, ObjectStackError, type PluginAPIContract, type PluginContract, type PluginExport, SecurityManager, type SecurityPolicy, type SlackIntegrationConfig, type StudioCanvasConfig, type StudioColorPalette, type StudioPropertyEditor, type StudioShadowPreset, type StudioThemeBuilderConfig, type StudioTypographyPreset, ValidationError, type WebhookIntegrationConfig, calculateAutoLayout, clearSharedDiscoveryCache, createDefaultCanvasConfig, createErrorFromResponse, createObjectStackAdapter, generateContractManifest, getSharedDiscovery, isErrorType, isObjectStackError, snapToGrid, validatePluginContract };
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,72 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
|
|
|
4
4
|
|
|
5
5
|
// src/index.ts
|
|
6
6
|
import { ObjectStackClient } from "@objectstack/client";
|
|
7
|
-
|
|
7
|
+
|
|
8
|
+
// ../core/src/utils/filter-converter.ts
|
|
9
|
+
function convertOperatorToAST(operator) {
|
|
10
|
+
const operatorMap = {
|
|
11
|
+
"$eq": "=",
|
|
12
|
+
"$ne": "!=",
|
|
13
|
+
"$gt": ">",
|
|
14
|
+
"$gte": ">=",
|
|
15
|
+
"$lt": "<",
|
|
16
|
+
"$lte": "<=",
|
|
17
|
+
"$in": "in",
|
|
18
|
+
"$nin": "nin",
|
|
19
|
+
"$notin": "nin",
|
|
20
|
+
"$between": "between",
|
|
21
|
+
"$contains": "contains",
|
|
22
|
+
"$notContains": "notcontains",
|
|
23
|
+
"$notcontains": "notcontains",
|
|
24
|
+
"$startsWith": "startswith",
|
|
25
|
+
"$startswith": "startswith",
|
|
26
|
+
"$endsWith": "endswith",
|
|
27
|
+
"$endswith": "endswith"
|
|
28
|
+
};
|
|
29
|
+
return operatorMap[operator] || null;
|
|
30
|
+
}
|
|
31
|
+
function convertFiltersToAST(filter) {
|
|
32
|
+
const conditions = [];
|
|
33
|
+
for (const [field, value] of Object.entries(filter)) {
|
|
34
|
+
if (value === null || value === void 0) continue;
|
|
35
|
+
if (typeof value === "object" && !Array.isArray(value)) {
|
|
36
|
+
for (const [operator, operatorValue] of Object.entries(value)) {
|
|
37
|
+
if (operator === "$regex") {
|
|
38
|
+
console.warn(
|
|
39
|
+
`[ObjectUI] Warning: $regex operator is not fully supported. Converting to 'contains' which only supports substring matching, not regex patterns. Field: '${field}', Value: ${JSON.stringify(operatorValue)}. Consider using $contains or $startsWith instead.`
|
|
40
|
+
);
|
|
41
|
+
conditions.push([field, "contains", operatorValue]);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (operator === "$null") {
|
|
45
|
+
conditions.push([field, operatorValue ? "is_null" : "is_not_null", true]);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (operator === "$exists") {
|
|
49
|
+
conditions.push([field, operatorValue ? "is_not_null" : "is_null", true]);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const astOperator = convertOperatorToAST(operator);
|
|
53
|
+
if (astOperator) {
|
|
54
|
+
conditions.push([field, astOperator, operatorValue]);
|
|
55
|
+
} else {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`[ObjectUI] Unknown filter operator '${operator}' for field '${field}'. Supported operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $between, $contains, $notContains, $startsWith, $endsWith, $null, $exists. If you need exact object matching, use the value directly without an operator.`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
conditions.push([field, "=", value]);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (conditions.length === 0) {
|
|
66
|
+
return filter;
|
|
67
|
+
}
|
|
68
|
+
if (conditions.length === 1) {
|
|
69
|
+
return conditions[0];
|
|
70
|
+
}
|
|
71
|
+
return ["and", ...conditions];
|
|
72
|
+
}
|
|
8
73
|
|
|
9
74
|
// src/cache/MetadataCache.ts
|
|
10
75
|
var MetadataCache = class {
|
|
@@ -17,16 +82,19 @@ var MetadataCache = class {
|
|
|
17
82
|
*/
|
|
18
83
|
constructor(options = {}) {
|
|
19
84
|
__publicField(this, "cache");
|
|
85
|
+
__publicField(this, "inflight");
|
|
20
86
|
__publicField(this, "maxSize");
|
|
21
87
|
__publicField(this, "ttl");
|
|
22
88
|
__publicField(this, "stats");
|
|
23
89
|
this.cache = /* @__PURE__ */ new Map();
|
|
90
|
+
this.inflight = /* @__PURE__ */ new Map();
|
|
24
91
|
this.maxSize = options.maxSize || 100;
|
|
25
92
|
this.ttl = options.ttl || 5 * 60 * 1e3;
|
|
26
93
|
this.stats = {
|
|
27
94
|
hits: 0,
|
|
28
95
|
misses: 0,
|
|
29
|
-
evictions: 0
|
|
96
|
+
evictions: 0,
|
|
97
|
+
coalesced: 0
|
|
30
98
|
};
|
|
31
99
|
}
|
|
32
100
|
/**
|
|
@@ -52,10 +120,31 @@ var MetadataCache = class {
|
|
|
52
120
|
this.cache.delete(key);
|
|
53
121
|
}
|
|
54
122
|
}
|
|
123
|
+
const existing = this.inflight.get(key);
|
|
124
|
+
if (existing) {
|
|
125
|
+
this.stats.coalesced++;
|
|
126
|
+
return existing;
|
|
127
|
+
}
|
|
55
128
|
this.stats.misses++;
|
|
56
|
-
const
|
|
129
|
+
const promise = (async () => {
|
|
130
|
+
try {
|
|
131
|
+
const data = await fetcher();
|
|
132
|
+
this.set(key, data);
|
|
133
|
+
return data;
|
|
134
|
+
} finally {
|
|
135
|
+
this.inflight.delete(key);
|
|
136
|
+
}
|
|
137
|
+
})();
|
|
138
|
+
this.inflight.set(key, promise);
|
|
139
|
+
return promise;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Prime the cache with a pre-fetched value. Useful when a bulk endpoint
|
|
143
|
+
* (e.g. list of all object schemas) returns data that would otherwise
|
|
144
|
+
* be fetched again per item.
|
|
145
|
+
*/
|
|
146
|
+
prime(key, data) {
|
|
57
147
|
this.set(key, data);
|
|
58
|
-
return data;
|
|
59
148
|
}
|
|
60
149
|
/**
|
|
61
150
|
* Set a value in the cache
|
|
@@ -102,10 +191,12 @@ var MetadataCache = class {
|
|
|
102
191
|
*/
|
|
103
192
|
clear() {
|
|
104
193
|
this.cache.clear();
|
|
194
|
+
this.inflight.clear();
|
|
105
195
|
this.stats = {
|
|
106
196
|
hits: 0,
|
|
107
197
|
misses: 0,
|
|
108
|
-
evictions: 0
|
|
198
|
+
evictions: 0,
|
|
199
|
+
coalesced: 0
|
|
109
200
|
};
|
|
110
201
|
}
|
|
111
202
|
/**
|
|
@@ -122,6 +213,7 @@ var MetadataCache = class {
|
|
|
122
213
|
hits: this.stats.hits,
|
|
123
214
|
misses: this.stats.misses,
|
|
124
215
|
evictions: this.stats.evictions,
|
|
216
|
+
coalesced: this.stats.coalesced,
|
|
125
217
|
hitRate
|
|
126
218
|
};
|
|
127
219
|
}
|
|
@@ -695,10 +787,26 @@ function calculateAutoLayout(items, canvasWidth, padding = 40, gap = 40) {
|
|
|
695
787
|
}
|
|
696
788
|
|
|
697
789
|
// src/index.ts
|
|
790
|
+
var discoveryCache = /* @__PURE__ */ new Map();
|
|
791
|
+
async function getSharedDiscovery(baseUrl, fetcher) {
|
|
792
|
+
const key = baseUrl || "<default>";
|
|
793
|
+
const cached = discoveryCache.get(key);
|
|
794
|
+
if (cached) return cached;
|
|
795
|
+
const p = fetcher().catch((err) => {
|
|
796
|
+
discoveryCache.delete(key);
|
|
797
|
+
throw err;
|
|
798
|
+
});
|
|
799
|
+
discoveryCache.set(key, p);
|
|
800
|
+
return p;
|
|
801
|
+
}
|
|
802
|
+
function clearSharedDiscoveryCache() {
|
|
803
|
+
discoveryCache.clear();
|
|
804
|
+
}
|
|
698
805
|
var ObjectStackAdapter = class {
|
|
699
806
|
constructor(config) {
|
|
700
807
|
__publicField(this, "client");
|
|
701
808
|
__publicField(this, "connected", false);
|
|
809
|
+
__publicField(this, "connectPromise", null);
|
|
702
810
|
__publicField(this, "metadataCache");
|
|
703
811
|
__publicField(this, "connectionState", "disconnected");
|
|
704
812
|
__publicField(this, "connectionStateListeners", []);
|
|
@@ -724,10 +832,25 @@ var ObjectStackAdapter = class {
|
|
|
724
832
|
* Call this before making requests or it will auto-connect on first request.
|
|
725
833
|
*/
|
|
726
834
|
async connect() {
|
|
727
|
-
if (
|
|
728
|
-
|
|
835
|
+
if (this.connected) return;
|
|
836
|
+
if (this.connectPromise) return this.connectPromise;
|
|
837
|
+
this.setConnectionState("connecting");
|
|
838
|
+
this.connectPromise = (async () => {
|
|
729
839
|
try {
|
|
730
|
-
|
|
840
|
+
const baseUrl = this.baseUrl || "";
|
|
841
|
+
const discoveryUrl = baseUrl ? `${baseUrl.replace(/\/$/, "")}/api/v1/discovery` : "/api/v1/discovery";
|
|
842
|
+
const data = await getSharedDiscovery(baseUrl, async () => {
|
|
843
|
+
const res = await this.fetchImpl(discoveryUrl, {
|
|
844
|
+
method: "GET",
|
|
845
|
+
headers: this.token ? { Authorization: `Bearer ${this.token}` } : void 0
|
|
846
|
+
});
|
|
847
|
+
if (!res.ok) {
|
|
848
|
+
throw new Error(`discovery ${res.status} ${res.statusText}`);
|
|
849
|
+
}
|
|
850
|
+
const body = await res.json();
|
|
851
|
+
return body && typeof body.success === "boolean" && "data" in body ? body.data : body;
|
|
852
|
+
});
|
|
853
|
+
this.client.discoveryInfo = data;
|
|
731
854
|
this.connected = true;
|
|
732
855
|
this.reconnectAttempts = 0;
|
|
733
856
|
this.setConnectionState("connected");
|
|
@@ -744,8 +867,11 @@ var ObjectStackAdapter = class {
|
|
|
744
867
|
} else {
|
|
745
868
|
throw connectionError;
|
|
746
869
|
}
|
|
870
|
+
} finally {
|
|
871
|
+
this.connectPromise = null;
|
|
747
872
|
}
|
|
748
|
-
}
|
|
873
|
+
})();
|
|
874
|
+
return this.connectPromise;
|
|
749
875
|
}
|
|
750
876
|
/**
|
|
751
877
|
* Attempt to reconnect to the server with exponential backoff
|
|
@@ -1086,8 +1212,11 @@ var ObjectStackAdapter = class {
|
|
|
1086
1212
|
queryParams.set("sort", sortStr);
|
|
1087
1213
|
}
|
|
1088
1214
|
}
|
|
1089
|
-
if (params.$filter) {
|
|
1090
|
-
|
|
1215
|
+
if (params.$filter !== void 0 && params.$filter !== null) {
|
|
1216
|
+
const isEmpty = Array.isArray(params.$filter) ? params.$filter.length === 0 : typeof params.$filter === "object" && Object.keys(params.$filter).length === 0;
|
|
1217
|
+
if (!isEmpty) {
|
|
1218
|
+
queryParams.set("filter", JSON.stringify(params.$filter));
|
|
1219
|
+
}
|
|
1091
1220
|
}
|
|
1092
1221
|
const baseUrl = this.baseUrl.replace(/\/$/, "");
|
|
1093
1222
|
const qs = queryParams.toString();
|
|
@@ -1123,11 +1252,14 @@ var ObjectStackAdapter = class {
|
|
|
1123
1252
|
if (params.$select) {
|
|
1124
1253
|
options.select = params.$select;
|
|
1125
1254
|
}
|
|
1126
|
-
if (params.$filter) {
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1255
|
+
if (params.$filter !== void 0 && params.$filter !== null) {
|
|
1256
|
+
const isEmpty = Array.isArray(params.$filter) ? params.$filter.length === 0 : typeof params.$filter === "object" && Object.keys(params.$filter).length === 0;
|
|
1257
|
+
if (!isEmpty) {
|
|
1258
|
+
if (Array.isArray(params.$filter)) {
|
|
1259
|
+
options.filters = params.$filter;
|
|
1260
|
+
} else {
|
|
1261
|
+
options.filters = convertFiltersToAST(params.$filter);
|
|
1262
|
+
}
|
|
1131
1263
|
}
|
|
1132
1264
|
}
|
|
1133
1265
|
if (params.$orderby) {
|
|
@@ -1491,10 +1623,12 @@ export {
|
|
|
1491
1623
|
SecurityManager,
|
|
1492
1624
|
ValidationError,
|
|
1493
1625
|
calculateAutoLayout,
|
|
1626
|
+
clearSharedDiscoveryCache,
|
|
1494
1627
|
createDefaultCanvasConfig,
|
|
1495
1628
|
createErrorFromResponse,
|
|
1496
1629
|
createObjectStackAdapter,
|
|
1497
1630
|
generateContractManifest,
|
|
1631
|
+
getSharedDiscovery,
|
|
1498
1632
|
isErrorType,
|
|
1499
1633
|
isObjectStackError,
|
|
1500
1634
|
snapToGrid,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/data-objectstack",
|
|
3
|
-
"version": "3.3.
|
|
3
|
+
"version": "3.3.2",
|
|
4
4
|
"description": "ObjectStack Data Adapter for Object UI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -17,24 +17,49 @@
|
|
|
17
17
|
"files": [
|
|
18
18
|
"dist",
|
|
19
19
|
"src",
|
|
20
|
-
"README.md"
|
|
20
|
+
"README.md",
|
|
21
|
+
"CHANGELOG.md",
|
|
22
|
+
"LICENSE"
|
|
21
23
|
],
|
|
22
24
|
"dependencies": {
|
|
23
|
-
"@objectstack/client": "^4.0.
|
|
24
|
-
"@object-ui/core": "3.3.
|
|
25
|
-
"@object-ui/types": "3.3.
|
|
25
|
+
"@objectstack/client": "^4.0.4",
|
|
26
|
+
"@object-ui/core": "3.3.2",
|
|
27
|
+
"@object-ui/types": "3.3.2"
|
|
26
28
|
},
|
|
27
29
|
"devDependencies": {
|
|
28
30
|
"tsup": "^8.5.1",
|
|
29
|
-
"typescript": "^6.0.
|
|
30
|
-
"vitest": "^4.1.
|
|
31
|
+
"typescript": "^6.0.3",
|
|
32
|
+
"vitest": "^4.1.5"
|
|
31
33
|
},
|
|
32
34
|
"publishConfig": {
|
|
33
35
|
"access": "public"
|
|
34
36
|
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"objectui",
|
|
39
|
+
"sdui",
|
|
40
|
+
"schema-driven-ui",
|
|
41
|
+
"react",
|
|
42
|
+
"tailwind",
|
|
43
|
+
"shadcn",
|
|
44
|
+
"objectstack",
|
|
45
|
+
"data-source",
|
|
46
|
+
"adapter",
|
|
47
|
+
"objectql",
|
|
48
|
+
"objectstack-client"
|
|
49
|
+
],
|
|
50
|
+
"author": "ObjectStack Team <team@objectstack.ai>",
|
|
51
|
+
"repository": {
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "git+https://github.com/objectstack-ai/objectui.git",
|
|
54
|
+
"directory": "packages/data-objectstack"
|
|
55
|
+
},
|
|
56
|
+
"bugs": {
|
|
57
|
+
"url": "https://github.com/objectstack-ai/objectui/issues"
|
|
58
|
+
},
|
|
59
|
+
"homepage": "https://www.objectui.org/docs/guide/data-source",
|
|
35
60
|
"scripts": {
|
|
36
|
-
"build": "tsup
|
|
37
|
-
"dev": "tsup
|
|
61
|
+
"build": "tsup",
|
|
62
|
+
"dev": "tsup --watch",
|
|
38
63
|
"clean": "rm -rf dist",
|
|
39
64
|
"type-check": "tsc --noEmit",
|
|
40
65
|
"test": "vitest run",
|
|
@@ -410,6 +410,57 @@ describe('MetadataCache', () => {
|
|
|
410
410
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
411
411
|
});
|
|
412
412
|
|
|
413
|
+
it('should coalesce concurrent fetches for the same key', async () => {
|
|
414
|
+
let resolveFetch: (v: { data: string }) => void = () => {};
|
|
415
|
+
const fetcher = vi.fn(
|
|
416
|
+
() =>
|
|
417
|
+
new Promise<{ data: string }>((resolve) => {
|
|
418
|
+
resolveFetch = resolve;
|
|
419
|
+
})
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
const p1 = cache.get('shared-key', fetcher);
|
|
423
|
+
const p2 = cache.get('shared-key', fetcher);
|
|
424
|
+
const p3 = cache.get('shared-key', fetcher);
|
|
425
|
+
|
|
426
|
+
// All three calls should share a single in-flight fetcher invocation
|
|
427
|
+
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
428
|
+
|
|
429
|
+
resolveFetch({ data: 'shared' });
|
|
430
|
+
const [r1, r2, r3] = await Promise.all([p1, p2, p3]);
|
|
431
|
+
|
|
432
|
+
expect(r1).toEqual({ data: 'shared' });
|
|
433
|
+
expect(r2).toBe(r1);
|
|
434
|
+
expect(r3).toBe(r1);
|
|
435
|
+
|
|
436
|
+
const stats = cache.getStats();
|
|
437
|
+
expect(stats.coalesced).toBe(2);
|
|
438
|
+
expect(stats.misses).toBe(1);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('should clear inflight slot after fetch rejection so retries are possible', async () => {
|
|
442
|
+
const fetcher = vi
|
|
443
|
+
.fn()
|
|
444
|
+
.mockRejectedValueOnce(new Error('boom'))
|
|
445
|
+
.mockResolvedValueOnce({ data: 'ok' });
|
|
446
|
+
|
|
447
|
+
await expect(cache.get('retry-key', fetcher)).rejects.toThrow('boom');
|
|
448
|
+
const result = await cache.get('retry-key', fetcher);
|
|
449
|
+
|
|
450
|
+
expect(result).toEqual({ data: 'ok' });
|
|
451
|
+
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('should expose prime() to seed cache from bulk endpoints', async () => {
|
|
455
|
+
cache.prime('object:account', { name: 'account', fields: [] });
|
|
456
|
+
|
|
457
|
+
const fetcher = vi.fn(async () => ({ name: 'account', fields: [{ name: 'id' }] }));
|
|
458
|
+
const result = await cache.get('object:account', fetcher);
|
|
459
|
+
|
|
460
|
+
expect(fetcher).not.toHaveBeenCalled();
|
|
461
|
+
expect(result).toEqual({ name: 'account', fields: [] });
|
|
462
|
+
});
|
|
463
|
+
|
|
413
464
|
it('should handle very large cache', async () => {
|
|
414
465
|
const largeCache = new MetadataCache({ maxSize: 10000, ttl: 60000 });
|
|
415
466
|
|
|
@@ -25,6 +25,8 @@ export interface CacheStats {
|
|
|
25
25
|
hits: number;
|
|
26
26
|
misses: number;
|
|
27
27
|
evictions: number;
|
|
28
|
+
/** Number of concurrent fetches that were coalesced onto an in-flight request. */
|
|
29
|
+
coalesced: number;
|
|
28
30
|
hitRate: number;
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -38,9 +40,9 @@ export interface CacheStats {
|
|
|
38
40
|
* - Async-safe operations
|
|
39
41
|
* - Performance statistics tracking
|
|
40
42
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
43
|
+
* Concurrent requests for the same uncached key are deduplicated via an
|
|
44
|
+
* internal in-flight Promise map: only the first call invokes the fetcher,
|
|
45
|
+
* and subsequent callers receive the same Promise.
|
|
44
46
|
*
|
|
45
47
|
* @example
|
|
46
48
|
* ```typescript
|
|
@@ -55,12 +57,14 @@ export interface CacheStats {
|
|
|
55
57
|
*/
|
|
56
58
|
export class MetadataCache {
|
|
57
59
|
private cache: Map<string, CachedSchema>;
|
|
60
|
+
private inflight: Map<string, Promise<unknown>>;
|
|
58
61
|
private maxSize: number;
|
|
59
62
|
private ttl: number;
|
|
60
63
|
private stats: {
|
|
61
64
|
hits: number;
|
|
62
65
|
misses: number;
|
|
63
66
|
evictions: number;
|
|
67
|
+
coalesced: number;
|
|
64
68
|
};
|
|
65
69
|
|
|
66
70
|
/**
|
|
@@ -72,12 +76,14 @@ export class MetadataCache {
|
|
|
72
76
|
*/
|
|
73
77
|
constructor(options: { maxSize?: number; ttl?: number } = {}) {
|
|
74
78
|
this.cache = new Map();
|
|
79
|
+
this.inflight = new Map();
|
|
75
80
|
this.maxSize = options.maxSize || 100;
|
|
76
81
|
this.ttl = options.ttl || 5 * 60 * 1000; // 5 minutes default
|
|
77
82
|
this.stats = {
|
|
78
83
|
hits: 0,
|
|
79
84
|
misses: 0,
|
|
80
85
|
evictions: 0,
|
|
86
|
+
coalesced: 0,
|
|
81
87
|
};
|
|
82
88
|
}
|
|
83
89
|
|
|
@@ -113,14 +119,34 @@ export class MetadataCache {
|
|
|
113
119
|
}
|
|
114
120
|
}
|
|
115
121
|
|
|
116
|
-
// Cache miss -
|
|
122
|
+
// Cache miss - dedupe concurrent fetches for the same key
|
|
123
|
+
const existing = this.inflight.get(key);
|
|
124
|
+
if (existing) {
|
|
125
|
+
this.stats.coalesced++;
|
|
126
|
+
return existing as Promise<T>;
|
|
127
|
+
}
|
|
128
|
+
|
|
117
129
|
this.stats.misses++;
|
|
118
|
-
const
|
|
130
|
+
const promise = (async () => {
|
|
131
|
+
try {
|
|
132
|
+
const data = await fetcher();
|
|
133
|
+
this.set(key, data);
|
|
134
|
+
return data;
|
|
135
|
+
} finally {
|
|
136
|
+
this.inflight.delete(key);
|
|
137
|
+
}
|
|
138
|
+
})();
|
|
139
|
+
this.inflight.set(key, promise as Promise<unknown>);
|
|
140
|
+
return promise;
|
|
141
|
+
}
|
|
119
142
|
|
|
120
|
-
|
|
143
|
+
/**
|
|
144
|
+
* Prime the cache with a pre-fetched value. Useful when a bulk endpoint
|
|
145
|
+
* (e.g. list of all object schemas) returns data that would otherwise
|
|
146
|
+
* be fetched again per item.
|
|
147
|
+
*/
|
|
148
|
+
prime(key: string, data: unknown): void {
|
|
121
149
|
this.set(key, data);
|
|
122
|
-
|
|
123
|
-
return data;
|
|
124
150
|
}
|
|
125
151
|
|
|
126
152
|
/**
|
|
@@ -178,10 +204,12 @@ export class MetadataCache {
|
|
|
178
204
|
*/
|
|
179
205
|
clear(): void {
|
|
180
206
|
this.cache.clear();
|
|
207
|
+
this.inflight.clear();
|
|
181
208
|
this.stats = {
|
|
182
209
|
hits: 0,
|
|
183
210
|
misses: 0,
|
|
184
211
|
evictions: 0,
|
|
212
|
+
coalesced: 0,
|
|
185
213
|
};
|
|
186
214
|
}
|
|
187
215
|
|
|
@@ -200,6 +228,7 @@ export class MetadataCache {
|
|
|
200
228
|
hits: this.stats.hits,
|
|
201
229
|
misses: this.stats.misses,
|
|
202
230
|
evictions: this.stats.evictions,
|
|
231
|
+
coalesced: this.stats.coalesced,
|
|
203
232
|
hitRate: hitRate,
|
|
204
233
|
};
|
|
205
234
|
}
|
package/src/connection.test.ts
CHANGED
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
ObjectStackAdapter,
|
|
12
|
+
ConnectionState,
|
|
13
|
+
ConnectionStateEvent,
|
|
14
|
+
BatchProgressEvent,
|
|
15
|
+
clearSharedDiscoveryCache,
|
|
16
|
+
} from './index';
|
|
11
17
|
|
|
12
18
|
describe('Connection State Monitoring', () => {
|
|
13
19
|
let adapter: ObjectStackAdapter;
|
|
@@ -100,6 +106,10 @@ describe('Batch Progress Events', () => {
|
|
|
100
106
|
});
|
|
101
107
|
|
|
102
108
|
describe('getDiscovery', () => {
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
clearSharedDiscoveryCache();
|
|
111
|
+
});
|
|
112
|
+
|
|
103
113
|
it('should return discoveryInfo from the underlying client after connect', async () => {
|
|
104
114
|
const mockDiscovery = {
|
|
105
115
|
name: 'test-server',
|
|
@@ -110,32 +120,69 @@ describe('getDiscovery', () => {
|
|
|
110
120
|
},
|
|
111
121
|
};
|
|
112
122
|
|
|
123
|
+
const fetchImpl = vi.fn(async () =>
|
|
124
|
+
({
|
|
125
|
+
ok: true,
|
|
126
|
+
json: async () => ({ success: true, data: mockDiscovery }),
|
|
127
|
+
}) as unknown as Response,
|
|
128
|
+
);
|
|
129
|
+
|
|
113
130
|
const adapter = new ObjectStackAdapter({
|
|
114
131
|
baseUrl: 'http://localhost:3000',
|
|
115
132
|
autoReconnect: false,
|
|
133
|
+
fetch: fetchImpl,
|
|
116
134
|
});
|
|
117
135
|
|
|
118
|
-
|
|
119
|
-
const client = adapter.getClient();
|
|
120
|
-
vi.spyOn(client, 'connect').mockResolvedValue(mockDiscovery as any);
|
|
121
|
-
// Simulate what connect() does: sets discoveryInfo
|
|
122
|
-
(client as any).discoveryInfo = mockDiscovery;
|
|
136
|
+
await adapter.connect();
|
|
123
137
|
|
|
124
138
|
const discovery = await adapter.getDiscovery();
|
|
125
139
|
expect(discovery).toEqual(mockDiscovery);
|
|
126
140
|
expect((discovery as any)?.services?.auth?.enabled).toBe(false);
|
|
141
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
142
|
+
expect(fetchImpl.mock.calls[0][0]).toContain('/api/v1/discovery');
|
|
127
143
|
});
|
|
128
144
|
|
|
129
145
|
it('should return null when connection fails', async () => {
|
|
146
|
+
const fetchImpl = vi.fn(async () => {
|
|
147
|
+
throw new Error('Connection failed');
|
|
148
|
+
});
|
|
149
|
+
|
|
130
150
|
const adapter = new ObjectStackAdapter({
|
|
131
151
|
baseUrl: 'http://localhost:3000',
|
|
132
152
|
autoReconnect: false,
|
|
153
|
+
fetch: fetchImpl as any,
|
|
133
154
|
});
|
|
134
155
|
|
|
135
|
-
|
|
136
|
-
vi.spyOn(client, 'connect').mockRejectedValue(new Error('Connection failed'));
|
|
156
|
+
await expect(adapter.connect()).rejects.toThrow();
|
|
137
157
|
|
|
138
158
|
const discovery = await adapter.getDiscovery();
|
|
139
159
|
expect(discovery).toBeNull();
|
|
140
160
|
});
|
|
161
|
+
|
|
162
|
+
it('should share a single discovery fetch across adapters with the same baseUrl', async () => {
|
|
163
|
+
const mockDiscovery = { name: 'shared', version: '1.0.0' };
|
|
164
|
+
const fetchImpl = vi.fn(async () =>
|
|
165
|
+
({
|
|
166
|
+
ok: true,
|
|
167
|
+
json: async () => ({ success: true, data: mockDiscovery }),
|
|
168
|
+
}) as unknown as Response,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const a1 = new ObjectStackAdapter({
|
|
172
|
+
baseUrl: 'http://localhost:3000',
|
|
173
|
+
autoReconnect: false,
|
|
174
|
+
fetch: fetchImpl,
|
|
175
|
+
});
|
|
176
|
+
const a2 = new ObjectStackAdapter({
|
|
177
|
+
baseUrl: 'http://localhost:3000',
|
|
178
|
+
autoReconnect: false,
|
|
179
|
+
fetch: fetchImpl,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await Promise.all([a1.connect(), a2.connect()]);
|
|
183
|
+
|
|
184
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
185
|
+
expect(await a1.getDiscovery()).toEqual(mockDiscovery);
|
|
186
|
+
expect(await a2.getDiscovery()).toEqual(mockDiscovery);
|
|
187
|
+
});
|
|
141
188
|
});
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,38 @@ import {
|
|
|
18
18
|
createErrorFromResponse,
|
|
19
19
|
} from './errors';
|
|
20
20
|
|
|
21
|
+
// Module-level discovery cache. Multiple ObjectStackAdapter instances pointed
|
|
22
|
+
// at the same baseUrl (e.g. ConditionalAuthWrapper's throwaway adapter +
|
|
23
|
+
// AdapterProvider's main adapter) would otherwise each fire `/discovery`. By
|
|
24
|
+
// keying on baseUrl we collapse them to a single network round trip per origin.
|
|
25
|
+
const discoveryCache = new Map<string, Promise<unknown>>();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Fetch the server `discovery` document once per (baseUrl) and reuse the
|
|
29
|
+
* resulting Promise. Used by `ObjectStackAdapter.connect()` (and any caller
|
|
30
|
+
* that wants the discovery payload without spinning up a new client).
|
|
31
|
+
*/
|
|
32
|
+
export async function getSharedDiscovery(
|
|
33
|
+
baseUrl: string,
|
|
34
|
+
fetcher: () => Promise<unknown>,
|
|
35
|
+
): Promise<unknown> {
|
|
36
|
+
const key = baseUrl || '<default>';
|
|
37
|
+
const cached = discoveryCache.get(key);
|
|
38
|
+
if (cached) return cached;
|
|
39
|
+
const p = fetcher().catch((err) => {
|
|
40
|
+
// Allow retry on failure
|
|
41
|
+
discoveryCache.delete(key);
|
|
42
|
+
throw err;
|
|
43
|
+
});
|
|
44
|
+
discoveryCache.set(key, p);
|
|
45
|
+
return p;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Test/dev helper to drop the cache (e.g. on logout or origin change). */
|
|
49
|
+
export function clearSharedDiscoveryCache(): void {
|
|
50
|
+
discoveryCache.clear();
|
|
51
|
+
}
|
|
52
|
+
|
|
21
53
|
/**
|
|
22
54
|
* Connection state for monitoring
|
|
23
55
|
*/
|
|
@@ -88,6 +120,7 @@ export type { FileUploadResult } from '@object-ui/types';
|
|
|
88
120
|
export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
89
121
|
private client: ObjectStackClient;
|
|
90
122
|
private connected: boolean = false;
|
|
123
|
+
private connectPromise: Promise<void> | null = null;
|
|
91
124
|
private metadataCache: MetadataCache;
|
|
92
125
|
private connectionState: ConnectionState = 'disconnected';
|
|
93
126
|
private connectionStateListeners: ConnectionStateListener[] = [];
|
|
@@ -127,11 +160,44 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
127
160
|
* Call this before making requests or it will auto-connect on first request.
|
|
128
161
|
*/
|
|
129
162
|
async connect(): Promise<void> {
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
163
|
+
if (this.connected) return;
|
|
164
|
+
// Dedupe concurrent connect() calls — without this, every component
|
|
165
|
+
// that mounts on first paint can trigger an independent discovery
|
|
166
|
+
// request before the first one completes.
|
|
167
|
+
if (this.connectPromise) return this.connectPromise;
|
|
168
|
+
|
|
169
|
+
this.setConnectionState('connecting');
|
|
170
|
+
this.connectPromise = (async () => {
|
|
133
171
|
try {
|
|
134
|
-
|
|
172
|
+
// Use the module-level discovery cache so multiple adapter instances
|
|
173
|
+
// (or React StrictMode double-mounts) at the same baseUrl share a
|
|
174
|
+
// single network round trip. We inject the result into the client's
|
|
175
|
+
// private `discoveryInfo` field to avoid client.connect() re-fetching.
|
|
176
|
+
const baseUrl = this.baseUrl || '';
|
|
177
|
+
const discoveryUrl = baseUrl
|
|
178
|
+
? `${baseUrl.replace(/\/$/, '')}/api/v1/discovery`
|
|
179
|
+
: '/api/v1/discovery';
|
|
180
|
+
|
|
181
|
+
const data = await getSharedDiscovery(baseUrl, async () => {
|
|
182
|
+
const res = await this.fetchImpl(discoveryUrl, {
|
|
183
|
+
method: 'GET',
|
|
184
|
+
headers: this.token
|
|
185
|
+
? { Authorization: `Bearer ${this.token}` }
|
|
186
|
+
: undefined,
|
|
187
|
+
});
|
|
188
|
+
if (!res.ok) {
|
|
189
|
+
throw new Error(`discovery ${res.status} ${res.statusText}`);
|
|
190
|
+
}
|
|
191
|
+
const body = await res.json();
|
|
192
|
+
return body && typeof body.success === 'boolean' && 'data' in body
|
|
193
|
+
? body.data
|
|
194
|
+
: body;
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Prime the underlying client's cached discovery so capability/route
|
|
198
|
+
// helpers continue to work without a redundant fetch.
|
|
199
|
+
(this.client as unknown as { discoveryInfo?: unknown }).discoveryInfo = data;
|
|
200
|
+
|
|
135
201
|
this.connected = true;
|
|
136
202
|
this.reconnectAttempts = 0;
|
|
137
203
|
this.setConnectionState('connected');
|
|
@@ -142,17 +208,20 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
142
208
|
undefined,
|
|
143
209
|
{ originalError: error }
|
|
144
210
|
);
|
|
145
|
-
|
|
211
|
+
|
|
146
212
|
this.setConnectionState('error', connectionError);
|
|
147
|
-
|
|
213
|
+
|
|
148
214
|
// Attempt auto-reconnect if enabled
|
|
149
215
|
if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
150
216
|
await this.attemptReconnect();
|
|
151
217
|
} else {
|
|
152
218
|
throw connectionError;
|
|
153
219
|
}
|
|
220
|
+
} finally {
|
|
221
|
+
this.connectPromise = null;
|
|
154
222
|
}
|
|
155
|
-
}
|
|
223
|
+
})();
|
|
224
|
+
return this.connectPromise;
|
|
156
225
|
}
|
|
157
226
|
|
|
158
227
|
/**
|
|
@@ -582,9 +651,14 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
582
651
|
}
|
|
583
652
|
}
|
|
584
653
|
|
|
585
|
-
// Filter
|
|
586
|
-
if (params.$filter) {
|
|
587
|
-
|
|
654
|
+
// Filter — drop empty arrays/objects so we don't send `?filter=%5B%5D`
|
|
655
|
+
if (params.$filter !== undefined && params.$filter !== null) {
|
|
656
|
+
const isEmpty = Array.isArray(params.$filter)
|
|
657
|
+
? params.$filter.length === 0
|
|
658
|
+
: typeof params.$filter === 'object' && Object.keys(params.$filter).length === 0;
|
|
659
|
+
if (!isEmpty) {
|
|
660
|
+
queryParams.set('filter', JSON.stringify(params.$filter));
|
|
661
|
+
}
|
|
588
662
|
}
|
|
589
663
|
|
|
590
664
|
const baseUrl = this.baseUrl.replace(/\/$/, '');
|
|
@@ -632,13 +706,19 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
632
706
|
options.select = params.$select;
|
|
633
707
|
}
|
|
634
708
|
|
|
635
|
-
// Filtering - convert to ObjectStack FilterNode AST format
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
709
|
+
// Filtering - convert to ObjectStack FilterNode AST format. Treat empty
|
|
710
|
+
// arrays/objects as "no filter" to avoid emitting `filter=[]` over the wire.
|
|
711
|
+
if (params.$filter !== undefined && params.$filter !== null) {
|
|
712
|
+
const isEmpty = Array.isArray(params.$filter)
|
|
713
|
+
? params.$filter.length === 0
|
|
714
|
+
: typeof params.$filter === 'object' && Object.keys(params.$filter).length === 0;
|
|
715
|
+
if (!isEmpty) {
|
|
716
|
+
if (Array.isArray(params.$filter)) {
|
|
717
|
+
// Assume active AST format if it's already an array
|
|
718
|
+
options.filters = params.$filter;
|
|
719
|
+
} else {
|
|
720
|
+
options.filters = convertFiltersToAST(params.$filter);
|
|
721
|
+
}
|
|
642
722
|
}
|
|
643
723
|
}
|
|
644
724
|
|