diff --git a/apps/console/README.md b/apps/console/README.md index a0cbdc9dd..d57564937 100644 --- a/apps/console/README.md +++ b/apps/console/README.md @@ -4,7 +4,7 @@ The standard runtime UI for ObjectStack applications. This package provides the ## Features -- **Spec-Compliant**: Fully implements ObjectStack Spec v0.8.2 +- **Spec-Compliant**: Fully implements ObjectStack Spec v0.9.0 - **Dynamic UI**: Renders Dashboards, Grids, and Forms based on JSON schemas - **Multi-App Support**: Switch between different apps defined in your stack - **Plugin Architecture**: Can be loaded as a static plugin in the ObjectStack Runtime @@ -27,8 +27,8 @@ This console implements the following ObjectStack Spec features: ### Navigation Support - ✅ `object` - Navigate to object list views -- ✅ `dashboard` - Navigate to dashboards (planned) -- ✅ `page` - Navigate to custom pages (planned) +- ✅ `dashboard` - Navigate to dashboards +- ✅ `page` - Navigate to custom pages - ✅ `url` - External URL navigation with target support - ✅ `group` - Nested navigation groups - ✅ Navigation item visibility conditions diff --git a/apps/console/src/__tests__/SpecCompliance.test.tsx b/apps/console/src/__tests__/SpecCompliance.test.tsx index d993df0dc..2f191e81e 100644 --- a/apps/console/src/__tests__/SpecCompliance.test.tsx +++ b/apps/console/src/__tests__/SpecCompliance.test.tsx @@ -5,11 +5,11 @@ import appConfig from '../../objectstack.config'; /** * Spec Compliance Tests * - * These tests verify that the console properly implements the ObjectStack Spec v0.8.2 + * These tests verify that the console properly implements the ObjectStack Spec v0.9.0 * See: apps/console/SPEC_ALIGNMENT.md for full compliance details */ -describe('ObjectStack Spec v0.8.2 Compliance', () => { +describe('ObjectStack Spec v0.9.0 Compliance', () => { describe('AppSchema Validation', () => { it('should have at least one app defined', () => { diff --git a/packages/data-objectstack/README.md b/packages/data-objectstack/README.md index 2aa428d50..e65e0ac4f 100644 --- a/packages/data-objectstack/README.md +++ b/packages/data-objectstack/README.md @@ -49,7 +49,11 @@ const dataSource = createObjectStackAdapter({ cache: { maxSize: 100, // Maximum number of cached schemas (default: 100) ttl: 5 * 60 * 1000 // Time to live in ms (default: 5 minutes) - } + }, + // Configure auto-reconnect + autoReconnect: true, // Enable auto-reconnect (default: true) + maxReconnectAttempts: 5, // Max reconnection attempts (default: 3) + reconnectDelay: 2000 // Initial delay between reconnects in ms (default: 1000) }); ``` @@ -61,6 +65,9 @@ const dataSource = createObjectStackAdapter({ - ✅ **Query Translation**: Converts Object UI's OData-like query parameters to ObjectStack's native query format. - ✅ **Bulk Operations**: Supports optimized batch create/update/delete with detailed error reporting. - ✅ **Error Handling**: Comprehensive error hierarchy with unique error codes and debugging details. +- ✅ **Connection Monitoring**: Real-time connection state tracking with event listeners. +- ✅ **Auto-Reconnect**: Automatic reconnection with exponential backoff on connection failures. +- ✅ **Batch Progress**: Progress events for tracking bulk operation status. ## Metadata Caching @@ -88,6 +95,77 @@ dataSource.clearCache(); - **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. +## Connection State Monitoring + +The adapter provides real-time connection state monitoring with automatic reconnection: + +```typescript +// Monitor connection state changes +const unsubscribe = dataSource.onConnectionStateChange((event) => { + console.log('Connection state:', event.state); + console.log('Timestamp:', new Date(event.timestamp)); + + if (event.error) { + console.error('Connection error:', event.error); + } +}); + +// Check current connection state +console.log(dataSource.getConnectionState()); // 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error' + +// Check if connected +if (dataSource.isConnected()) { + console.log('Adapter is connected'); +} + +// Unsubscribe from events when done +unsubscribe(); +``` + +### Connection States + +- `disconnected` - Not connected to server +- `connecting` - Attempting initial connection +- `connected` - Successfully connected +- `reconnecting` - Attempting to reconnect after failure +- `error` - Connection failed (check event.error for details) + +### Auto-Reconnect + +The adapter automatically attempts to reconnect on connection failures: + +- **Exponential Backoff**: Delay increases with each attempt (delay × 2^(attempts-1)) +- **Configurable Attempts**: Set `maxReconnectAttempts` (default: 3) +- **Configurable Delay**: Set `reconnectDelay` for initial delay (default: 1000ms) +- **Automatic**: Enabled by default, disable with `autoReconnect: false` + +## Batch Operation Progress + +Track progress of bulk operations in real-time: + +```typescript +// Monitor batch operation progress +const unsubscribe = dataSource.onBatchProgress((event) => { + console.log(`${event.operation}: ${event.percentage.toFixed(1)}%`); + console.log(`Completed: ${event.completed}/${event.total}`); + console.log(`Failed: ${event.failed}`); +}); + +// Perform bulk operation +const users = await dataSource.bulk('users', 'create', largeDataset); + +// Unsubscribe when done +unsubscribe(); +``` + +### Progress Event Properties + +- `operation` - Operation type ('create' | 'update' | 'delete') +- `total` - Total number of items +- `completed` - Number of successfully completed items +- `failed` - Number of failed items +- `percentage` - Completion percentage (0-100) + ## Error Handling The adapter provides a comprehensive error hierarchy for better error handling: @@ -209,6 +287,9 @@ new ObjectStackAdapter(config: { maxSize?: number; ttl?: number; }; + autoReconnect?: boolean; + maxReconnectAttempts?: number; + reconnectDelay?: number; }) ``` @@ -226,6 +307,10 @@ new ObjectStackAdapter(config: { - `invalidateCache(key?)` - Invalidate cache entries - `clearCache()` - Clear all cache entries - `getClient()` - Access underlying ObjectStack client +- `getConnectionState()` - Get current connection state +- `isConnected()` - Check if adapter is connected +- `onConnectionStateChange(listener)` - Subscribe to connection state changes (returns unsubscribe function) +- `onBatchProgress(listener)` - Subscribe to batch operation progress (returns unsubscribe function) ## Best Practices @@ -234,6 +319,9 @@ new ObjectStackAdapter(config: { 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 +6. **Connection Monitoring**: Subscribe to connection state changes for better UX +7. **Auto-Reconnect**: Use default auto-reconnect settings for resilient applications +8. **Batch Progress**: Monitor progress for long-running bulk operations ## Troubleshooting diff --git a/packages/data-objectstack/src/connection.test.ts b/packages/data-objectstack/src/connection.test.ts new file mode 100644 index 000000000..07784a90a --- /dev/null +++ b/packages/data-objectstack/src/connection.test.ts @@ -0,0 +1,100 @@ +/** + * 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 { ObjectStackAdapter, ConnectionState, ConnectionStateEvent, BatchProgressEvent } from './index'; + +describe('Connection State Monitoring', () => { + let adapter: ObjectStackAdapter; + + beforeEach(() => { + adapter = new ObjectStackAdapter({ + baseUrl: 'http://localhost:3000', + autoReconnect: false, // Disable auto-reconnect for testing + }); + }); + + it('should initialize with disconnected state', () => { + expect(adapter.getConnectionState()).toBe('disconnected'); + expect(adapter.isConnected()).toBe(false); + }); + + it('should allow subscribing to connection state changes', () => { + const listener = vi.fn(); + const unsubscribe = adapter.onConnectionStateChange(listener); + + expect(typeof unsubscribe).toBe('function'); + expect(listener).not.toHaveBeenCalled(); + + // Cleanup + unsubscribe(); + }); + + it('should allow subscribing to batch progress events', () => { + const listener = vi.fn(); + const unsubscribe = adapter.onBatchProgress(listener); + + expect(typeof unsubscribe).toBe('function'); + expect(listener).not.toHaveBeenCalled(); + + // Cleanup + unsubscribe(); + }); + + it('should unsubscribe connection state listener', () => { + const listener = vi.fn(); + const unsubscribe = adapter.onConnectionStateChange(listener); + + // Unsubscribe + unsubscribe(); + + // Listener should not be called after unsubscribe + // (We can't easily test this without triggering a connection state change) + }); + + it('should unsubscribe batch progress listener', () => { + const listener = vi.fn(); + const unsubscribe = adapter.onBatchProgress(listener); + + // Unsubscribe + unsubscribe(); + + // Listener should not be called after unsubscribe + }); + + it('should support auto-reconnect configuration', () => { + const adapterWithReconnect = new ObjectStackAdapter({ + baseUrl: 'http://localhost:3000', + autoReconnect: true, + maxReconnectAttempts: 5, + reconnectDelay: 2000, + }); + + expect(adapterWithReconnect.getConnectionState()).toBe('disconnected'); + }); +}); + +describe('Batch Progress Events', () => { + let adapter: ObjectStackAdapter; + + beforeEach(() => { + adapter = new ObjectStackAdapter({ + baseUrl: 'http://localhost:3000', + }); + }); + + it('should allow subscribing to batch progress', () => { + const listener = vi.fn(); + const unsubscribe = adapter.onBatchProgress(listener); + + expect(typeof unsubscribe).toBe('function'); + + // Cleanup + unsubscribe(); + }); +}); diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts index 503cfd6bc..e1d9b0989 100644 --- a/packages/data-objectstack/src/index.ts +++ b/packages/data-objectstack/src/index.ts @@ -18,6 +18,41 @@ import { createErrorFromResponse, } from './errors'; +/** + * Connection state for monitoring + */ +export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error'; + +/** + * Connection state change event + */ +export interface ConnectionStateEvent { + state: ConnectionState; + timestamp: number; + error?: Error; +} + +/** + * Batch operation progress event + */ +export interface BatchProgressEvent { + operation: 'create' | 'update' | 'delete'; + total: number; + completed: number; + failed: number; + percentage: number; +} + +/** + * Event listener type for connection state changes + */ +export type ConnectionStateListener = (event: ConnectionStateEvent) => void; + +/** + * Event listener type for batch operation progress + */ +export type BatchProgressListener = (event: BatchProgressEvent) => void; + /** * ObjectStack Data Source Adapter * @@ -31,7 +66,14 @@ import { * * const dataSource = new ObjectStackAdapter({ * baseUrl: 'https://api.example.com', - * token: 'your-api-token' + * token: 'your-api-token', + * autoReconnect: true, + * maxReconnectAttempts: 5 + * }); + * + * // Monitor connection state + * dataSource.onConnectionStateChange((event) => { + * console.log('Connection state:', event.state); * }); * * const users = await dataSource.find('users', { @@ -44,6 +86,13 @@ export class ObjectStackAdapter implements DataSource { private client: ObjectStackClient; private connected: boolean = false; private metadataCache: MetadataCache; + private connectionState: ConnectionState = 'disconnected'; + private connectionStateListeners: ConnectionStateListener[] = []; + private batchProgressListeners: BatchProgressListener[] = []; + private autoReconnect: boolean; + private maxReconnectAttempts: number; + private reconnectDelay: number; + private reconnectAttempts: number = 0; constructor(config: { baseUrl: string; @@ -53,9 +102,15 @@ export class ObjectStackAdapter implements DataSource { maxSize?: number; ttl?: number; }; + autoReconnect?: boolean; + maxReconnectAttempts?: number; + reconnectDelay?: number; }) { this.client = new ObjectStackClient(config); this.metadataCache = new MetadataCache(config.cache); + this.autoReconnect = config.autoReconnect ?? true; + this.maxReconnectAttempts = config.maxReconnectAttempts ?? 3; + this.reconnectDelay = config.reconnectDelay ?? 1000; } /** @@ -64,20 +119,127 @@ export class ObjectStackAdapter implements DataSource { */ async connect(): Promise { if (!this.connected) { + this.setConnectionState('connecting'); + try { await this.client.connect(); this.connected = true; + this.reconnectAttempts = 0; + this.setConnectionState('connected'); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Failed to connect to ObjectStack server'; - throw new ConnectionError( + const connectionError = new ConnectionError( errorMessage, undefined, { originalError: error } ); + + this.setConnectionState('error', connectionError); + + // Attempt auto-reconnect if enabled + if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) { + await this.attemptReconnect(); + } else { + throw connectionError; + } } } } + /** + * Attempt to reconnect to the server with exponential backoff + */ + private async attemptReconnect(): Promise { + this.reconnectAttempts++; + this.setConnectionState('reconnecting'); + + // Exponential backoff: delay * 2^(attempts-1) + const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); + + await new Promise(resolve => setTimeout(resolve, delay)); + + this.connected = false; + await this.connect(); + } + + /** + * Get the current connection state + */ + getConnectionState(): ConnectionState { + return this.connectionState; + } + + /** + * Check if the adapter is currently connected + */ + isConnected(): boolean { + return this.connected && this.connectionState === 'connected'; + } + + /** + * Register a listener for connection state changes + */ + onConnectionStateChange(listener: ConnectionStateListener): () => void { + this.connectionStateListeners.push(listener); + + // Return unsubscribe function + return () => { + const index = this.connectionStateListeners.indexOf(listener); + if (index > -1) { + this.connectionStateListeners.splice(index, 1); + } + }; + } + + /** + * Register a listener for batch operation progress + */ + onBatchProgress(listener: BatchProgressListener): () => void { + this.batchProgressListeners.push(listener); + + // Return unsubscribe function + return () => { + const index = this.batchProgressListeners.indexOf(listener); + if (index > -1) { + this.batchProgressListeners.splice(index, 1); + } + }; + } + + /** + * Set connection state and notify listeners + */ + private setConnectionState(state: ConnectionState, error?: Error): void { + this.connectionState = state; + + const event: ConnectionStateEvent = { + state, + timestamp: Date.now(), + error, + }; + + this.connectionStateListeners.forEach(listener => { + try { + listener(event); + } catch (err) { + console.error('Error in connection state listener:', err); + } + }); + } + + /** + * Emit batch progress event to listeners + */ + private emitBatchProgress(event: BatchProgressEvent): void { + this.batchProgressListeners.forEach(listener => { + try { + listener(event); + } catch (err) { + console.error('Error in batch progress listener:', err); + } + }); + } + /** * Find multiple records with query parameters. * Converts OData-style params to ObjectStack query options. @@ -155,6 +317,7 @@ export class ObjectStackAdapter implements DataSource { /** * Bulk operations with optimized batch processing and error handling. + * Emits progress events for tracking operation status. * * @param resource - Resource name * @param operation - Operation type (create, update, delete) @@ -168,10 +331,29 @@ export class ObjectStackAdapter implements DataSource { return []; } + const total = data.length; + let completed = 0; + let failed = 0; + + const emitProgress = () => { + this.emitBatchProgress({ + operation, + total, + completed, + failed, + percentage: total > 0 ? (completed + failed) / total * 100 : 0, + }); + }; + try { switch (operation) { case 'create': - return await this.client.data.createMany(resource, data); + emitProgress(); + const created = await this.client.data.createMany(resource, data); + completed = created.length; + failed = total - completed; + emitProgress(); + return created; case 'delete': { const ids = data.map(item => (item as Record).id).filter(Boolean) as string[]; @@ -183,10 +365,17 @@ export class ObjectStackAdapter implements DataSource { error: `Missing ID for item at index ${index}` })); + failed = data.length; + emitProgress(); + throw new BulkOperationError('delete', 0, data.length, errors); } + emitProgress(); await this.client.data.deleteMany(resource, ids); + completed = ids.length; + failed = total - completed; + emitProgress(); return [] as T[]; } @@ -195,16 +384,21 @@ export class ObjectStackAdapter implements DataSource { // eslint-disable-next-line @typescript-eslint/no-explicit-any if (typeof (this.client.data as any).updateMany === 'function') { try { + emitProgress(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const updateMany = (this.client.data as any).updateMany; - return await updateMany(resource, data) as T[]; + const updated = await updateMany(resource, data) as T[]; + completed = updated.length; + failed = total - completed; + emitProgress(); + return updated; } catch { // If updateMany is not supported, fall back to individual updates // Silently fallback without logging } } - // Fallback: Process updates individually with detailed error tracking + // Fallback: Process updates individually with detailed error tracking and progress const results: T[] = []; const errors: Array<{ index: number; error: unknown }> = []; @@ -214,15 +408,21 @@ export class ObjectStackAdapter implements DataSource { if (!id) { errors.push({ index: i, error: 'Missing ID' }); + failed++; + emitProgress(); continue; } try { const result = await this.client.data.update(resource, String(id), item); results.push(result); + completed++; + emitProgress(); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); errors.push({ index: i, error: errorMessage }); + failed++; + emitProgress(); } } @@ -248,6 +448,9 @@ export class ObjectStackAdapter implements DataSource { ); } } catch (error: unknown) { + // Emit final progress with failure + emitProgress(); + // If it's already a BulkOperationError, re-throw it if (error instanceof BulkOperationError) { throw error; @@ -393,7 +596,9 @@ export class ObjectStackAdapter implements DataSource { * const dataSource = createObjectStackAdapter({ * baseUrl: process.env.API_URL, * token: process.env.API_TOKEN, - * cache: { maxSize: 100, ttl: 300000 } + * cache: { maxSize: 100, ttl: 300000 }, + * autoReconnect: true, + * maxReconnectAttempts: 5 * }); * ``` */ @@ -405,6 +610,9 @@ export function createObjectStackAdapter(config: { maxSize?: number; ttl?: number; }; + autoReconnect?: boolean; + maxReconnectAttempts?: number; + reconnectDelay?: number; }): DataSource { return new ObjectStackAdapter(config); }