@object-ui/data-objectstack 0.3.0 → 0.5.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/src/index.ts CHANGED
@@ -9,6 +9,49 @@
9
9
  import { ObjectStackClient, type QueryOptions as ObjectStackQueryOptions } from '@objectstack/client';
10
10
  import type { DataSource, QueryParams, QueryResult } 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;
12
55
 
13
56
  /**
14
57
  * ObjectStack Data Source Adapter
@@ -23,7 +66,14 @@ import { convertFiltersToAST } from '@object-ui/core';
23
66
  *
24
67
  * const dataSource = new ObjectStackAdapter({
25
68
  * baseUrl: 'https://api.example.com',
26
- * token: 'your-api-token'
69
+ * token: 'your-api-token',
70
+ * autoReconnect: true,
71
+ * maxReconnectAttempts: 5
72
+ * });
73
+ *
74
+ * // Monitor connection state
75
+ * dataSource.onConnectionStateChange((event) => {
76
+ * console.log('Connection state:', event.state);
27
77
  * });
28
78
  *
29
79
  * const users = await dataSource.find('users', {
@@ -32,16 +82,35 @@ import { convertFiltersToAST } from '@object-ui/core';
32
82
  * });
33
83
  * ```
34
84
  */
35
- export class ObjectStackAdapter<T = any> implements DataSource<T> {
85
+ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
36
86
  private client: ObjectStackClient;
37
87
  private connected: boolean = false;
88
+ private metadataCache: MetadataCache;
89
+ private connectionState: ConnectionState = 'disconnected';
90
+ private connectionStateListeners: ConnectionStateListener[] = [];
91
+ private batchProgressListeners: BatchProgressListener[] = [];
92
+ private autoReconnect: boolean;
93
+ private maxReconnectAttempts: number;
94
+ private reconnectDelay: number;
95
+ private reconnectAttempts: number = 0;
38
96
 
39
97
  constructor(config: {
40
98
  baseUrl: string;
41
99
  token?: string;
42
100
  fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
101
+ cache?: {
102
+ maxSize?: number;
103
+ ttl?: number;
104
+ };
105
+ autoReconnect?: boolean;
106
+ maxReconnectAttempts?: number;
107
+ reconnectDelay?: number;
43
108
  }) {
44
109
  this.client = new ObjectStackClient(config);
110
+ this.metadataCache = new MetadataCache(config.cache);
111
+ this.autoReconnect = config.autoReconnect ?? true;
112
+ this.maxReconnectAttempts = config.maxReconnectAttempts ?? 3;
113
+ this.reconnectDelay = config.reconnectDelay ?? 1000;
45
114
  }
46
115
 
47
116
  /**
@@ -50,11 +119,127 @@ export class ObjectStackAdapter<T = any> implements DataSource<T> {
50
119
  */
51
120
  async connect(): Promise<void> {
52
121
  if (!this.connected) {
53
- await this.client.connect();
54
- this.connected = true;
122
+ this.setConnectionState('connecting');
123
+
124
+ try {
125
+ await this.client.connect();
126
+ this.connected = true;
127
+ this.reconnectAttempts = 0;
128
+ this.setConnectionState('connected');
129
+ } catch (error: unknown) {
130
+ const errorMessage = error instanceof Error ? error.message : 'Failed to connect to ObjectStack server';
131
+ const connectionError = new ConnectionError(
132
+ errorMessage,
133
+ undefined,
134
+ { originalError: error }
135
+ );
136
+
137
+ this.setConnectionState('error', connectionError);
138
+
139
+ // Attempt auto-reconnect if enabled
140
+ if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
141
+ await this.attemptReconnect();
142
+ } else {
143
+ throw connectionError;
144
+ }
145
+ }
55
146
  }
56
147
  }
57
148
 
149
+ /**
150
+ * Attempt to reconnect to the server with exponential backoff
151
+ */
152
+ private async attemptReconnect(): Promise<void> {
153
+ this.reconnectAttempts++;
154
+ this.setConnectionState('reconnecting');
155
+
156
+ // Exponential backoff: delay * 2^(attempts-1)
157
+ const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
158
+
159
+ await new Promise(resolve => setTimeout(resolve, delay));
160
+
161
+ this.connected = false;
162
+ await this.connect();
163
+ }
164
+
165
+ /**
166
+ * Get the current connection state
167
+ */
168
+ getConnectionState(): ConnectionState {
169
+ return this.connectionState;
170
+ }
171
+
172
+ /**
173
+ * Check if the adapter is currently connected
174
+ */
175
+ isConnected(): boolean {
176
+ return this.connected && this.connectionState === 'connected';
177
+ }
178
+
179
+ /**
180
+ * Register a listener for connection state changes
181
+ */
182
+ onConnectionStateChange(listener: ConnectionStateListener): () => void {
183
+ this.connectionStateListeners.push(listener);
184
+
185
+ // Return unsubscribe function
186
+ return () => {
187
+ const index = this.connectionStateListeners.indexOf(listener);
188
+ if (index > -1) {
189
+ this.connectionStateListeners.splice(index, 1);
190
+ }
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Register a listener for batch operation progress
196
+ */
197
+ onBatchProgress(listener: BatchProgressListener): () => void {
198
+ this.batchProgressListeners.push(listener);
199
+
200
+ // Return unsubscribe function
201
+ return () => {
202
+ const index = this.batchProgressListeners.indexOf(listener);
203
+ if (index > -1) {
204
+ this.batchProgressListeners.splice(index, 1);
205
+ }
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Set connection state and notify listeners
211
+ */
212
+ private setConnectionState(state: ConnectionState, error?: Error): void {
213
+ this.connectionState = state;
214
+
215
+ const event: ConnectionStateEvent = {
216
+ state,
217
+ timestamp: Date.now(),
218
+ error,
219
+ };
220
+
221
+ this.connectionStateListeners.forEach(listener => {
222
+ try {
223
+ listener(event);
224
+ } catch (err) {
225
+ console.error('Error in connection state listener:', err);
226
+ }
227
+ });
228
+ }
229
+
230
+ /**
231
+ * Emit batch progress event to listeners
232
+ */
233
+ private emitBatchProgress(event: BatchProgressEvent): void {
234
+ this.batchProgressListeners.forEach(listener => {
235
+ try {
236
+ listener(event);
237
+ } catch (err) {
238
+ console.error('Error in batch progress listener:', err);
239
+ }
240
+ });
241
+ }
242
+
58
243
  /**
59
244
  * Find multiple records with query parameters.
60
245
  * Converts OData-style params to ObjectStack query options.
@@ -63,14 +248,27 @@ export class ObjectStackAdapter<T = any> implements DataSource<T> {
63
248
  await this.connect();
64
249
 
65
250
  const queryOptions = this.convertQueryParams(params);
66
- const result = await this.client.data.find<T>(resource, queryOptions);
251
+ const result: unknown = await this.client.data.find<T>(resource, queryOptions);
252
+
253
+ // Handle legacy/raw array response (e.g. from some mock servers or non-OData endpoints)
254
+ if (Array.isArray(result)) {
255
+ return {
256
+ data: result,
257
+ total: result.length,
258
+ page: 1,
259
+ pageSize: result.length,
260
+ hasMore: false,
261
+ };
262
+ }
67
263
 
264
+ const resultObj = result as { value?: T[]; count?: number };
68
265
  return {
69
- data: result.value,
70
- total: result.count,
71
- page: params?.$skip ? Math.floor(params.$skip / (params.$top || 20)) + 1 : 1,
266
+ data: resultObj.value || [],
267
+ total: resultObj.count || (resultObj.value ? resultObj.value.length : 0),
268
+ // Calculate page number safely
269
+ page: params?.$skip && params.$top ? Math.floor(params.$skip / params.$top) + 1 : 1,
72
270
  pageSize: params?.$top,
73
- hasMore: result.value.length === params?.$top,
271
+ hasMore: params?.$top ? (resultObj.value?.length || 0) === params.$top : false,
74
272
  };
75
273
  }
76
274
 
@@ -83,9 +281,9 @@ export class ObjectStackAdapter<T = any> implements DataSource<T> {
83
281
  try {
84
282
  const record = await this.client.data.get<T>(resource, String(id));
85
283
  return record;
86
- } catch (error) {
284
+ } catch (error: unknown) {
87
285
  // If record not found, return null instead of throwing
88
- if ((error as any)?.status === 404) {
286
+ if ((error as Record<string, unknown>)?.status === 404) {
89
287
  return null;
90
288
  }
91
289
  throw error;
@@ -118,31 +316,166 @@ export class ObjectStackAdapter<T = any> implements DataSource<T> {
118
316
  }
119
317
 
120
318
  /**
121
- * Bulk operations (optional implementation).
319
+ * Bulk operations with optimized batch processing and error handling.
320
+ * Emits progress events for tracking operation status.
321
+ *
322
+ * @param resource - Resource name
323
+ * @param operation - Operation type (create, update, delete)
324
+ * @param data - Array of records to process
325
+ * @returns Promise resolving to array of results
122
326
  */
123
327
  async bulk(resource: string, operation: 'create' | 'update' | 'delete', data: Partial<T>[]): Promise<T[]> {
124
328
  await this.connect();
125
329
 
126
- switch (operation) {
127
- case 'create':
128
- return this.client.data.createMany<T>(resource, data);
129
- case 'delete': {
130
- const ids = data.map(item => (item as any).id).filter(Boolean);
131
- await this.client.data.deleteMany(resource, ids);
132
- return [];
330
+ if (!data || data.length === 0) {
331
+ return [];
332
+ }
333
+
334
+ const total = data.length;
335
+ let completed = 0;
336
+ let failed = 0;
337
+
338
+ const emitProgress = () => {
339
+ this.emitBatchProgress({
340
+ operation,
341
+ total,
342
+ completed,
343
+ failed,
344
+ percentage: total > 0 ? (completed + failed) / total * 100 : 0,
345
+ });
346
+ };
347
+
348
+ try {
349
+ switch (operation) {
350
+ case 'create': {
351
+ emitProgress();
352
+ const created = await this.client.data.createMany<T>(resource, data);
353
+ completed = created.length;
354
+ failed = total - completed;
355
+ emitProgress();
356
+ return created;
357
+ }
358
+
359
+ case 'delete': {
360
+ const ids = data.map(item => (item as Record<string, unknown>).id).filter(Boolean) as string[];
361
+
362
+ if (ids.length === 0) {
363
+ // Track which items are missing IDs
364
+ const errors = data.map((_, index) => ({
365
+ index,
366
+ error: `Missing ID for item at index ${index}`
367
+ }));
368
+
369
+ failed = data.length;
370
+ emitProgress();
371
+
372
+ throw new BulkOperationError('delete', 0, data.length, errors);
373
+ }
374
+
375
+ emitProgress();
376
+ await this.client.data.deleteMany(resource, ids);
377
+ completed = ids.length;
378
+ failed = total - completed;
379
+ emitProgress();
380
+ return [] as T[];
381
+ }
382
+
383
+ case 'update': {
384
+ // Check if client supports updateMany
385
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
386
+ if (typeof (this.client.data as any).updateMany === 'function') {
387
+ try {
388
+ emitProgress();
389
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
390
+ const updateMany = (this.client.data as any).updateMany;
391
+ const updated = await updateMany(resource, data) as T[];
392
+ completed = updated.length;
393
+ failed = total - completed;
394
+ emitProgress();
395
+ return updated;
396
+ } catch {
397
+ // If updateMany is not supported, fall back to individual updates
398
+ // Silently fallback without logging
399
+ }
400
+ }
401
+
402
+ // Fallback: Process updates individually with detailed error tracking and progress
403
+ const results: T[] = [];
404
+ const errors: Array<{ index: number; error: unknown }> = [];
405
+
406
+ for (let i = 0; i < data.length; i++) {
407
+ const item = data[i];
408
+ const id = (item as Record<string, unknown>).id;
409
+
410
+ if (!id) {
411
+ errors.push({ index: i, error: 'Missing ID' });
412
+ failed++;
413
+ emitProgress();
414
+ continue;
415
+ }
416
+
417
+ try {
418
+ const result = await this.client.data.update<T>(resource, String(id), item);
419
+ results.push(result);
420
+ completed++;
421
+ emitProgress();
422
+ } catch (error: unknown) {
423
+ const errorMessage = error instanceof Error ? error.message : String(error);
424
+ errors.push({ index: i, error: errorMessage });
425
+ failed++;
426
+ emitProgress();
427
+ }
428
+ }
429
+
430
+ // If there were any errors, throw BulkOperationError
431
+ if (errors.length > 0) {
432
+ throw new BulkOperationError(
433
+ 'update',
434
+ results.length,
435
+ errors.length,
436
+ errors,
437
+ { resource, totalRecords: data.length }
438
+ );
439
+ }
440
+
441
+ return results;
442
+ }
443
+
444
+ default:
445
+ throw new ObjectStackError(
446
+ `Unsupported bulk operation: ${operation}`,
447
+ 'UNSUPPORTED_OPERATION',
448
+ 400
449
+ );
133
450
  }
134
- case 'update': {
135
- // For update, we need to handle each record individually
136
- // or use the batch update if all records get the same changes
137
- const results = await Promise.all(
138
- data.map(item =>
139
- this.client.data.update<T>(resource, String((item as any).id), item)
140
- )
141
- );
142
- return results;
451
+ } catch (error: unknown) {
452
+ // Emit final progress with failure
453
+ emitProgress();
454
+
455
+ // If it's already a BulkOperationError, re-throw it
456
+ if (error instanceof BulkOperationError) {
457
+ throw error;
458
+ }
459
+
460
+ // If it's already an ObjectStackError, re-throw it
461
+ if (error instanceof ObjectStackError) {
462
+ throw error;
143
463
  }
144
- default:
145
- throw new Error(`Unsupported bulk operation: ${operation}`);
464
+
465
+ // Wrap other errors in BulkOperationError with proper error tracking
466
+ const errorMessage = error instanceof Error ? error.message : String(error);
467
+ const errors = data.map((_, index) => ({
468
+ index,
469
+ error: errorMessage
470
+ }));
471
+
472
+ throw new BulkOperationError(
473
+ operation,
474
+ 0,
475
+ data.length,
476
+ errors,
477
+ { resource, originalError: error }
478
+ );
146
479
  }
147
480
  }
148
481
 
@@ -162,15 +495,32 @@ export class ObjectStackAdapter<T = any> implements DataSource<T> {
162
495
 
163
496
  // Filtering - convert to ObjectStack FilterNode AST format
164
497
  if (params.$filter) {
165
- options.filters = convertFiltersToAST(params.$filter);
498
+ if (Array.isArray(params.$filter)) {
499
+ // Assume active AST format if it's already an array
500
+ options.filters = params.$filter;
501
+ } else {
502
+ options.filters = convertFiltersToAST(params.$filter);
503
+ }
166
504
  }
167
505
 
168
506
  // Sorting - convert to ObjectStack format
169
507
  if (params.$orderby) {
170
- const sortArray = Object.entries(params.$orderby).map(([field, order]) => {
171
- return order === 'desc' ? `-${field}` : field;
172
- });
173
- options.sort = sortArray;
508
+ if (Array.isArray(params.$orderby)) {
509
+ // Handle array format ['name', '-age'] or [{ field: 'name', order: 'asc' }]
510
+ options.sort = params.$orderby.map(item => {
511
+ if (typeof item === 'string') return item;
512
+ // Handle object format { field: 'name', order: 'desc' }
513
+ const field = item.field;
514
+ const order = item.order || 'asc';
515
+ return order === 'desc' ? `-${field}` : field;
516
+ });
517
+ } else {
518
+ // Handle Record format { name: 'asc', age: 'desc' }
519
+ const sortArray = Object.entries(params.$orderby).map(([field, order]) => {
520
+ return order === 'desc' ? `-${field}` : field;
521
+ });
522
+ options.sort = sortArray;
523
+ }
174
524
  }
175
525
 
176
526
  // Pagination
@@ -187,19 +537,41 @@ export class ObjectStackAdapter<T = any> implements DataSource<T> {
187
537
 
188
538
  /**
189
539
  * Get object schema/metadata from ObjectStack.
540
+ * Uses caching to improve performance for repeated requests.
190
541
  *
191
542
  * @param objectName - Object name
192
543
  * @returns Promise resolving to the object schema
193
544
  */
194
- async getObjectSchema(objectName: string): Promise<any> {
545
+ async getObjectSchema(objectName: string): Promise<unknown> {
195
546
  await this.connect();
196
547
 
197
548
  try {
198
- const schema = await this.client.meta.getObject(objectName);
549
+ // Use cache with automatic fetching
550
+ const schema = await this.metadataCache.get(objectName, async () => {
551
+ const result: any = await this.client.meta.getObject(objectName);
552
+
553
+ // Unwrap 'item' property if present (common API response wrapper)
554
+ if (result && result.item) {
555
+ return result.item;
556
+ }
557
+
558
+ return result;
559
+ });
560
+
199
561
  return schema;
200
- } catch (error) {
201
- console.error(`Failed to fetch schema for ${objectName}:`, error);
202
- throw error;
562
+ } catch (error: unknown) {
563
+ // Check if it's a 404 error
564
+ const errorObj = error as Record<string, unknown>;
565
+ if (errorObj?.status === 404 || errorObj?.statusCode === 404) {
566
+ throw new MetadataNotFoundError(objectName, { originalError: error });
567
+ }
568
+
569
+ // For other errors, wrap in ObjectStackError if not already
570
+ if (error instanceof ObjectStackError) {
571
+ throw error;
572
+ }
573
+
574
+ throw createErrorFromResponse(errorObj, `getObjectSchema(${objectName})`);
203
575
  }
204
576
  }
205
577
 
@@ -209,6 +581,29 @@ export class ObjectStackAdapter<T = any> implements DataSource<T> {
209
581
  getClient(): ObjectStackClient {
210
582
  return this.client;
211
583
  }
584
+
585
+ /**
586
+ * Get cache statistics for monitoring performance.
587
+ */
588
+ getCacheStats() {
589
+ return this.metadataCache.getStats();
590
+ }
591
+
592
+ /**
593
+ * Invalidate metadata cache entries.
594
+ *
595
+ * @param key - Optional key to invalidate. If omitted, invalidates all entries.
596
+ */
597
+ invalidateCache(key?: string): void {
598
+ this.metadataCache.invalidate(key);
599
+ }
600
+
601
+ /**
602
+ * Clear all cache entries and statistics.
603
+ */
604
+ clearCache(): void {
605
+ this.metadataCache.clear();
606
+ }
212
607
  }
213
608
 
214
609
  /**
@@ -218,14 +613,40 @@ export class ObjectStackAdapter<T = any> implements DataSource<T> {
218
613
  * ```typescript
219
614
  * const dataSource = createObjectStackAdapter({
220
615
  * baseUrl: process.env.API_URL,
221
- * token: process.env.API_TOKEN
616
+ * token: process.env.API_TOKEN,
617
+ * cache: { maxSize: 100, ttl: 300000 },
618
+ * autoReconnect: true,
619
+ * maxReconnectAttempts: 5
222
620
  * });
223
621
  * ```
224
622
  */
225
- export function createObjectStackAdapter<T = any>(config: {
623
+ export function createObjectStackAdapter<T = unknown>(config: {
226
624
  baseUrl: string;
227
625
  token?: string;
228
626
  fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
627
+ cache?: {
628
+ maxSize?: number;
629
+ ttl?: number;
630
+ };
631
+ autoReconnect?: boolean;
632
+ maxReconnectAttempts?: number;
633
+ reconnectDelay?: number;
229
634
  }): DataSource<T> {
230
635
  return new ObjectStackAdapter<T>(config);
231
636
  }
637
+
638
+ // Export error classes for error handling
639
+ export {
640
+ ObjectStackError,
641
+ MetadataNotFoundError,
642
+ BulkOperationError,
643
+ ConnectionError,
644
+ AuthenticationError,
645
+ ValidationError,
646
+ createErrorFromResponse,
647
+ isObjectStackError,
648
+ isErrorType,
649
+ } from './errors';
650
+
651
+ // Export cache types
652
+ export type { CacheStats } from './cache/MetadataCache';