@object-ui/data-objectstack 0.3.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +317 -1
- package/dist/index.cjs +814 -39
- package/dist/index.d.cts +337 -8
- package/dist/index.d.ts +337 -8
- package/dist/index.js +804 -38
- package/package.json +5 -5
- package/src/cache/MetadataCache.test.ts +426 -0
- package/src/cache/MetadataCache.ts +229 -0
- package/src/connection.test.ts +141 -0
- package/src/errors.test.ts +426 -0
- package/src/errors.ts +275 -0
- package/src/index.ts +679 -48
- package/src/upload.test.ts +112 -0
package/src/index.ts
CHANGED
|
@@ -7,8 +7,54 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { ObjectStackClient, type QueryOptions as ObjectStackQueryOptions } from '@objectstack/client';
|
|
10
|
-
import type { DataSource, QueryParams, QueryResult } from '@object-ui/types';
|
|
10
|
+
import type { DataSource, QueryParams, QueryResult, FileUploadResult } from '@object-ui/types';
|
|
11
11
|
import { convertFiltersToAST } from '@object-ui/core';
|
|
12
|
+
import { MetadataCache } from './cache/MetadataCache';
|
|
13
|
+
import {
|
|
14
|
+
ObjectStackError,
|
|
15
|
+
MetadataNotFoundError,
|
|
16
|
+
BulkOperationError,
|
|
17
|
+
ConnectionError,
|
|
18
|
+
createErrorFromResponse,
|
|
19
|
+
} from './errors';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Connection state for monitoring
|
|
23
|
+
*/
|
|
24
|
+
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Connection state change event
|
|
28
|
+
*/
|
|
29
|
+
export interface ConnectionStateEvent {
|
|
30
|
+
state: ConnectionState;
|
|
31
|
+
timestamp: number;
|
|
32
|
+
error?: Error;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Batch operation progress event
|
|
37
|
+
*/
|
|
38
|
+
export interface BatchProgressEvent {
|
|
39
|
+
operation: 'create' | 'update' | 'delete';
|
|
40
|
+
total: number;
|
|
41
|
+
completed: number;
|
|
42
|
+
failed: number;
|
|
43
|
+
percentage: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Event listener type for connection state changes
|
|
48
|
+
*/
|
|
49
|
+
export type ConnectionStateListener = (event: ConnectionStateEvent) => void;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Event listener type for batch operation progress
|
|
53
|
+
*/
|
|
54
|
+
export type BatchProgressListener = (event: BatchProgressEvent) => void;
|
|
55
|
+
|
|
56
|
+
// Re-export FileUploadResult from types for consumers
|
|
57
|
+
export type { FileUploadResult } from '@object-ui/types';
|
|
12
58
|
|
|
13
59
|
/**
|
|
14
60
|
* ObjectStack Data Source Adapter
|
|
@@ -23,7 +69,14 @@ import { convertFiltersToAST } from '@object-ui/core';
|
|
|
23
69
|
*
|
|
24
70
|
* const dataSource = new ObjectStackAdapter({
|
|
25
71
|
* baseUrl: 'https://api.example.com',
|
|
26
|
-
* token: 'your-api-token'
|
|
72
|
+
* token: 'your-api-token',
|
|
73
|
+
* autoReconnect: true,
|
|
74
|
+
* maxReconnectAttempts: 5
|
|
75
|
+
* });
|
|
76
|
+
*
|
|
77
|
+
* // Monitor connection state
|
|
78
|
+
* dataSource.onConnectionStateChange((event) => {
|
|
79
|
+
* console.log('Connection state:', event.state);
|
|
27
80
|
* });
|
|
28
81
|
*
|
|
29
82
|
* const users = await dataSource.find('users', {
|
|
@@ -32,16 +85,39 @@ import { convertFiltersToAST } from '@object-ui/core';
|
|
|
32
85
|
* });
|
|
33
86
|
* ```
|
|
34
87
|
*/
|
|
35
|
-
export class ObjectStackAdapter<T =
|
|
88
|
+
export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
36
89
|
private client: ObjectStackClient;
|
|
37
90
|
private connected: boolean = false;
|
|
91
|
+
private metadataCache: MetadataCache;
|
|
92
|
+
private connectionState: ConnectionState = 'disconnected';
|
|
93
|
+
private connectionStateListeners: ConnectionStateListener[] = [];
|
|
94
|
+
private batchProgressListeners: BatchProgressListener[] = [];
|
|
95
|
+
private autoReconnect: boolean;
|
|
96
|
+
private maxReconnectAttempts: number;
|
|
97
|
+
private reconnectDelay: number;
|
|
98
|
+
private reconnectAttempts: number = 0;
|
|
99
|
+
private baseUrl: string;
|
|
100
|
+
private token?: string;
|
|
38
101
|
|
|
39
102
|
constructor(config: {
|
|
40
103
|
baseUrl: string;
|
|
41
104
|
token?: string;
|
|
42
105
|
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
106
|
+
cache?: {
|
|
107
|
+
maxSize?: number;
|
|
108
|
+
ttl?: number;
|
|
109
|
+
};
|
|
110
|
+
autoReconnect?: boolean;
|
|
111
|
+
maxReconnectAttempts?: number;
|
|
112
|
+
reconnectDelay?: number;
|
|
43
113
|
}) {
|
|
44
114
|
this.client = new ObjectStackClient(config);
|
|
115
|
+
this.metadataCache = new MetadataCache(config.cache);
|
|
116
|
+
this.autoReconnect = config.autoReconnect ?? true;
|
|
117
|
+
this.maxReconnectAttempts = config.maxReconnectAttempts ?? 3;
|
|
118
|
+
this.reconnectDelay = config.reconnectDelay ?? 1000;
|
|
119
|
+
this.baseUrl = config.baseUrl;
|
|
120
|
+
this.token = config.token;
|
|
45
121
|
}
|
|
46
122
|
|
|
47
123
|
/**
|
|
@@ -50,11 +126,127 @@ export class ObjectStackAdapter<T = any> implements DataSource<T> {
|
|
|
50
126
|
*/
|
|
51
127
|
async connect(): Promise<void> {
|
|
52
128
|
if (!this.connected) {
|
|
53
|
-
|
|
54
|
-
|
|
129
|
+
this.setConnectionState('connecting');
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
await this.client.connect();
|
|
133
|
+
this.connected = true;
|
|
134
|
+
this.reconnectAttempts = 0;
|
|
135
|
+
this.setConnectionState('connected');
|
|
136
|
+
} catch (error: unknown) {
|
|
137
|
+
const errorMessage = error instanceof Error ? error.message : 'Failed to connect to ObjectStack server';
|
|
138
|
+
const connectionError = new ConnectionError(
|
|
139
|
+
errorMessage,
|
|
140
|
+
undefined,
|
|
141
|
+
{ originalError: error }
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
this.setConnectionState('error', connectionError);
|
|
145
|
+
|
|
146
|
+
// Attempt auto-reconnect if enabled
|
|
147
|
+
if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
148
|
+
await this.attemptReconnect();
|
|
149
|
+
} else {
|
|
150
|
+
throw connectionError;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
55
153
|
}
|
|
56
154
|
}
|
|
57
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Attempt to reconnect to the server with exponential backoff
|
|
158
|
+
*/
|
|
159
|
+
private async attemptReconnect(): Promise<void> {
|
|
160
|
+
this.reconnectAttempts++;
|
|
161
|
+
this.setConnectionState('reconnecting');
|
|
162
|
+
|
|
163
|
+
// Exponential backoff: delay * 2^(attempts-1)
|
|
164
|
+
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
|
165
|
+
|
|
166
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
167
|
+
|
|
168
|
+
this.connected = false;
|
|
169
|
+
await this.connect();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Get the current connection state
|
|
174
|
+
*/
|
|
175
|
+
getConnectionState(): ConnectionState {
|
|
176
|
+
return this.connectionState;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Check if the adapter is currently connected
|
|
181
|
+
*/
|
|
182
|
+
isConnected(): boolean {
|
|
183
|
+
return this.connected && this.connectionState === 'connected';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Register a listener for connection state changes
|
|
188
|
+
*/
|
|
189
|
+
onConnectionStateChange(listener: ConnectionStateListener): () => void {
|
|
190
|
+
this.connectionStateListeners.push(listener);
|
|
191
|
+
|
|
192
|
+
// Return unsubscribe function
|
|
193
|
+
return () => {
|
|
194
|
+
const index = this.connectionStateListeners.indexOf(listener);
|
|
195
|
+
if (index > -1) {
|
|
196
|
+
this.connectionStateListeners.splice(index, 1);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Register a listener for batch operation progress
|
|
203
|
+
*/
|
|
204
|
+
onBatchProgress(listener: BatchProgressListener): () => void {
|
|
205
|
+
this.batchProgressListeners.push(listener);
|
|
206
|
+
|
|
207
|
+
// Return unsubscribe function
|
|
208
|
+
return () => {
|
|
209
|
+
const index = this.batchProgressListeners.indexOf(listener);
|
|
210
|
+
if (index > -1) {
|
|
211
|
+
this.batchProgressListeners.splice(index, 1);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Set connection state and notify listeners
|
|
218
|
+
*/
|
|
219
|
+
private setConnectionState(state: ConnectionState, error?: Error): void {
|
|
220
|
+
this.connectionState = state;
|
|
221
|
+
|
|
222
|
+
const event: ConnectionStateEvent = {
|
|
223
|
+
state,
|
|
224
|
+
timestamp: Date.now(),
|
|
225
|
+
error,
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
this.connectionStateListeners.forEach(listener => {
|
|
229
|
+
try {
|
|
230
|
+
listener(event);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
console.error('Error in connection state listener:', err);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Emit batch progress event to listeners
|
|
239
|
+
*/
|
|
240
|
+
private emitBatchProgress(event: BatchProgressEvent): void {
|
|
241
|
+
this.batchProgressListeners.forEach(listener => {
|
|
242
|
+
try {
|
|
243
|
+
listener(event);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
console.error('Error in batch progress listener:', err);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
58
250
|
/**
|
|
59
251
|
* Find multiple records with query parameters.
|
|
60
252
|
* Converts OData-style params to ObjectStack query options.
|
|
@@ -63,14 +255,27 @@ export class ObjectStackAdapter<T = any> implements DataSource<T> {
|
|
|
63
255
|
await this.connect();
|
|
64
256
|
|
|
65
257
|
const queryOptions = this.convertQueryParams(params);
|
|
66
|
-
const result = await this.client.data.find<T>(resource, queryOptions);
|
|
258
|
+
const result: unknown = await this.client.data.find<T>(resource, queryOptions);
|
|
259
|
+
|
|
260
|
+
// Handle legacy/raw array response (e.g. from some mock servers or non-OData endpoints)
|
|
261
|
+
if (Array.isArray(result)) {
|
|
262
|
+
return {
|
|
263
|
+
data: result,
|
|
264
|
+
total: result.length,
|
|
265
|
+
page: 1,
|
|
266
|
+
pageSize: result.length,
|
|
267
|
+
hasMore: false,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
67
270
|
|
|
271
|
+
const resultObj = result as { value?: T[]; count?: number };
|
|
68
272
|
return {
|
|
69
|
-
data:
|
|
70
|
-
total:
|
|
71
|
-
|
|
273
|
+
data: resultObj.value || [],
|
|
274
|
+
total: resultObj.count || (resultObj.value ? resultObj.value.length : 0),
|
|
275
|
+
// Calculate page number safely
|
|
276
|
+
page: params?.$skip && params.$top ? Math.floor(params.$skip / params.$top) + 1 : 1,
|
|
72
277
|
pageSize: params?.$top,
|
|
73
|
-
hasMore:
|
|
278
|
+
hasMore: params?.$top ? (resultObj.value?.length || 0) === params.$top : false,
|
|
74
279
|
};
|
|
75
280
|
}
|
|
76
281
|
|
|
@@ -81,11 +286,11 @@ export class ObjectStackAdapter<T = any> implements DataSource<T> {
|
|
|
81
286
|
await this.connect();
|
|
82
287
|
|
|
83
288
|
try {
|
|
84
|
-
const
|
|
85
|
-
return record;
|
|
86
|
-
} catch (error) {
|
|
289
|
+
const result = await this.client.data.get<T>(resource, String(id));
|
|
290
|
+
return result.record;
|
|
291
|
+
} catch (error: unknown) {
|
|
87
292
|
// If record not found, return null instead of throwing
|
|
88
|
-
if ((error as
|
|
293
|
+
if ((error as Record<string, unknown>)?.status === 404) {
|
|
89
294
|
return null;
|
|
90
295
|
}
|
|
91
296
|
throw error;
|
|
@@ -97,7 +302,8 @@ export class ObjectStackAdapter<T = any> implements DataSource<T> {
|
|
|
97
302
|
*/
|
|
98
303
|
async create(resource: string, data: Partial<T>): Promise<T> {
|
|
99
304
|
await this.connect();
|
|
100
|
-
|
|
305
|
+
const result = await this.client.data.create<T>(resource, data);
|
|
306
|
+
return result.record;
|
|
101
307
|
}
|
|
102
308
|
|
|
103
309
|
/**
|
|
@@ -105,7 +311,8 @@ export class ObjectStackAdapter<T = any> implements DataSource<T> {
|
|
|
105
311
|
*/
|
|
106
312
|
async update(resource: string, id: string | number, data: Partial<T>): Promise<T> {
|
|
107
313
|
await this.connect();
|
|
108
|
-
|
|
314
|
+
const result = await this.client.data.update<T>(resource, String(id), data);
|
|
315
|
+
return result.record;
|
|
109
316
|
}
|
|
110
317
|
|
|
111
318
|
/**
|
|
@@ -114,35 +321,170 @@ export class ObjectStackAdapter<T = any> implements DataSource<T> {
|
|
|
114
321
|
async delete(resource: string, id: string | number): Promise<boolean> {
|
|
115
322
|
await this.connect();
|
|
116
323
|
const result = await this.client.data.delete(resource, String(id));
|
|
117
|
-
return result.
|
|
324
|
+
return result.deleted;
|
|
118
325
|
}
|
|
119
326
|
|
|
120
327
|
/**
|
|
121
|
-
* Bulk operations
|
|
328
|
+
* Bulk operations with optimized batch processing and error handling.
|
|
329
|
+
* Emits progress events for tracking operation status.
|
|
330
|
+
*
|
|
331
|
+
* @param resource - Resource name
|
|
332
|
+
* @param operation - Operation type (create, update, delete)
|
|
333
|
+
* @param data - Array of records to process
|
|
334
|
+
* @returns Promise resolving to array of results
|
|
122
335
|
*/
|
|
123
336
|
async bulk(resource: string, operation: 'create' | 'update' | 'delete', data: Partial<T>[]): Promise<T[]> {
|
|
124
337
|
await this.connect();
|
|
125
338
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
339
|
+
if (!data || data.length === 0) {
|
|
340
|
+
return [];
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const total = data.length;
|
|
344
|
+
let completed = 0;
|
|
345
|
+
let failed = 0;
|
|
346
|
+
|
|
347
|
+
const emitProgress = () => {
|
|
348
|
+
this.emitBatchProgress({
|
|
349
|
+
operation,
|
|
350
|
+
total,
|
|
351
|
+
completed,
|
|
352
|
+
failed,
|
|
353
|
+
percentage: total > 0 ? (completed + failed) / total * 100 : 0,
|
|
354
|
+
});
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
switch (operation) {
|
|
359
|
+
case 'create': {
|
|
360
|
+
emitProgress();
|
|
361
|
+
const created = await this.client.data.createMany<T>(resource, data);
|
|
362
|
+
completed = created.length;
|
|
363
|
+
failed = total - completed;
|
|
364
|
+
emitProgress();
|
|
365
|
+
return created;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
case 'delete': {
|
|
369
|
+
const ids = data.map(item => (item as Record<string, unknown>).id).filter(Boolean) as string[];
|
|
370
|
+
|
|
371
|
+
if (ids.length === 0) {
|
|
372
|
+
// Track which items are missing IDs
|
|
373
|
+
const errors = data.map((_, index) => ({
|
|
374
|
+
index,
|
|
375
|
+
error: `Missing ID for item at index ${index}`
|
|
376
|
+
}));
|
|
377
|
+
|
|
378
|
+
failed = data.length;
|
|
379
|
+
emitProgress();
|
|
380
|
+
|
|
381
|
+
throw new BulkOperationError('delete', 0, data.length, errors);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
emitProgress();
|
|
385
|
+
await this.client.data.deleteMany(resource, ids);
|
|
386
|
+
completed = ids.length;
|
|
387
|
+
failed = total - completed;
|
|
388
|
+
emitProgress();
|
|
389
|
+
return [] as T[];
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
case 'update': {
|
|
393
|
+
// Check if client supports updateMany
|
|
394
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
395
|
+
if (typeof (this.client.data as any).updateMany === 'function') {
|
|
396
|
+
try {
|
|
397
|
+
emitProgress();
|
|
398
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
399
|
+
const updateMany = (this.client.data as any).updateMany;
|
|
400
|
+
const updated = await updateMany(resource, data) as T[];
|
|
401
|
+
completed = updated.length;
|
|
402
|
+
failed = total - completed;
|
|
403
|
+
emitProgress();
|
|
404
|
+
return updated;
|
|
405
|
+
} catch {
|
|
406
|
+
// If updateMany is not supported, fall back to individual updates
|
|
407
|
+
// Silently fallback without logging
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Fallback: Process updates individually with detailed error tracking and progress
|
|
412
|
+
const results: T[] = [];
|
|
413
|
+
const errors: Array<{ index: number; error: unknown }> = [];
|
|
414
|
+
|
|
415
|
+
for (let i = 0; i < data.length; i++) {
|
|
416
|
+
const item = data[i];
|
|
417
|
+
const id = (item as Record<string, unknown>).id;
|
|
418
|
+
|
|
419
|
+
if (!id) {
|
|
420
|
+
errors.push({ index: i, error: 'Missing ID' });
|
|
421
|
+
failed++;
|
|
422
|
+
emitProgress();
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
const result = await this.client.data.update<T>(resource, String(id), item);
|
|
428
|
+
results.push(result.record);
|
|
429
|
+
completed++;
|
|
430
|
+
emitProgress();
|
|
431
|
+
} catch (error: unknown) {
|
|
432
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
433
|
+
errors.push({ index: i, error: errorMessage });
|
|
434
|
+
failed++;
|
|
435
|
+
emitProgress();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// If there were any errors, throw BulkOperationError
|
|
440
|
+
if (errors.length > 0) {
|
|
441
|
+
throw new BulkOperationError(
|
|
442
|
+
'update',
|
|
443
|
+
results.length,
|
|
444
|
+
errors.length,
|
|
445
|
+
errors,
|
|
446
|
+
{ resource, totalRecords: data.length }
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return results;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
default:
|
|
454
|
+
throw new ObjectStackError(
|
|
455
|
+
`Unsupported bulk operation: ${operation}`,
|
|
456
|
+
'UNSUPPORTED_OPERATION',
|
|
457
|
+
400
|
|
458
|
+
);
|
|
133
459
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
460
|
+
} catch (error: unknown) {
|
|
461
|
+
// Emit final progress with failure
|
|
462
|
+
emitProgress();
|
|
463
|
+
|
|
464
|
+
// If it's already a BulkOperationError, re-throw it
|
|
465
|
+
if (error instanceof BulkOperationError) {
|
|
466
|
+
throw error;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// If it's already an ObjectStackError, re-throw it
|
|
470
|
+
if (error instanceof ObjectStackError) {
|
|
471
|
+
throw error;
|
|
143
472
|
}
|
|
144
|
-
|
|
145
|
-
|
|
473
|
+
|
|
474
|
+
// Wrap other errors in BulkOperationError with proper error tracking
|
|
475
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
476
|
+
const errors = data.map((_, index) => ({
|
|
477
|
+
index,
|
|
478
|
+
error: errorMessage
|
|
479
|
+
}));
|
|
480
|
+
|
|
481
|
+
throw new BulkOperationError(
|
|
482
|
+
operation,
|
|
483
|
+
0,
|
|
484
|
+
data.length,
|
|
485
|
+
errors,
|
|
486
|
+
{ resource, originalError: error }
|
|
487
|
+
);
|
|
146
488
|
}
|
|
147
489
|
}
|
|
148
490
|
|
|
@@ -162,15 +504,32 @@ export class ObjectStackAdapter<T = any> implements DataSource<T> {
|
|
|
162
504
|
|
|
163
505
|
// Filtering - convert to ObjectStack FilterNode AST format
|
|
164
506
|
if (params.$filter) {
|
|
165
|
-
|
|
507
|
+
if (Array.isArray(params.$filter)) {
|
|
508
|
+
// Assume active AST format if it's already an array
|
|
509
|
+
options.filters = params.$filter;
|
|
510
|
+
} else {
|
|
511
|
+
options.filters = convertFiltersToAST(params.$filter);
|
|
512
|
+
}
|
|
166
513
|
}
|
|
167
514
|
|
|
168
515
|
// Sorting - convert to ObjectStack format
|
|
169
516
|
if (params.$orderby) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
517
|
+
if (Array.isArray(params.$orderby)) {
|
|
518
|
+
// Handle array format ['name', '-age'] or [{ field: 'name', order: 'asc' }]
|
|
519
|
+
options.sort = params.$orderby.map(item => {
|
|
520
|
+
if (typeof item === 'string') return item;
|
|
521
|
+
// Handle object format { field: 'name', order: 'desc' }
|
|
522
|
+
const field = item.field;
|
|
523
|
+
const order = item.order || 'asc';
|
|
524
|
+
return order === 'desc' ? `-${field}` : field;
|
|
525
|
+
});
|
|
526
|
+
} else {
|
|
527
|
+
// Handle Record format { name: 'asc', age: 'desc' }
|
|
528
|
+
const sortArray = Object.entries(params.$orderby).map(([field, order]) => {
|
|
529
|
+
return order === 'desc' ? `-${field}` : field;
|
|
530
|
+
});
|
|
531
|
+
options.sort = sortArray;
|
|
532
|
+
}
|
|
174
533
|
}
|
|
175
534
|
|
|
176
535
|
// Pagination
|
|
@@ -187,19 +546,41 @@ export class ObjectStackAdapter<T = any> implements DataSource<T> {
|
|
|
187
546
|
|
|
188
547
|
/**
|
|
189
548
|
* Get object schema/metadata from ObjectStack.
|
|
549
|
+
* Uses caching to improve performance for repeated requests.
|
|
190
550
|
*
|
|
191
551
|
* @param objectName - Object name
|
|
192
552
|
* @returns Promise resolving to the object schema
|
|
193
553
|
*/
|
|
194
|
-
async getObjectSchema(objectName: string): Promise<
|
|
554
|
+
async getObjectSchema(objectName: string): Promise<unknown> {
|
|
195
555
|
await this.connect();
|
|
196
556
|
|
|
197
557
|
try {
|
|
198
|
-
|
|
558
|
+
// Use cache with automatic fetching
|
|
559
|
+
const schema = await this.metadataCache.get(objectName, async () => {
|
|
560
|
+
const result: any = await this.client.meta.getObject(objectName);
|
|
561
|
+
|
|
562
|
+
// Unwrap 'item' property if present (common API response wrapper)
|
|
563
|
+
if (result && result.item) {
|
|
564
|
+
return result.item;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return result;
|
|
568
|
+
});
|
|
569
|
+
|
|
199
570
|
return schema;
|
|
200
|
-
} catch (error) {
|
|
201
|
-
|
|
202
|
-
|
|
571
|
+
} catch (error: unknown) {
|
|
572
|
+
// Check if it's a 404 error
|
|
573
|
+
const errorObj = error as Record<string, unknown>;
|
|
574
|
+
if (errorObj?.status === 404 || errorObj?.statusCode === 404) {
|
|
575
|
+
throw new MetadataNotFoundError(objectName, { originalError: error });
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// For other errors, wrap in ObjectStackError if not already
|
|
579
|
+
if (error instanceof ObjectStackError) {
|
|
580
|
+
throw error;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
throw createErrorFromResponse(errorObj, `getObjectSchema(${objectName})`);
|
|
203
584
|
}
|
|
204
585
|
}
|
|
205
586
|
|
|
@@ -209,6 +590,230 @@ export class ObjectStackAdapter<T = any> implements DataSource<T> {
|
|
|
209
590
|
getClient(): ObjectStackClient {
|
|
210
591
|
return this.client;
|
|
211
592
|
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Get the discovery information from the connected server.
|
|
596
|
+
* Returns the capabilities and service status of the ObjectStack server.
|
|
597
|
+
*
|
|
598
|
+
* Note: This accesses an internal property of the ObjectStackClient.
|
|
599
|
+
* The discovery data is populated during client.connect() and cached.
|
|
600
|
+
*
|
|
601
|
+
* @returns Promise resolving to discovery data, or null if not connected
|
|
602
|
+
*/
|
|
603
|
+
async getDiscovery(): Promise<unknown | null> {
|
|
604
|
+
try {
|
|
605
|
+
// Ensure we're connected first
|
|
606
|
+
await this.connect();
|
|
607
|
+
|
|
608
|
+
// Access discovery data from the client
|
|
609
|
+
// The ObjectStackClient caches discovery during connect()
|
|
610
|
+
// This is an internal property, but documented for this use case
|
|
611
|
+
// @ts-expect-error - Accessing internal discoveryInfo property
|
|
612
|
+
return this.client.discoveryInfo || null;
|
|
613
|
+
} catch {
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Get a view definition for an object.
|
|
620
|
+
* Attempts to fetch from the server metadata API.
|
|
621
|
+
* Falls back to null if the server doesn't provide view definitions,
|
|
622
|
+
* allowing the consumer to use static config.
|
|
623
|
+
*
|
|
624
|
+
* @param objectName - Object name
|
|
625
|
+
* @param viewId - View identifier
|
|
626
|
+
* @returns Promise resolving to the view definition or null
|
|
627
|
+
*/
|
|
628
|
+
async getView(objectName: string, viewId: string): Promise<unknown | null> {
|
|
629
|
+
await this.connect();
|
|
630
|
+
|
|
631
|
+
try {
|
|
632
|
+
const cacheKey = `view:${objectName}:${viewId}`;
|
|
633
|
+
return await this.metadataCache.get(cacheKey, async () => {
|
|
634
|
+
// Try meta.getItem for view metadata
|
|
635
|
+
const result: any = await this.client.meta.getItem(objectName, `views/${viewId}`);
|
|
636
|
+
if (result && result.item) return result.item;
|
|
637
|
+
return result ?? null;
|
|
638
|
+
});
|
|
639
|
+
} catch {
|
|
640
|
+
// Server doesn't support view metadata — return null to fall back to static config
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Get an application definition by name or ID.
|
|
647
|
+
* Attempts to fetch from the server metadata API.
|
|
648
|
+
* Falls back to null if the server doesn't provide app definitions,
|
|
649
|
+
* allowing the consumer to use static config.
|
|
650
|
+
*
|
|
651
|
+
* @param appId - Application identifier
|
|
652
|
+
* @returns Promise resolving to the app definition or null
|
|
653
|
+
*/
|
|
654
|
+
async getApp(appId: string): Promise<unknown | null> {
|
|
655
|
+
await this.connect();
|
|
656
|
+
|
|
657
|
+
try {
|
|
658
|
+
const cacheKey = `app:${appId}`;
|
|
659
|
+
return await this.metadataCache.get(cacheKey, async () => {
|
|
660
|
+
const result: any = await this.client.meta.getItem('apps', appId);
|
|
661
|
+
if (result && result.item) return result.item;
|
|
662
|
+
return result ?? null;
|
|
663
|
+
});
|
|
664
|
+
} catch {
|
|
665
|
+
// Server doesn't support app metadata — return null to fall back to static config
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Get cache statistics for monitoring performance.
|
|
672
|
+
*/
|
|
673
|
+
getCacheStats() {
|
|
674
|
+
return this.metadataCache.getStats();
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Invalidate metadata cache entries.
|
|
679
|
+
*
|
|
680
|
+
* @param key - Optional key to invalidate. If omitted, invalidates all entries.
|
|
681
|
+
*/
|
|
682
|
+
invalidateCache(key?: string): void {
|
|
683
|
+
this.metadataCache.invalidate(key);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Clear all cache entries and statistics.
|
|
688
|
+
*/
|
|
689
|
+
clearCache(): void {
|
|
690
|
+
this.metadataCache.clear();
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Upload a single file to a resource.
|
|
695
|
+
* Posts the file as multipart/form-data to the ObjectStack server.
|
|
696
|
+
*
|
|
697
|
+
* @param resource - The resource/object name to attach the file to
|
|
698
|
+
* @param file - File object or Blob to upload
|
|
699
|
+
* @param options - Additional upload options (recordId, fieldName, metadata)
|
|
700
|
+
* @returns Promise resolving to the upload result (file URL, metadata)
|
|
701
|
+
*/
|
|
702
|
+
async uploadFile(
|
|
703
|
+
resource: string,
|
|
704
|
+
file: File | Blob,
|
|
705
|
+
options?: {
|
|
706
|
+
recordId?: string;
|
|
707
|
+
fieldName?: string;
|
|
708
|
+
metadata?: Record<string, unknown>;
|
|
709
|
+
onProgress?: (percent: number) => void;
|
|
710
|
+
},
|
|
711
|
+
): Promise<FileUploadResult> {
|
|
712
|
+
await this.connect();
|
|
713
|
+
|
|
714
|
+
const formData = new FormData();
|
|
715
|
+
formData.append('file', file);
|
|
716
|
+
|
|
717
|
+
if (options?.recordId) {
|
|
718
|
+
formData.append('recordId', options.recordId);
|
|
719
|
+
}
|
|
720
|
+
if (options?.fieldName) {
|
|
721
|
+
formData.append('fieldName', options.fieldName);
|
|
722
|
+
}
|
|
723
|
+
if (options?.metadata) {
|
|
724
|
+
formData.append('metadata', JSON.stringify(options.metadata));
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const url = `${this.baseUrl}/api/data/${encodeURIComponent(resource)}/upload`;
|
|
728
|
+
|
|
729
|
+
const response = await fetch(url, {
|
|
730
|
+
method: 'POST',
|
|
731
|
+
body: formData,
|
|
732
|
+
headers: {
|
|
733
|
+
...(this.getAuthHeaders()),
|
|
734
|
+
},
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
if (!response.ok) {
|
|
738
|
+
const error = await response.json().catch(() => ({ message: response.statusText }));
|
|
739
|
+
throw new ObjectStackError(
|
|
740
|
+
error.message || `Upload failed with status ${response.status}`,
|
|
741
|
+
'UPLOAD_ERROR',
|
|
742
|
+
response.status,
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
return response.json();
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Upload multiple files to a resource.
|
|
751
|
+
* Posts all files as a single multipart/form-data request.
|
|
752
|
+
*
|
|
753
|
+
* @param resource - The resource/object name to attach the files to
|
|
754
|
+
* @param files - Array of File objects or Blobs to upload
|
|
755
|
+
* @param options - Additional upload options
|
|
756
|
+
* @returns Promise resolving to array of upload results
|
|
757
|
+
*/
|
|
758
|
+
async uploadFiles(
|
|
759
|
+
resource: string,
|
|
760
|
+
files: (File | Blob)[],
|
|
761
|
+
options?: {
|
|
762
|
+
recordId?: string;
|
|
763
|
+
fieldName?: string;
|
|
764
|
+
metadata?: Record<string, unknown>;
|
|
765
|
+
onProgress?: (percent: number) => void;
|
|
766
|
+
},
|
|
767
|
+
): Promise<FileUploadResult[]> {
|
|
768
|
+
await this.connect();
|
|
769
|
+
|
|
770
|
+
const formData = new FormData();
|
|
771
|
+
files.forEach((file, idx) => {
|
|
772
|
+
formData.append(`files`, file, (file as File).name || `file-${idx}`);
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
if (options?.recordId) {
|
|
776
|
+
formData.append('recordId', options.recordId);
|
|
777
|
+
}
|
|
778
|
+
if (options?.fieldName) {
|
|
779
|
+
formData.append('fieldName', options.fieldName);
|
|
780
|
+
}
|
|
781
|
+
if (options?.metadata) {
|
|
782
|
+
formData.append('metadata', JSON.stringify(options.metadata));
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const url = `${this.baseUrl}/api/data/${encodeURIComponent(resource)}/upload`;
|
|
786
|
+
|
|
787
|
+
const response = await fetch(url, {
|
|
788
|
+
method: 'POST',
|
|
789
|
+
body: formData,
|
|
790
|
+
headers: {
|
|
791
|
+
...(this.getAuthHeaders()),
|
|
792
|
+
},
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
if (!response.ok) {
|
|
796
|
+
const error = await response.json().catch(() => ({ message: response.statusText }));
|
|
797
|
+
throw new ObjectStackError(
|
|
798
|
+
error.message || `Upload failed with status ${response.status}`,
|
|
799
|
+
'UPLOAD_ERROR',
|
|
800
|
+
response.status,
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
return response.json();
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Get authorization headers from the adapter config.
|
|
809
|
+
*/
|
|
810
|
+
private getAuthHeaders(): Record<string, string> {
|
|
811
|
+
const headers: Record<string, string> = {};
|
|
812
|
+
if (this.token) {
|
|
813
|
+
headers['Authorization'] = `Bearer ${this.token}`;
|
|
814
|
+
}
|
|
815
|
+
return headers;
|
|
816
|
+
}
|
|
212
817
|
}
|
|
213
818
|
|
|
214
819
|
/**
|
|
@@ -218,14 +823,40 @@ export class ObjectStackAdapter<T = any> implements DataSource<T> {
|
|
|
218
823
|
* ```typescript
|
|
219
824
|
* const dataSource = createObjectStackAdapter({
|
|
220
825
|
* baseUrl: process.env.API_URL,
|
|
221
|
-
* token: process.env.API_TOKEN
|
|
826
|
+
* token: process.env.API_TOKEN,
|
|
827
|
+
* cache: { maxSize: 100, ttl: 300000 },
|
|
828
|
+
* autoReconnect: true,
|
|
829
|
+
* maxReconnectAttempts: 5
|
|
222
830
|
* });
|
|
223
831
|
* ```
|
|
224
832
|
*/
|
|
225
|
-
export function createObjectStackAdapter<T =
|
|
833
|
+
export function createObjectStackAdapter<T = unknown>(config: {
|
|
226
834
|
baseUrl: string;
|
|
227
835
|
token?: string;
|
|
228
836
|
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
837
|
+
cache?: {
|
|
838
|
+
maxSize?: number;
|
|
839
|
+
ttl?: number;
|
|
840
|
+
};
|
|
841
|
+
autoReconnect?: boolean;
|
|
842
|
+
maxReconnectAttempts?: number;
|
|
843
|
+
reconnectDelay?: number;
|
|
229
844
|
}): DataSource<T> {
|
|
230
845
|
return new ObjectStackAdapter<T>(config);
|
|
231
846
|
}
|
|
847
|
+
|
|
848
|
+
// Export error classes for error handling
|
|
849
|
+
export {
|
|
850
|
+
ObjectStackError,
|
|
851
|
+
MetadataNotFoundError,
|
|
852
|
+
BulkOperationError,
|
|
853
|
+
ConnectionError,
|
|
854
|
+
AuthenticationError,
|
|
855
|
+
ValidationError,
|
|
856
|
+
createErrorFromResponse,
|
|
857
|
+
isObjectStackError,
|
|
858
|
+
isErrorType,
|
|
859
|
+
} from './errors';
|
|
860
|
+
|
|
861
|
+
// Export cache types
|
|
862
|
+
export type { CacheStats } from './cache/MetadataCache';
|