@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 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
- var import_core = require("@object-ui/core");
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 data = await fetcher();
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 (!this.connected) {
768
- this.setConnectionState("connecting");
877
+ if (this.connected) return;
878
+ if (this.connectPromise) return this.connectPromise;
879
+ this.setConnectionState("connecting");
880
+ this.connectPromise = (async () => {
769
881
  try {
770
- await this.client.connect();
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
- queryParams.set("filter", JSON.stringify(params.$filter));
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
- if (Array.isArray(params.$filter)) {
1168
- options.filters = params.$filter;
1169
- } else {
1170
- options.filters = (0, import_core.convertFiltersToAST)(params.$filter);
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
- import { convertFiltersToAST } from "@object-ui/core";
7
+
8
+ // ../core/src/utils/filter-converter.ts
9
+ function convertOperatorToAST(operator) {
10
+ const operatorMap = {
11
+ "$eq": "=",
12
+ "$ne": "!=",
13
+ "$gt": ">",
14
+ "$gte": ">=",
15
+ "$lt": "<",
16
+ "$lte": "<=",
17
+ "$in": "in",
18
+ "$nin": "nin",
19
+ "$notin": "nin",
20
+ "$between": "between",
21
+ "$contains": "contains",
22
+ "$notContains": "notcontains",
23
+ "$notcontains": "notcontains",
24
+ "$startsWith": "startswith",
25
+ "$startswith": "startswith",
26
+ "$endsWith": "endswith",
27
+ "$endswith": "endswith"
28
+ };
29
+ return operatorMap[operator] || null;
30
+ }
31
+ function convertFiltersToAST(filter) {
32
+ const conditions = [];
33
+ for (const [field, value] of Object.entries(filter)) {
34
+ if (value === null || value === void 0) continue;
35
+ if (typeof value === "object" && !Array.isArray(value)) {
36
+ for (const [operator, operatorValue] of Object.entries(value)) {
37
+ if (operator === "$regex") {
38
+ console.warn(
39
+ `[ObjectUI] Warning: $regex operator is not fully supported. Converting to 'contains' which only supports substring matching, not regex patterns. Field: '${field}', Value: ${JSON.stringify(operatorValue)}. Consider using $contains or $startsWith instead.`
40
+ );
41
+ conditions.push([field, "contains", operatorValue]);
42
+ continue;
43
+ }
44
+ if (operator === "$null") {
45
+ conditions.push([field, operatorValue ? "is_null" : "is_not_null", true]);
46
+ continue;
47
+ }
48
+ if (operator === "$exists") {
49
+ conditions.push([field, operatorValue ? "is_not_null" : "is_null", true]);
50
+ continue;
51
+ }
52
+ const astOperator = convertOperatorToAST(operator);
53
+ if (astOperator) {
54
+ conditions.push([field, astOperator, operatorValue]);
55
+ } else {
56
+ throw new Error(
57
+ `[ObjectUI] Unknown filter operator '${operator}' for field '${field}'. Supported operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $between, $contains, $notContains, $startsWith, $endsWith, $null, $exists. If you need exact object matching, use the value directly without an operator.`
58
+ );
59
+ }
60
+ }
61
+ } else {
62
+ conditions.push([field, "=", value]);
63
+ }
64
+ }
65
+ if (conditions.length === 0) {
66
+ return filter;
67
+ }
68
+ if (conditions.length === 1) {
69
+ return conditions[0];
70
+ }
71
+ return ["and", ...conditions];
72
+ }
8
73
 
9
74
  // src/cache/MetadataCache.ts
10
75
  var MetadataCache = class {
@@ -17,16 +82,19 @@ var MetadataCache = class {
17
82
  */
18
83
  constructor(options = {}) {
19
84
  __publicField(this, "cache");
85
+ __publicField(this, "inflight");
20
86
  __publicField(this, "maxSize");
21
87
  __publicField(this, "ttl");
22
88
  __publicField(this, "stats");
23
89
  this.cache = /* @__PURE__ */ new Map();
90
+ this.inflight = /* @__PURE__ */ new Map();
24
91
  this.maxSize = options.maxSize || 100;
25
92
  this.ttl = options.ttl || 5 * 60 * 1e3;
26
93
  this.stats = {
27
94
  hits: 0,
28
95
  misses: 0,
29
- evictions: 0
96
+ evictions: 0,
97
+ coalesced: 0
30
98
  };
31
99
  }
32
100
  /**
@@ -52,10 +120,31 @@ var MetadataCache = class {
52
120
  this.cache.delete(key);
53
121
  }
54
122
  }
123
+ const existing = this.inflight.get(key);
124
+ if (existing) {
125
+ this.stats.coalesced++;
126
+ return existing;
127
+ }
55
128
  this.stats.misses++;
56
- const data = await fetcher();
129
+ const promise = (async () => {
130
+ try {
131
+ const data = await fetcher();
132
+ this.set(key, data);
133
+ return data;
134
+ } finally {
135
+ this.inflight.delete(key);
136
+ }
137
+ })();
138
+ this.inflight.set(key, promise);
139
+ return promise;
140
+ }
141
+ /**
142
+ * Prime the cache with a pre-fetched value. Useful when a bulk endpoint
143
+ * (e.g. list of all object schemas) returns data that would otherwise
144
+ * be fetched again per item.
145
+ */
146
+ prime(key, data) {
57
147
  this.set(key, data);
58
- return data;
59
148
  }
60
149
  /**
61
150
  * Set a value in the cache
@@ -102,10 +191,12 @@ var MetadataCache = class {
102
191
  */
103
192
  clear() {
104
193
  this.cache.clear();
194
+ this.inflight.clear();
105
195
  this.stats = {
106
196
  hits: 0,
107
197
  misses: 0,
108
- evictions: 0
198
+ evictions: 0,
199
+ coalesced: 0
109
200
  };
110
201
  }
111
202
  /**
@@ -122,6 +213,7 @@ var MetadataCache = class {
122
213
  hits: this.stats.hits,
123
214
  misses: this.stats.misses,
124
215
  evictions: this.stats.evictions,
216
+ coalesced: this.stats.coalesced,
125
217
  hitRate
126
218
  };
127
219
  }
@@ -695,10 +787,26 @@ function calculateAutoLayout(items, canvasWidth, padding = 40, gap = 40) {
695
787
  }
696
788
 
697
789
  // src/index.ts
790
+ var discoveryCache = /* @__PURE__ */ new Map();
791
+ async function getSharedDiscovery(baseUrl, fetcher) {
792
+ const key = baseUrl || "<default>";
793
+ const cached = discoveryCache.get(key);
794
+ if (cached) return cached;
795
+ const p = fetcher().catch((err) => {
796
+ discoveryCache.delete(key);
797
+ throw err;
798
+ });
799
+ discoveryCache.set(key, p);
800
+ return p;
801
+ }
802
+ function clearSharedDiscoveryCache() {
803
+ discoveryCache.clear();
804
+ }
698
805
  var ObjectStackAdapter = class {
699
806
  constructor(config) {
700
807
  __publicField(this, "client");
701
808
  __publicField(this, "connected", false);
809
+ __publicField(this, "connectPromise", null);
702
810
  __publicField(this, "metadataCache");
703
811
  __publicField(this, "connectionState", "disconnected");
704
812
  __publicField(this, "connectionStateListeners", []);
@@ -724,10 +832,25 @@ var ObjectStackAdapter = class {
724
832
  * Call this before making requests or it will auto-connect on first request.
725
833
  */
726
834
  async connect() {
727
- if (!this.connected) {
728
- this.setConnectionState("connecting");
835
+ if (this.connected) return;
836
+ if (this.connectPromise) return this.connectPromise;
837
+ this.setConnectionState("connecting");
838
+ this.connectPromise = (async () => {
729
839
  try {
730
- await this.client.connect();
840
+ const baseUrl = this.baseUrl || "";
841
+ const discoveryUrl = baseUrl ? `${baseUrl.replace(/\/$/, "")}/api/v1/discovery` : "/api/v1/discovery";
842
+ const data = await getSharedDiscovery(baseUrl, async () => {
843
+ const res = await this.fetchImpl(discoveryUrl, {
844
+ method: "GET",
845
+ headers: this.token ? { Authorization: `Bearer ${this.token}` } : void 0
846
+ });
847
+ if (!res.ok) {
848
+ throw new Error(`discovery ${res.status} ${res.statusText}`);
849
+ }
850
+ const body = await res.json();
851
+ return body && typeof body.success === "boolean" && "data" in body ? body.data : body;
852
+ });
853
+ this.client.discoveryInfo = data;
731
854
  this.connected = true;
732
855
  this.reconnectAttempts = 0;
733
856
  this.setConnectionState("connected");
@@ -744,8 +867,11 @@ var ObjectStackAdapter = class {
744
867
  } else {
745
868
  throw connectionError;
746
869
  }
870
+ } finally {
871
+ this.connectPromise = null;
747
872
  }
748
- }
873
+ })();
874
+ return this.connectPromise;
749
875
  }
750
876
  /**
751
877
  * Attempt to reconnect to the server with exponential backoff
@@ -1086,8 +1212,11 @@ var ObjectStackAdapter = class {
1086
1212
  queryParams.set("sort", sortStr);
1087
1213
  }
1088
1214
  }
1089
- if (params.$filter) {
1090
- queryParams.set("filter", JSON.stringify(params.$filter));
1215
+ if (params.$filter !== void 0 && params.$filter !== null) {
1216
+ const isEmpty = Array.isArray(params.$filter) ? params.$filter.length === 0 : typeof params.$filter === "object" && Object.keys(params.$filter).length === 0;
1217
+ if (!isEmpty) {
1218
+ queryParams.set("filter", JSON.stringify(params.$filter));
1219
+ }
1091
1220
  }
1092
1221
  const baseUrl = this.baseUrl.replace(/\/$/, "");
1093
1222
  const qs = queryParams.toString();
@@ -1123,11 +1252,14 @@ var ObjectStackAdapter = class {
1123
1252
  if (params.$select) {
1124
1253
  options.select = params.$select;
1125
1254
  }
1126
- if (params.$filter) {
1127
- if (Array.isArray(params.$filter)) {
1128
- options.filters = params.$filter;
1129
- } else {
1130
- options.filters = convertFiltersToAST(params.$filter);
1255
+ if (params.$filter !== void 0 && params.$filter !== null) {
1256
+ const isEmpty = Array.isArray(params.$filter) ? params.$filter.length === 0 : typeof params.$filter === "object" && Object.keys(params.$filter).length === 0;
1257
+ if (!isEmpty) {
1258
+ if (Array.isArray(params.$filter)) {
1259
+ options.filters = params.$filter;
1260
+ } else {
1261
+ options.filters = convertFiltersToAST(params.$filter);
1262
+ }
1131
1263
  }
1132
1264
  }
1133
1265
  if (params.$orderby) {
@@ -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.0",
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.3",
24
- "@object-ui/core": "3.3.0",
25
- "@object-ui/types": "3.3.0"
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.2",
30
- "vitest": "^4.1.4"
31
+ "typescript": "^6.0.3",
32
+ "vitest": "^4.1.5"
31
33
  },
32
34
  "publishConfig": {
33
35
  "access": "public"
34
36
  },
37
+ "keywords": [
38
+ "objectui",
39
+ "sdui",
40
+ "schema-driven-ui",
41
+ "react",
42
+ "tailwind",
43
+ "shadcn",
44
+ "objectstack",
45
+ "data-source",
46
+ "adapter",
47
+ "objectql",
48
+ "objectstack-client"
49
+ ],
50
+ "author": "ObjectStack Team <team@objectstack.ai>",
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "git+https://github.com/objectstack-ai/objectui.git",
54
+ "directory": "packages/data-objectstack"
55
+ },
56
+ "bugs": {
57
+ "url": "https://github.com/objectstack-ai/objectui/issues"
58
+ },
59
+ "homepage": "https://www.objectui.org/docs/guide/data-source",
35
60
  "scripts": {
36
- "build": "tsup src/index.ts --format cjs,esm --dts",
37
- "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
61
+ "build": "tsup",
62
+ "dev": "tsup --watch",
38
63
  "clean": "rm -rf dist",
39
64
  "type-check": "tsc --noEmit",
40
65
  "test": "vitest run",
@@ -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
- * Note: Concurrent requests for the same uncached key may result in multiple
42
- * fetcher calls. For production use cases requiring request deduplication,
43
- * consider wrapping the cache with a promise-based deduplication layer.
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 - fetch the data
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 data = await fetcher();
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
- // Store in cache
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
  }
@@ -7,7 +7,13 @@
7
7
  */
8
8
 
9
9
  import { describe, it, expect, beforeEach, vi } from 'vitest';
10
- import { ObjectStackAdapter, ConnectionState, ConnectionStateEvent, BatchProgressEvent } from './index';
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
- // Mock the underlying client's connect method and discoveryInfo property
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
- const client = adapter.getClient();
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 (!this.connected) {
131
- this.setConnectionState('connecting');
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
- await this.client.connect();
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
- queryParams.set('filter', JSON.stringify(params.$filter));
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
- if (params.$filter) {
637
- if (Array.isArray(params.$filter)) {
638
- // Assume active AST format if it's already an array
639
- options.filters = params.$filter;
640
- } else {
641
- options.filters = convertFiltersToAST(params.$filter);
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