diff --git a/packages/data-objectstack/README.md b/packages/data-objectstack/README.md index 59b9923a6..2aa428d50 100644 --- a/packages/data-objectstack/README.md +++ b/packages/data-objectstack/README.md @@ -16,6 +16,8 @@ npm install @object-ui/data-objectstack @objectstack/client ## Usage +### Basic Setup + ```typescript import { createObjectStackAdapter } from '@object-ui/data-objectstack'; import { SchemaRenderer } from '@object-ui/react'; @@ -37,9 +39,235 @@ function App() { } ``` +### Advanced Configuration + +```typescript +const dataSource = createObjectStackAdapter({ + baseUrl: 'https://api.example.com', + token: 'your-api-token', + // Configure metadata cache + cache: { + maxSize: 100, // Maximum number of cached schemas (default: 100) + ttl: 5 * 60 * 1000 // Time to live in ms (default: 5 minutes) + } +}); +``` + ## Features - ✅ **CRUD Operations**: Implements `find`, `findOne`, `create`, `update`, `delete`. +- ✅ **Metadata Caching**: Automatic LRU caching of schema metadata with TTL expiration. - ✅ **Metadata Fetching**: Implements `getObjectSchema` to power auto-generated forms and grids. - ✅ **Query Translation**: Converts Object UI's OData-like query parameters to ObjectStack's native query format. -- ✅ **Bulk Operations**: Supports batch create/update/delete. +- ✅ **Bulk Operations**: Supports optimized batch create/update/delete with detailed error reporting. +- ✅ **Error Handling**: Comprehensive error hierarchy with unique error codes and debugging details. + +## Metadata Caching + +The adapter includes built-in metadata caching to improve performance when fetching schemas: + +```typescript +// Get cache statistics +const stats = dataSource.getCacheStats(); +console.log(`Cache hit rate: ${stats.hitRate * 100}%`); +console.log(`Cache size: ${stats.size}/${stats.maxSize}`); + +// Manually invalidate cache entries +dataSource.invalidateCache('users'); // Invalidate specific schema +dataSource.invalidateCache(); // Invalidate all cached schemas + +// Clear cache and statistics +dataSource.clearCache(); +``` + +### Cache Configuration + +- **LRU Eviction**: Automatically evicts least recently used entries when cache is full +- **TTL Expiration**: Entries expire after the configured time-to-live from creation (default: 5 minutes) + - Note: TTL is fixed from creation time, not sliding based on access +- **Memory Limits**: Configurable maximum cache size (default: 100 entries) +- **Concurrent Access**: Handles async operations safely. Note that concurrent requests for the same uncached key may result in multiple fetcher calls. + +## Error Handling + +The adapter provides a comprehensive error hierarchy for better error handling: + +### Error Types + +```typescript +import { + ObjectStackError, // Base error class + MetadataNotFoundError, // Schema/metadata not found (404) + BulkOperationError, // Bulk operation failures with partial results + ConnectionError, // Network/connection errors (503/504) + AuthenticationError, // Authentication failures (401/403) + ValidationError, // Data validation errors (400) +} from '@object-ui/data-objectstack'; +``` + +### Error Handling Example + +```typescript +try { + const schema = await dataSource.getObjectSchema('users'); +} catch (error) { + if (error instanceof MetadataNotFoundError) { + console.error(`Schema not found: ${error.details.objectName}`); + } else if (error instanceof ConnectionError) { + console.error(`Connection failed to: ${error.url}`); + } else if (error instanceof AuthenticationError) { + console.error('Authentication required'); + } + + // All errors have consistent structure + console.error({ + code: error.code, + message: error.message, + statusCode: error.statusCode, + details: error.details + }); +} +``` + +### Bulk Operation Errors + +Bulk operations provide detailed error reporting with partial success information: + +```typescript +try { + await dataSource.bulk('users', 'update', records); +} catch (error) { + if (error instanceof BulkOperationError) { + const summary = error.getSummary(); + console.log(`${summary.successful} succeeded, ${summary.failed} failed`); + console.log(`Failure rate: ${summary.failureRate * 100}%`); + + // Inspect individual failures + summary.errors.forEach(({ index, error }) => { + console.error(`Record ${index} failed:`, error); + }); + } +} +``` + +### Error Codes + +All errors include unique error codes for programmatic handling: + +- `METADATA_NOT_FOUND` - Schema/metadata not found +- `BULK_OPERATION_ERROR` - Bulk operation failure +- `CONNECTION_ERROR` - Connection/network error +- `AUTHENTICATION_ERROR` - Authentication failure +- `VALIDATION_ERROR` - Data validation error +- `UNSUPPORTED_OPERATION` - Unsupported operation +- `NOT_FOUND` - Resource not found +- `UNKNOWN_ERROR` - Unknown error + +## Batch Operations + +The adapter supports optimized batch operations with automatic fallback: + +```typescript +// Batch create +const newUsers = await dataSource.bulk('users', 'create', [ + { name: 'Alice', email: 'alice@example.com' }, + { name: 'Bob', email: 'bob@example.com' }, +]); + +// Batch update (uses updateMany if available, falls back to individual updates) +const updated = await dataSource.bulk('users', 'update', [ + { id: '1', name: 'Alice Smith' }, + { id: '2', name: 'Bob Jones' }, +]); + +// Batch delete +await dataSource.bulk('users', 'delete', [ + { id: '1' }, + { id: '2' }, +]); +``` + +### Performance Optimizations + +- Automatically uses `createMany`, `updateMany`, `deleteMany` when available +- Falls back to individual operations with detailed error tracking +- Provides partial success reporting for resilient error handling +- Atomic operations where supported by the backend + +## API Reference + +### ObjectStackAdapter + +#### Constructor + +```typescript +new ObjectStackAdapter(config: { + baseUrl: string; + token?: string; + fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; + cache?: { + maxSize?: number; + ttl?: number; + }; +}) +``` + +#### Methods + +- `connect()` - Establish connection to ObjectStack server +- `find(resource, params?)` - Query multiple records +- `findOne(resource, id, params?)` - Get a single record by ID +- `create(resource, data)` - Create a new record +- `update(resource, id, data)` - Update an existing record +- `delete(resource, id)` - Delete a record +- `bulk(resource, operation, data)` - Batch operations (create/update/delete) +- `getObjectSchema(objectName)` - Get schema metadata (cached) +- `getCacheStats()` - Get cache statistics +- `invalidateCache(key?)` - Invalidate cache entries +- `clearCache()` - Clear all cache entries +- `getClient()` - Access underlying ObjectStack client + +## Best Practices + +1. **Enable Caching**: Use default cache settings for optimal performance +2. **Handle Errors**: Use typed error handling for better user experience +3. **Batch Operations**: Use bulk methods for large datasets +4. **Monitor Cache**: Check cache hit rates in production +5. **Invalidate Wisely**: Clear cache after schema changes + +## Troubleshooting + +### Common Issues + +#### Schema Not Found + +```typescript +// Error: MetadataNotFoundError +// Solution: Verify object name and ensure schema exists on server +const schema = await dataSource.getObjectSchema('correct_object_name'); +``` + +#### Connection Errors + +```typescript +// Error: ConnectionError +// Solution: Check baseUrl and network connectivity +const dataSource = createObjectStackAdapter({ + baseUrl: 'https://correct-url.example.com', + token: 'valid-token' +}); +``` + +#### Cache Issues + +```typescript +// Clear cache if stale data is being returned +dataSource.clearCache(); + +// Or invalidate specific entries +dataSource.invalidateCache('users'); +``` + +## License + +MIT diff --git a/packages/data-objectstack/src/cache/MetadataCache.test.ts b/packages/data-objectstack/src/cache/MetadataCache.test.ts new file mode 100644 index 000000000..fc0de98c9 --- /dev/null +++ b/packages/data-objectstack/src/cache/MetadataCache.test.ts @@ -0,0 +1,426 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { MetadataCache } from './MetadataCache'; + +describe('MetadataCache', () => { + let cache: MetadataCache; + + beforeEach(() => { + cache = new MetadataCache({ maxSize: 3, ttl: 1000 }); // Small size and TTL for testing + }); + + describe('Cache Hit/Miss Scenarios', () => { + it('should return cached value on cache hit', async () => { + const fetcher = vi.fn(async () => ({ name: 'users', fields: [] })); + + // First call - cache miss + const result1 = await cache.get('users', fetcher); + expect(result1).toEqual({ name: 'users', fields: [] }); + expect(fetcher).toHaveBeenCalledTimes(1); + + // Second call - cache hit + const result2 = await cache.get('users', fetcher); + expect(result2).toEqual({ name: 'users', fields: [] }); + expect(fetcher).toHaveBeenCalledTimes(1); // Not called again + + const stats = cache.getStats(); + expect(stats.hits).toBe(1); + expect(stats.misses).toBe(1); + expect(stats.hitRate).toBe(0.5); + }); + + it('should call fetcher on cache miss', async () => { + const fetcher = vi.fn(async () => ({ data: 'test' })); + + const result = await cache.get('test-key', fetcher); + + expect(result).toEqual({ data: 'test' }); + expect(fetcher).toHaveBeenCalledTimes(1); + + const stats = cache.getStats(); + expect(stats.misses).toBe(1); + expect(stats.hits).toBe(0); + }); + + it('should handle multiple different keys', async () => { + const fetcher1 = vi.fn(async () => ({ type: 'users' })); + const fetcher2 = vi.fn(async () => ({ type: 'posts' })); + + const result1 = await cache.get('users', fetcher1); + const result2 = await cache.get('posts', fetcher2); + + expect(result1).toEqual({ type: 'users' }); + expect(result2).toEqual({ type: 'posts' }); + expect(fetcher1).toHaveBeenCalledTimes(1); + expect(fetcher2).toHaveBeenCalledTimes(1); + + // Get again - both should be cached + await cache.get('users', fetcher1); + await cache.get('posts', fetcher2); + + expect(fetcher1).toHaveBeenCalledTimes(1); + expect(fetcher2).toHaveBeenCalledTimes(1); + }); + }); + + describe('TTL Expiration', () => { + it('should expire entries after TTL', async () => { + const cache = new MetadataCache({ maxSize: 10, ttl: 100 }); // 100ms TTL + const fetcher = vi.fn(async () => ({ data: 'test' })); + + // First fetch + await cache.get('test', fetcher); + expect(fetcher).toHaveBeenCalledTimes(1); + + // Immediate second fetch - should be cached + await cache.get('test', fetcher); + expect(fetcher).toHaveBeenCalledTimes(1); + + // Wait for TTL to expire + await new Promise(resolve => setTimeout(resolve, 150)); + + // Should fetch again after expiration + await cache.get('test', fetcher); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('should update timestamp on cache hit', async () => { + const cache = new MetadataCache({ maxSize: 10, ttl: 200 }); + const fetcher = vi.fn(async () => ({ data: 'test' })); + + await cache.get('test', fetcher); + + // Access again after 100ms + await new Promise(resolve => setTimeout(resolve, 100)); + await cache.get('test', fetcher); + + // Access again after another 100ms (total 200ms from first, but 100ms from last access) + await new Promise(resolve => setTimeout(resolve, 100)); + + // Should still be in cache because we're checking timestamp, not last accessed + // Actually, the implementation uses timestamp for expiration, not lastAccessed + // So after 200ms total, it should expire + await cache.get('test', fetcher); + + // Should have been called twice - initial + after expiration + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('should not return expired entries via has()', async () => { + const cache = new MetadataCache({ maxSize: 10, ttl: 100 }); + const fetcher = vi.fn(async () => ({ data: 'test' })); + + await cache.get('test', fetcher); + + expect(cache.has('test')).toBe(true); + + // Wait for expiration + await new Promise(resolve => setTimeout(resolve, 150)); + + expect(cache.has('test')).toBe(false); + }); + }); + + describe('LRU Eviction', () => { + it('should evict least recently used entry when maxSize is reached', async () => { + // Cache size is 3 + const fetcher1 = vi.fn(async () => ({ id: 1 })); + const fetcher2 = vi.fn(async () => ({ id: 2 })); + const fetcher3 = vi.fn(async () => ({ id: 3 })); + const fetcher4 = vi.fn(async () => ({ id: 4 })); + + // Fill cache + await cache.get('key1', fetcher1); + await cache.get('key2', fetcher2); + await cache.get('key3', fetcher3); + + expect(cache.getStats().size).toBe(3); + + // Add fourth item - should evict key1 (least recently used) + await cache.get('key4', fetcher4); + + expect(cache.getStats().size).toBe(3); + expect(cache.getStats().evictions).toBe(1); + + // key1 should not be in cache anymore + await cache.get('key1', fetcher1); + expect(fetcher1).toHaveBeenCalledTimes(2); // Called again + + // After re-adding key1, key2 should have been evicted + // So cache now has: key3, key4, key1 + + // key3, key4 should still be cached + await cache.get('key3', fetcher3); + await cache.get('key4', fetcher4); + expect(fetcher3).toHaveBeenCalledTimes(1); + expect(fetcher4).toHaveBeenCalledTimes(1); + + // key2 should have been evicted when key1 was re-added + await cache.get('key2', fetcher2); + expect(fetcher2).toHaveBeenCalledTimes(2); + }); + + it('should update LRU order on access', async () => { + const fetcher1 = vi.fn(async () => ({ id: 1 })); + const fetcher2 = vi.fn(async () => ({ id: 2 })); + const fetcher3 = vi.fn(async () => ({ id: 3 })); + const fetcher4 = vi.fn(async () => ({ id: 4 })); + + // Fill cache: key1, key2, key3 + await cache.get('key1', fetcher1); + await cache.get('key2', fetcher2); + await cache.get('key3', fetcher3); + + // Access key1 again - should move it to the end (most recently used) + // Cache order: key2, key3, key1 + await cache.get('key1', fetcher1); + + // Add key4 - should evict key2 (now the LRU) + // Cache order: key3, key1, key4 + await cache.get('key4', fetcher4); + + // Verify key2 was evicted + await cache.get('key2', fetcher2); + expect(fetcher2).toHaveBeenCalledTimes(2); + + // After re-adding key2, key3 should have been evicted + // Cache order: key1, key4, key2 + + // key1, key4 should still be cached + await cache.get('key1', fetcher1); + await cache.get('key4', fetcher4); + expect(fetcher1).toHaveBeenCalledTimes(1); // Only called once initially (re-access was a cache hit) + expect(fetcher4).toHaveBeenCalledTimes(1); + + // key3 should have been evicted when key2 was re-added + await cache.get('key3', fetcher3); + expect(fetcher3).toHaveBeenCalledTimes(2); + }); + }); + + describe('Concurrent Access', () => { + it('should handle concurrent requests for the same key', async () => { + let fetchCount = 0; + const fetcher = vi.fn(async () => { + fetchCount++; + await new Promise(resolve => setTimeout(resolve, 50)); + return { data: 'test', fetchCount }; + }); + + // Make multiple concurrent requests + const results = await Promise.all([ + cache.get('test', fetcher), + cache.get('test', fetcher), + cache.get('test', fetcher), + ]); + + // All should return the same data + // Note: Due to async nature, the first call will fetch and others might also fetch + // if they check before the first one completes. This is acceptable behavior. + // But at least one should be cached if they complete after the first one. + expect(results[0]).toBeDefined(); + expect(results[1]).toBeDefined(); + expect(results[2]).toBeDefined(); + }); + + it('should handle concurrent requests for different keys', async () => { + const fetcher1 = vi.fn(async () => { + await new Promise(resolve => setTimeout(resolve, 30)); + return { id: 1 }; + }); + const fetcher2 = vi.fn(async () => { + await new Promise(resolve => setTimeout(resolve, 30)); + return { id: 2 }; + }); + const fetcher3 = vi.fn(async () => { + await new Promise(resolve => setTimeout(resolve, 30)); + return { id: 3 }; + }); + + const results = await Promise.all([ + cache.get('key1', fetcher1), + cache.get('key2', fetcher2), + cache.get('key3', fetcher3), + ]); + + expect(results[0]).toEqual({ id: 1 }); + expect(results[1]).toEqual({ id: 2 }); + expect(results[2]).toEqual({ id: 3 }); + expect(fetcher1).toHaveBeenCalledTimes(1); + expect(fetcher2).toHaveBeenCalledTimes(1); + expect(fetcher3).toHaveBeenCalledTimes(1); + }); + }); + + describe('Cache Management', () => { + it('should invalidate specific key', async () => { + const fetcher = vi.fn(async () => ({ data: 'test' })); + + await cache.get('test', fetcher); + expect(fetcher).toHaveBeenCalledTimes(1); + + cache.invalidate('test'); + + await cache.get('test', fetcher); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('should invalidate all keys', async () => { + const fetcher1 = vi.fn(async () => ({ id: 1 })); + const fetcher2 = vi.fn(async () => ({ id: 2 })); + + await cache.get('key1', fetcher1); + await cache.get('key2', fetcher2); + + cache.invalidate(); // No key = invalidate all + + await cache.get('key1', fetcher1); + await cache.get('key2', fetcher2); + + expect(fetcher1).toHaveBeenCalledTimes(2); + expect(fetcher2).toHaveBeenCalledTimes(2); + }); + + it('should clear cache and reset stats', async () => { + const fetcher = vi.fn(async () => ({ data: 'test' })); + + await cache.get('key1', fetcher); + await cache.get('key2', fetcher); + await cache.get('key1', fetcher); // Hit + + const statsBefore = cache.getStats(); + expect(statsBefore.size).toBe(2); + expect(statsBefore.hits).toBe(1); + expect(statsBefore.misses).toBe(2); + + cache.clear(); + + const statsAfter = cache.getStats(); + expect(statsAfter.size).toBe(0); + expect(statsAfter.hits).toBe(0); + expect(statsAfter.misses).toBe(0); + expect(statsAfter.evictions).toBe(0); + }); + }); + + describe('Statistics', () => { + it('should track cache statistics correctly', async () => { + const fetcher = vi.fn(async () => ({ data: 'test' })); + + // Initial stats + let stats = cache.getStats(); + expect(stats.size).toBe(0); + expect(stats.maxSize).toBe(3); + expect(stats.hits).toBe(0); + expect(stats.misses).toBe(0); + expect(stats.evictions).toBe(0); + expect(stats.hitRate).toBe(0); + + // First access - miss + await cache.get('key1', fetcher); + stats = cache.getStats(); + expect(stats.size).toBe(1); + expect(stats.misses).toBe(1); + expect(stats.hitRate).toBe(0); + + // Second access - hit + await cache.get('key1', fetcher); + stats = cache.getStats(); + expect(stats.hits).toBe(1); + expect(stats.hitRate).toBe(0.5); + + // Third access - hit + await cache.get('key1', fetcher); + stats = cache.getStats(); + expect(stats.hits).toBe(2); + expect(stats.hitRate).toBeCloseTo(0.667, 2); + }); + + it('should track evictions', async () => { + const fetcher = vi.fn(async () => ({ data: 'test' })); + + // Fill cache to max + await cache.get('key1', fetcher); + await cache.get('key2', fetcher); + await cache.get('key3', fetcher); + + let stats = cache.getStats(); + expect(stats.evictions).toBe(0); + + // Trigger eviction + await cache.get('key4', fetcher); + + stats = cache.getStats(); + expect(stats.evictions).toBe(1); + + // Trigger more evictions + await cache.get('key5', fetcher); + await cache.get('key6', fetcher); + + stats = cache.getStats(); + expect(stats.evictions).toBe(3); + }); + }); + + describe('Edge Cases', () => { + it('should handle fetcher that throws error', async () => { + const fetcher = vi.fn(async () => { + throw new Error('Fetch failed'); + }); + + await expect(cache.get('test', fetcher)).rejects.toThrow('Fetch failed'); + + // Should not cache the error + const stats = cache.getStats(); + expect(stats.size).toBe(0); + }); + + it('should handle null/undefined values', async () => { + const fetcher1 = vi.fn(async () => null); + const fetcher2 = vi.fn(async () => undefined); + + const result1 = await cache.get('null-key', fetcher1); + const result2 = await cache.get('undefined-key', fetcher2); + + expect(result1).toBeNull(); + expect(result2).toBeUndefined(); + + // Should still cache these values + await cache.get('null-key', fetcher1); + await cache.get('undefined-key', fetcher2); + + expect(fetcher1).toHaveBeenCalledTimes(1); + expect(fetcher2).toHaveBeenCalledTimes(1); + }); + + it('should handle empty string key', async () => { + const fetcher = vi.fn(async () => ({ data: 'test' })); + + const result = await cache.get('', fetcher); + expect(result).toEqual({ data: 'test' }); + + await cache.get('', fetcher); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('should handle very large cache', async () => { + const largeCache = new MetadataCache({ maxSize: 10000, ttl: 60000 }); + + // Add many entries + for (let i = 0; i < 1000; i++) { + await largeCache.get(`key-${i}`, async () => ({ id: i })); + } + + const stats = largeCache.getStats(); + expect(stats.size).toBe(1000); + expect(stats.evictions).toBe(0); + }); + }); +}); diff --git a/packages/data-objectstack/src/cache/MetadataCache.ts b/packages/data-objectstack/src/cache/MetadataCache.ts new file mode 100644 index 000000000..dbb6f56a2 --- /dev/null +++ b/packages/data-objectstack/src/cache/MetadataCache.ts @@ -0,0 +1,229 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Represents a cached schema entry with metadata + */ +interface CachedSchema { + data: unknown; + timestamp: number; + accessCount: number; + lastAccessed: number; +} + +/** + * Cache statistics for monitoring + */ +export interface CacheStats { + size: number; + maxSize: number; + hits: number; + misses: number; + evictions: number; + hitRate: number; +} + +/** + * MetadataCache - LRU cache with TTL expiration for schema metadata + * + * Features: + * - LRU (Least Recently Used) eviction policy + * - TTL (Time To Live) based expiration (fixed from creation, not sliding) + * - Memory limit controls + * - Async-safe operations + * - Performance statistics tracking + * + * Note: Concurrent requests for the same uncached key may result in multiple + * fetcher calls. For production use cases requiring request deduplication, + * consider wrapping the cache with a promise-based deduplication layer. + * + * @example + * ```typescript + * const cache = new MetadataCache({ maxSize: 100, ttl: 300000 }); + * + * const schema = await cache.get('users', async () => { + * return await fetchSchemaFromServer('users'); + * }); + * + * console.log(cache.getStats()); + * ``` + */ +export class MetadataCache { + private cache: Map; + private maxSize: number; + private ttl: number; + private stats: { + hits: number; + misses: number; + evictions: number; + }; + + /** + * Create a new MetadataCache instance + * + * @param options - Configuration options + * @param options.maxSize - Maximum number of entries (default: 100) + * @param options.ttl - Time to live in milliseconds (default: 5 minutes) + */ + constructor(options: { maxSize?: number; ttl?: number } = {}) { + this.cache = new Map(); + this.maxSize = options.maxSize || 100; + this.ttl = options.ttl || 5 * 60 * 1000; // 5 minutes default + this.stats = { + hits: 0, + misses: 0, + evictions: 0, + }; + } + + /** + * Get a value from cache or fetch it using the provided fetcher function + * + * @param key - Cache key + * @param fetcher - Async function to fetch data if not in cache + * @returns Promise resolving to the cached or fetched data + */ + async get(key: string, fetcher: () => Promise): Promise { + const now = Date.now(); + const cached = this.cache.get(key); + + // Check if cache entry exists and is not expired + if (cached) { + const age = now - cached.timestamp; + + if (age < this.ttl) { + // Cache hit - update access metadata + cached.accessCount++; + cached.lastAccessed = now; + this.stats.hits++; + + // Move to end (most recently used) by re-inserting + this.cache.delete(key); + this.cache.set(key, cached); + + return cached.data as T; + } else { + // Expired entry - remove it + this.cache.delete(key); + } + } + + // Cache miss - fetch the data + this.stats.misses++; + const data = await fetcher(); + + // Store in cache + this.set(key, data); + + return data; + } + + /** + * Set a value in the cache + * + * @param key - Cache key + * @param data - Data to cache + */ + private set(key: string, data: unknown): void { + const now = Date.now(); + + // Check if we need to evict entries + if (this.cache.size >= this.maxSize && !this.cache.has(key)) { + this.evictLRU(); + } + + // Add or update the entry + this.cache.set(key, { + data, + timestamp: now, + accessCount: 1, + lastAccessed: now, + }); + } + + /** + * Evict the least recently used entry + */ + private evictLRU(): void { + // The first entry in the Map is the least recently used + // (since we move accessed items to the end) + const firstKey = this.cache.keys().next().value; + + if (firstKey !== undefined) { + this.cache.delete(firstKey); + this.stats.evictions++; + } + } + + /** + * Invalidate a specific cache entry or all entries + * + * @param key - Optional key to invalidate. If omitted, invalidates all entries + */ + invalidate(key?: string): void { + if (key) { + this.cache.delete(key); + } else { + this.cache.clear(); + } + } + + /** + * Clear all cache entries and reset statistics + */ + clear(): void { + this.cache.clear(); + this.stats = { + hits: 0, + misses: 0, + evictions: 0, + }; + } + + /** + * Get cache statistics + * + * @returns Cache statistics including hit rate + */ + getStats(): CacheStats { + const total = this.stats.hits + this.stats.misses; + const hitRate = total > 0 ? this.stats.hits / total : 0; + + return { + size: this.cache.size, + maxSize: this.maxSize, + hits: this.stats.hits, + misses: this.stats.misses, + evictions: this.stats.evictions, + hitRate: hitRate, + }; + } + + /** + * Check if a key exists in the cache (and is not expired) + * + * @param key - Cache key to check + * @returns true if the key exists and is not expired + */ + has(key: string): boolean { + const cached = this.cache.get(key); + + if (!cached) { + return false; + } + + const age = Date.now() - cached.timestamp; + + if (age >= this.ttl) { + this.cache.delete(key); + return false; + } + + return true; + } +} diff --git a/packages/data-objectstack/src/errors.test.ts b/packages/data-objectstack/src/errors.test.ts new file mode 100644 index 000000000..0d7990085 --- /dev/null +++ b/packages/data-objectstack/src/errors.test.ts @@ -0,0 +1,426 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect } from 'vitest'; +import { + ObjectStackError, + MetadataNotFoundError, + BulkOperationError, + ConnectionError, + AuthenticationError, + ValidationError, + createErrorFromResponse, + isObjectStackError, + isErrorType, +} from './errors'; + +describe('Error Classes', () => { + describe('ObjectStackError', () => { + it('should create base error with all properties', () => { + const error = new ObjectStackError( + 'Test error', + 'TEST_ERROR', + 500, + { extra: 'info' } + ); + + expect(error.message).toBe('Test error'); + expect(error.code).toBe('TEST_ERROR'); + expect(error.statusCode).toBe(500); + expect(error.details).toEqual({ extra: 'info' }); + expect(error.name).toBe('ObjectStackError'); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(ObjectStackError); + }); + + it('should work without optional parameters', () => { + const error = new ObjectStackError('Test error', 'TEST_ERROR'); + + expect(error.message).toBe('Test error'); + expect(error.code).toBe('TEST_ERROR'); + expect(error.statusCode).toBeUndefined(); + expect(error.details).toBeUndefined(); + }); + + it('should convert to JSON correctly', () => { + const error = new ObjectStackError( + 'Test error', + 'TEST_ERROR', + 500, + { extra: 'info' } + ); + + const json = error.toJSON(); + expect(json).toHaveProperty('name', 'ObjectStackError'); + expect(json).toHaveProperty('message', 'Test error'); + expect(json).toHaveProperty('code', 'TEST_ERROR'); + expect(json).toHaveProperty('statusCode', 500); + expect(json).toHaveProperty('details', { extra: 'info' }); + expect(json).toHaveProperty('stack'); + }); + + it('should maintain proper stack trace', () => { + const error = new ObjectStackError('Test error', 'TEST_ERROR'); + expect(error.stack).toBeDefined(); + expect(error.stack).toContain('ObjectStackError'); + }); + }); + + describe('MetadataNotFoundError', () => { + it('should create metadata not found error', () => { + const error = new MetadataNotFoundError('users'); + + expect(error.message).toBe('Metadata not found for object: users'); + expect(error.code).toBe('METADATA_NOT_FOUND'); + expect(error.statusCode).toBe(404); + expect(error.name).toBe('MetadataNotFoundError'); + expect(error.details).toHaveProperty('objectName', 'users'); + expect(error).toBeInstanceOf(ObjectStackError); + expect(error).toBeInstanceOf(MetadataNotFoundError); + }); + + it('should include additional details', () => { + const error = new MetadataNotFoundError('users', { reason: 'Schema not loaded' }); + + expect(error.details).toEqual({ + objectName: 'users', + reason: 'Schema not loaded', + }); + }); + }); + + describe('BulkOperationError', () => { + it('should create bulk operation error', () => { + const errors = [ + { index: 0, error: 'Invalid data' }, + { index: 2, error: 'Duplicate key' }, + ]; + + const error = new BulkOperationError('create', 8, 2, errors); + + expect(error.message).toBe('Bulk create operation failed: 8 succeeded, 2 failed'); + expect(error.code).toBe('BULK_OPERATION_ERROR'); + expect(error.statusCode).toBe(500); + expect(error.name).toBe('BulkOperationError'); + expect(error.successCount).toBe(8); + expect(error.failureCount).toBe(2); + expect(error.errors).toEqual(errors); + expect(error).toBeInstanceOf(ObjectStackError); + expect(error).toBeInstanceOf(BulkOperationError); + }); + + it('should provide operation summary', () => { + const errors = [{ index: 0, error: 'Error' }]; + const error = new BulkOperationError('update', 9, 1, errors); + + const summary = error.getSummary(); + expect(summary).toEqual({ + operation: 'update', + total: 10, + successful: 9, + failed: 1, + failureRate: 0.1, + errors: errors, + }); + }); + + it('should handle different operation types', () => { + const createError = new BulkOperationError('create', 5, 0, []); + const updateError = new BulkOperationError('update', 3, 2, []); + const deleteError = new BulkOperationError('delete', 10, 1, []); + + expect(createError.message).toContain('create'); + expect(updateError.message).toContain('update'); + expect(deleteError.message).toContain('delete'); + }); + }); + + describe('ConnectionError', () => { + it('should create connection error', () => { + const error = new ConnectionError( + 'Network timeout', + 'https://api.example.com' + ); + + expect(error.message).toBe('Connection error: Network timeout'); + expect(error.code).toBe('CONNECTION_ERROR'); + expect(error.statusCode).toBe(503); + expect(error.name).toBe('ConnectionError'); + expect(error.url).toBe('https://api.example.com'); + expect(error).toBeInstanceOf(ObjectStackError); + expect(error).toBeInstanceOf(ConnectionError); + }); + + it('should work without URL', () => { + const error = new ConnectionError('Network timeout'); + + expect(error.url).toBeUndefined(); + expect(error.message).toBe('Connection error: Network timeout'); + }); + }); + + describe('AuthenticationError', () => { + it('should create authentication error', () => { + const error = new AuthenticationError(); + + expect(error.message).toBe('Authentication failed'); + expect(error.code).toBe('AUTHENTICATION_ERROR'); + expect(error.statusCode).toBe(401); + expect(error.name).toBe('AuthenticationError'); + expect(error).toBeInstanceOf(ObjectStackError); + expect(error).toBeInstanceOf(AuthenticationError); + }); + + it('should accept custom message', () => { + const error = new AuthenticationError('Invalid API token'); + + expect(error.message).toBe('Invalid API token'); + }); + + it('should include additional details', () => { + const error = new AuthenticationError('Invalid token', { token: 'abc123' }); + + expect(error.details).toEqual({ token: 'abc123' }); + }); + }); + + describe('ValidationError', () => { + it('should create validation error', () => { + const error = new ValidationError('Invalid input'); + + expect(error.message).toBe('Invalid input'); + expect(error.code).toBe('VALIDATION_ERROR'); + expect(error.statusCode).toBe(400); + expect(error.name).toBe('ValidationError'); + expect(error).toBeInstanceOf(ObjectStackError); + expect(error).toBeInstanceOf(ValidationError); + }); + + it('should include field information', () => { + const error = new ValidationError('Email is invalid', 'email'); + + expect(error.field).toBe('email'); + expect(error.details).toHaveProperty('field', 'email'); + }); + + it('should include validation errors array', () => { + const validationErrors = [ + { field: 'email', message: 'Invalid email format' }, + { field: 'age', message: 'Must be a positive number' }, + ]; + + const error = new ValidationError( + 'Validation failed', + undefined, + validationErrors + ); + + expect(error.validationErrors).toEqual(validationErrors); + expect(error.getValidationErrors()).toEqual(validationErrors); + }); + + it('should return empty array when no validation errors', () => { + const error = new ValidationError('Validation failed'); + + expect(error.getValidationErrors()).toEqual([]); + }); + }); +}); + +describe('Error Helpers', () => { + describe('createErrorFromResponse', () => { + it('should create AuthenticationError for 401 status', () => { + const response = { + status: 401, + message: 'Unauthorized', + data: null, + }; + + const error = createErrorFromResponse(response, 'API request'); + + expect(error).toBeInstanceOf(AuthenticationError); + expect(error.message).toBe('Unauthorized'); + expect(error.statusCode).toBe(401); + }); + + it('should create AuthenticationError for 403 status', () => { + const response = { + status: 403, + message: 'Forbidden', + }; + + const error = createErrorFromResponse(response); + + expect(error).toBeInstanceOf(AuthenticationError); + expect(error.statusCode).toBe(403); + }); + + it('should create MetadataNotFoundError for 404 with metadata context', () => { + const response = { + status: 404, + message: 'Not found', + }; + + const error = createErrorFromResponse(response, 'getObjectSchema(users)'); + + expect(error).toBeInstanceOf(MetadataNotFoundError); + expect(error.statusCode).toBe(404); + expect((error as MetadataNotFoundError).details.objectName).toBe('users'); + }); + + it('should create generic error for 404 without metadata context', () => { + const response = { + status: 404, + message: 'Not found', + }; + + const error = createErrorFromResponse(response, 'data request'); + + expect(error).toBeInstanceOf(ObjectStackError); + expect(error).not.toBeInstanceOf(MetadataNotFoundError); + expect(error.code).toBe('NOT_FOUND'); + }); + + it('should create ValidationError for 400 status', () => { + const response = { + status: 400, + message: 'Bad request', + data: { + errors: [ + { field: 'email', message: 'Invalid email' }, + ], + }, + }; + + const error = createErrorFromResponse(response); + + expect(error).toBeInstanceOf(ValidationError); + expect(error.statusCode).toBe(400); + expect((error as ValidationError).validationErrors).toEqual([ + { field: 'email', message: 'Invalid email' }, + ]); + }); + + it('should create ConnectionError for 503 status', () => { + const response = { + status: 503, + message: 'Service unavailable', + config: { url: 'https://api.example.com' }, + }; + + const error = createErrorFromResponse(response); + + expect(error).toBeInstanceOf(ConnectionError); + expect(error.statusCode).toBe(503); + expect((error as ConnectionError).url).toBe('https://api.example.com'); + }); + + it('should create ConnectionError for 504 status', () => { + const response = { + status: 504, + message: 'Gateway timeout', + }; + + const error = createErrorFromResponse(response); + + expect(error).toBeInstanceOf(ConnectionError); + expect(error.statusCode).toBe(504); + }); + + it('should create generic error for unknown status', () => { + const response = { + status: 418, + message: "I'm a teapot", + }; + + const error = createErrorFromResponse(response); + + expect(error).toBeInstanceOf(ObjectStackError); + expect(error.code).toBe('UNKNOWN_ERROR'); + expect(error.statusCode).toBe(418); + }); + + it('should handle response without status code', () => { + const response = { + message: 'Unknown error', + }; + + const error = createErrorFromResponse(response); + + expect(error).toBeInstanceOf(ObjectStackError); + expect(error.statusCode).toBe(500); + }); + + it('should include context in error details', () => { + const response = { + status: 500, + message: 'Server error', + }; + + const error = createErrorFromResponse(response, 'user creation'); + + expect(error.details).toHaveProperty('context', 'user creation'); + }); + }); + + describe('isObjectStackError', () => { + it('should return true for ObjectStackError instances', () => { + const error = new ObjectStackError('Test', 'TEST'); + expect(isObjectStackError(error)).toBe(true); + }); + + it('should return true for derived error classes', () => { + const metadataError = new MetadataNotFoundError('users'); + const bulkError = new BulkOperationError('create', 0, 1, []); + const connError = new ConnectionError('timeout'); + const authError = new AuthenticationError(); + const validError = new ValidationError('invalid'); + + expect(isObjectStackError(metadataError)).toBe(true); + expect(isObjectStackError(bulkError)).toBe(true); + expect(isObjectStackError(connError)).toBe(true); + expect(isObjectStackError(authError)).toBe(true); + expect(isObjectStackError(validError)).toBe(true); + }); + + it('should return false for regular Error', () => { + const error = new Error('Regular error'); + expect(isObjectStackError(error)).toBe(false); + }); + + it('should return false for non-error values', () => { + expect(isObjectStackError(null)).toBe(false); + expect(isObjectStackError(undefined)).toBe(false); + expect(isObjectStackError('error')).toBe(false); + expect(isObjectStackError({})).toBe(false); + }); + }); + + describe('isErrorType', () => { + it('should return true for matching error type', () => { + const error = new MetadataNotFoundError('users'); + expect(isErrorType(error, MetadataNotFoundError)).toBe(true); + }); + + it('should return false for non-matching error type', () => { + const error = new MetadataNotFoundError('users'); + expect(isErrorType(error, ValidationError)).toBe(false); + }); + + it('should return true for base class check', () => { + const error = new MetadataNotFoundError('users'); + expect(isErrorType(error, ObjectStackError)).toBe(true); + }); + + it('should return false for non-error values', () => { + expect(isErrorType(null, ObjectStackError)).toBe(false); + expect(isErrorType(undefined, ObjectStackError)).toBe(false); + expect(isErrorType({}, ObjectStackError)).toBe(false); + }); + }); +}); diff --git a/packages/data-objectstack/src/errors.ts b/packages/data-objectstack/src/errors.ts new file mode 100644 index 000000000..5e316cdff --- /dev/null +++ b/packages/data-objectstack/src/errors.ts @@ -0,0 +1,275 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Base error class for all ObjectStack adapter errors + */ +export class ObjectStackError extends Error { + /** + * Create a new ObjectStackError + * + * @param message - Human-readable error message + * @param code - Unique error code for programmatic handling + * @param statusCode - Optional HTTP status code + * @param details - Optional additional error details for debugging + */ + constructor( + message: string, + public code: string, + public statusCode?: number, + public details?: Record + ) { + super(message); + this.name = 'ObjectStackError'; + + // Maintains proper stack trace for where error was thrown (only in V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } + + /** + * Convert error to JSON for logging/debugging + */ + toJSON() { + return { + name: this.name, + message: this.message, + code: this.code, + statusCode: this.statusCode, + details: this.details, + stack: this.stack, + }; + } +} + +/** + * Error thrown when requested metadata/schema is not found + */ +export class MetadataNotFoundError extends ObjectStackError { + constructor( + objectName: string, + details?: Record + ) { + super( + `Metadata not found for object: ${objectName}`, + 'METADATA_NOT_FOUND', + 404, + { objectName, ...details } + ); + this.name = 'MetadataNotFoundError'; + } +} + +/** + * Error thrown when a bulk operation fails + */ +export class BulkOperationError extends ObjectStackError { + /** + * Create a new BulkOperationError + * + * @param operation - The bulk operation that failed (create, update, delete) + * @param successCount - Number of successful operations + * @param failureCount - Number of failed operations + * @param errors - Array of individual errors + * @param details - Additional error details + */ + constructor( + operation: 'create' | 'update' | 'delete', + public successCount: number, + public failureCount: number, + public errors: Array<{ index: number; error: unknown }>, + details?: Record + ) { + super( + `Bulk ${operation} operation failed: ${successCount} succeeded, ${failureCount} failed`, + 'BULK_OPERATION_ERROR', + 500, + { + operation, + successCount, + failureCount, + errors, + ...details, + } + ); + this.name = 'BulkOperationError'; + } + + /** + * Get a summary of the bulk operation failure + */ + getSummary() { + const total = this.successCount + this.failureCount; + const failureRate = total > 0 ? this.failureCount / total : 0; + + return { + operation: this.details?.operation as string, + total: total, + successful: this.successCount, + failed: this.failureCount, + failureRate: failureRate, + errors: this.errors, + }; + } +} + +/** + * Error thrown when connection to ObjectStack server fails + */ +export class ConnectionError extends ObjectStackError { + constructor( + message: string, + public url?: string, + details?: Record, + statusCode?: number + ) { + super( + `Connection error: ${message}`, + 'CONNECTION_ERROR', + statusCode || 503, + { url, ...details } + ); + this.name = 'ConnectionError'; + } +} + +/** + * Error thrown when authentication fails + */ +export class AuthenticationError extends ObjectStackError { + constructor( + message: string = 'Authentication failed', + details?: Record, + statusCode?: number + ) { + super( + message, + 'AUTHENTICATION_ERROR', + statusCode || 401, + details + ); + this.name = 'AuthenticationError'; + } +} + +/** + * Error thrown when data validation fails + */ +export class ValidationError extends ObjectStackError { + /** + * Create a new ValidationError + * + * @param message - Human-readable error message + * @param field - The field that failed validation (optional) + * @param validationErrors - Array of validation error details + * @param details - Additional error details + */ + constructor( + message: string, + public field?: string, + public validationErrors?: Array<{ field: string; message: string }>, + details?: Record + ) { + super( + message, + 'VALIDATION_ERROR', + 400, + { + field, + validationErrors, + ...details, + } + ); + this.name = 'ValidationError'; + } + + /** + * Get all validation errors as a formatted list + */ + getValidationErrors() { + return this.validationErrors || []; + } +} + +/** + * Helper function to create an error from an HTTP response + * + * @param response - Response object or error from fetch/axios + * @param context - Additional context for debugging + * @returns Appropriate error instance + */ +export function createErrorFromResponse(response: Record, context?: string): ObjectStackError { + const status = (response?.status as number) || (response?.statusCode as number) || 500; + const message = (response?.message as string) || (response?.statusText as string) || 'Unknown error'; + const details = { + context, + response: { + status, + data: response?.data, + headers: response?.headers, + }, + }; + + switch (status) { + case 401: + return new AuthenticationError(message, details, 401); + + case 403: + return new AuthenticationError(message, details, 403); + + case 404: + // Check if it's a metadata request based on context + if (context?.includes('metadata') || context?.includes('schema') || context?.includes('getObjectSchema')) { + const objectName = extractObjectName(context); + return new MetadataNotFoundError(objectName, details); + } + return new ObjectStackError(message, 'NOT_FOUND', 404, details); + + case 400: + return new ValidationError(message, undefined, (response?.data as Record)?.errors as Array<{ field: string; message: string }>, details); + + case 503: + return new ConnectionError(message, (response?.config as Record)?.url as string, details, 503); + + case 504: + return new ConnectionError(message, (response?.config as Record)?.url as string, details, 504); + + default: + return new ObjectStackError(message, 'UNKNOWN_ERROR', status, details); + } +} + +/** + * Helper to extract object name from context string + */ +function extractObjectName(context?: string): string { + if (!context) return 'unknown'; + + // Try to extract object name from patterns like "getObjectSchema(users)" + const match = context.match(/\(([^)]+)\)/); + return match ? match[1] : 'unknown'; +} + +/** + * Type guard to check if an error is an ObjectStackError + */ +export function isObjectStackError(error: unknown): error is ObjectStackError { + return error instanceof ObjectStackError; +} + +/** + * Type guard to check if an error is a specific ObjectStack error type + */ +export function isErrorType( + error: unknown, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + errorClass: new (...args: any[]) => T +): error is T { + return error instanceof errorClass; +} diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts index 68cf7546a..a291c00fa 100644 --- a/packages/data-objectstack/src/index.ts +++ b/packages/data-objectstack/src/index.ts @@ -9,6 +9,14 @@ import { ObjectStackClient, type QueryOptions as ObjectStackQueryOptions } from '@objectstack/client'; import type { DataSource, QueryParams, QueryResult } from '@object-ui/types'; import { convertFiltersToAST } from '@object-ui/core'; +import { MetadataCache } from './cache/MetadataCache'; +import { + ObjectStackError, + MetadataNotFoundError, + BulkOperationError, + ConnectionError, + createErrorFromResponse, +} from './errors'; /** * ObjectStack Data Source Adapter @@ -32,16 +40,22 @@ import { convertFiltersToAST } from '@object-ui/core'; * }); * ``` */ -export class ObjectStackAdapter implements DataSource { +export class ObjectStackAdapter implements DataSource { private client: ObjectStackClient; private connected: boolean = false; + private metadataCache: MetadataCache; constructor(config: { baseUrl: string; token?: string; fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; + cache?: { + maxSize?: number; + ttl?: number; + }; }) { this.client = new ObjectStackClient(config); + this.metadataCache = new MetadataCache(config.cache); } /** @@ -50,8 +64,17 @@ export class ObjectStackAdapter implements DataSource { */ async connect(): Promise { if (!this.connected) { - await this.client.connect(); - this.connected = true; + try { + await this.client.connect(); + this.connected = true; + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Failed to connect to ObjectStack server'; + throw new ConnectionError( + errorMessage, + undefined, + { originalError: error } + ); + } } } @@ -63,7 +86,7 @@ export class ObjectStackAdapter implements DataSource { await this.connect(); const queryOptions = this.convertQueryParams(params); - const result: any = await this.client.data.find(resource, queryOptions); + const result: unknown = await this.client.data.find(resource, queryOptions); // Handle legacy/raw array response (e.g. from some mock servers or non-OData endpoints) if (Array.isArray(result)) { @@ -76,13 +99,14 @@ export class ObjectStackAdapter implements DataSource { }; } + const resultObj = result as { value?: T[]; count?: number }; return { - data: result.value || [], - total: result.count || (result.value ? result.value.length : 0), + data: resultObj.value || [], + total: resultObj.count || (resultObj.value ? resultObj.value.length : 0), // Calculate page number safely page: params?.$skip && params.$top ? Math.floor(params.$skip / params.$top) + 1 : 1, pageSize: params?.$top, - hasMore: params?.$top ? (result.value?.length || 0) === params.$top : false, + hasMore: params?.$top ? (resultObj.value?.length || 0) === params.$top : false, }; } @@ -95,9 +119,9 @@ export class ObjectStackAdapter implements DataSource { try { const record = await this.client.data.get(resource, String(id)); return record; - } catch (error) { + } catch (error: unknown) { // If record not found, return null instead of throwing - if ((error as any)?.status === 404) { + if ((error as Record)?.status === 404) { return null; } throw error; @@ -130,31 +154,124 @@ export class ObjectStackAdapter implements DataSource { } /** - * Bulk operations (optional implementation). + * Bulk operations with optimized batch processing and error handling. + * + * @param resource - Resource name + * @param operation - Operation type (create, update, delete) + * @param data - Array of records to process + * @returns Promise resolving to array of results */ async bulk(resource: string, operation: 'create' | 'update' | 'delete', data: Partial[]): Promise { await this.connect(); - switch (operation) { - case 'create': - return this.client.data.createMany(resource, data); - case 'delete': { - const ids = data.map(item => (item as any).id).filter(Boolean); - await this.client.data.deleteMany(resource, ids); - return []; + if (!data || data.length === 0) { + return []; + } + + try { + switch (operation) { + case 'create': + return await this.client.data.createMany(resource, data); + + case 'delete': { + const ids = data.map(item => (item as Record).id).filter(Boolean); + + if (ids.length === 0) { + // Track which items are missing IDs + const errors = data.map((_, index) => ({ + index, + error: `Missing ID for item at index ${index}` + })); + + throw new BulkOperationError('delete', 0, data.length, errors); + } + + await this.client.data.deleteMany(resource, ids); + return [] as T[]; + } + + case 'update': { + // Check if client supports updateMany + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (typeof (this.client.data as any).updateMany === 'function') { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const updateMany = (this.client.data as any).updateMany; + return await updateMany(resource, data) as T[]; + } catch { + // If updateMany is not supported, fall back to individual updates + // Silently fallback without logging + } + } + + // Fallback: Process updates individually with detailed error tracking + const results: T[] = []; + const errors: Array<{ index: number; error: unknown }> = []; + + for (let i = 0; i < data.length; i++) { + const item = data[i]; + const id = (item as Record).id; + + if (!id) { + errors.push({ index: i, error: 'Missing ID' }); + continue; + } + + try { + const result = await this.client.data.update(resource, String(id), item); + results.push(result); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + errors.push({ index: i, error: errorMessage }); + } + } + + // If there were any errors, throw BulkOperationError + if (errors.length > 0) { + throw new BulkOperationError( + 'update', + results.length, + errors.length, + errors, + { resource, totalRecords: data.length } + ); + } + + return results; + } + + default: + throw new ObjectStackError( + `Unsupported bulk operation: ${operation}`, + 'UNSUPPORTED_OPERATION', + 400 + ); } - case 'update': { - // For update, we need to handle each record individually - // or use the batch update if all records get the same changes - const results = await Promise.all( - data.map(item => - this.client.data.update(resource, String((item as any).id), item) - ) - ); - return results; + } catch (error: unknown) { + // If it's already a BulkOperationError, re-throw it + if (error instanceof BulkOperationError) { + throw error; + } + + // If it's already an ObjectStackError, re-throw it + if (error instanceof ObjectStackError) { + throw error; } - default: - throw new Error(`Unsupported bulk operation: ${operation}`); + + // Wrap other errors in BulkOperationError with proper error tracking + const errorMessage = error instanceof Error ? error.message : String(error); + const errors = data.map((_, index) => ({ + index, + error: errorMessage + })); + + throw new BulkOperationError( + operation, + 0, + data.length, + errors, + { resource, originalError: error } + ); } } @@ -199,19 +316,35 @@ export class ObjectStackAdapter implements DataSource { /** * Get object schema/metadata from ObjectStack. + * Uses caching to improve performance for repeated requests. * * @param objectName - Object name * @returns Promise resolving to the object schema */ - async getObjectSchema(objectName: string): Promise { + async getObjectSchema(objectName: string): Promise { await this.connect(); try { - const schema = await this.client.meta.getObject(objectName); + // Use cache with automatic fetching + const schema = await this.metadataCache.get(objectName, async () => { + const result = await this.client.meta.getObject(objectName); + return result; + }); + return schema; - } catch (error) { - console.error(`Failed to fetch schema for ${objectName}:`, error); - throw error; + } catch (error: unknown) { + // Check if it's a 404 error + const errorObj = error as Record; + if (errorObj?.status === 404 || errorObj?.statusCode === 404) { + throw new MetadataNotFoundError(objectName, { originalError: error }); + } + + // For other errors, wrap in ObjectStackError if not already + if (error instanceof ObjectStackError) { + throw error; + } + + throw createErrorFromResponse(errorObj, `getObjectSchema(${objectName})`); } } @@ -221,6 +354,29 @@ export class ObjectStackAdapter implements DataSource { getClient(): ObjectStackClient { return this.client; } + + /** + * Get cache statistics for monitoring performance. + */ + getCacheStats() { + return this.metadataCache.getStats(); + } + + /** + * Invalidate metadata cache entries. + * + * @param key - Optional key to invalidate. If omitted, invalidates all entries. + */ + invalidateCache(key?: string): void { + this.metadataCache.invalidate(key); + } + + /** + * Clear all cache entries and statistics. + */ + clearCache(): void { + this.metadataCache.clear(); + } } /** @@ -230,14 +386,35 @@ export class ObjectStackAdapter implements DataSource { * ```typescript * const dataSource = createObjectStackAdapter({ * baseUrl: process.env.API_URL, - * token: process.env.API_TOKEN + * token: process.env.API_TOKEN, + * cache: { maxSize: 100, ttl: 300000 } * }); * ``` */ -export function createObjectStackAdapter(config: { +export function createObjectStackAdapter(config: { baseUrl: string; token?: string; fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; + cache?: { + maxSize?: number; + ttl?: number; + }; }): DataSource { return new ObjectStackAdapter(config); } + +// Export error classes for error handling +export { + ObjectStackError, + MetadataNotFoundError, + BulkOperationError, + ConnectionError, + AuthenticationError, + ValidationError, + createErrorFromResponse, + isObjectStackError, + isErrorType, +} from './errors'; + +// Export cache types +export type { CacheStats } from './cache/MetadataCache'; diff --git a/packages/plugin-aggrid/src/ObjectAgGridImpl.tsx b/packages/plugin-aggrid/src/ObjectAgGridImpl.tsx index cfc8f0670..5b33d42a4 100644 --- a/packages/plugin-aggrid/src/ObjectAgGridImpl.tsx +++ b/packages/plugin-aggrid/src/ObjectAgGridImpl.tsx @@ -549,7 +549,7 @@ function applyFieldTypeFormatting(colDef: ColDef, field: FieldMetadata): void { }; break; - case 'number': + case 'number': { const precision = (field as any).precision; if (precision !== undefined) { colDef.valueFormatter = (params: any) => { @@ -558,6 +558,7 @@ function applyFieldTypeFormatting(colDef: ColDef, field: FieldMetadata): void { }; } break; + } case 'color': colDef.cellRenderer = (params: any) => {