@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/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 = any> implements DataSource<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
- await this.client.connect();
54
- this.connected = true;
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: result.value,
70
- total: result.count,
71
- page: params?.$skip ? Math.floor(params.$skip / (params.$top || 20)) + 1 : 1,
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: result.value.length === params?.$top,
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 record = await this.client.data.get<T>(resource, String(id));
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 any)?.status === 404) {
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
- return this.client.data.create<T>(resource, data);
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
- return this.client.data.update<T>(resource, String(id), data);
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.success;
324
+ return result.deleted;
118
325
  }
119
326
 
120
327
  /**
121
- * Bulk operations (optional implementation).
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
- 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 [];
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
- 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;
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
- default:
145
- throw new Error(`Unsupported bulk operation: ${operation}`);
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
- options.filters = convertFiltersToAST(params.$filter);
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
- const sortArray = Object.entries(params.$orderby).map(([field, order]) => {
171
- return order === 'desc' ? `-${field}` : field;
172
- });
173
- options.sort = sortArray;
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<any> {
554
+ async getObjectSchema(objectName: string): Promise<unknown> {
195
555
  await this.connect();
196
556
 
197
557
  try {
198
- const schema = await this.client.meta.getObject(objectName);
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
- console.error(`Failed to fetch schema for ${objectName}:`, error);
202
- throw error;
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 = any>(config: {
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';