diff --git a/nuxt4-app/app/stores/README.md b/nuxt4-app/app/stores/README.md deleted file mode 100644 index f3e5130..0000000 --- a/nuxt4-app/app/stores/README.md +++ /dev/null @@ -1,794 +0,0 @@ -# 📚 Sistema de Stores - Analítica Núcleo - -Este directorio contiene el sistema de gestión de estado de la aplicación usando **Pinia** y **localStorage** para caché persistente. - -## 📋 Índice - -- [Arquitectura General](#-arquitectura-general) -- [Stores Disponibles](#-stores-disponibles) -- [Metadata Store](#-metadata-store) -- [Table Data Factory](#-table-data-factory) -- [Flujo de Datos](#-flujo-de-datos) -- [Uso en Componentes](#-uso-en-componentes) -- [API Reference](#-api-reference) -- [Cache y Persistencia](#-cache-y-persistencia) -- [Ejemplos Avanzados](#-ejemplos-avanzados) - ---- - -## 🏗️ Arquitectura General - -El sistema de stores se compone de dos partes principales: - -``` -app/stores/ -├── metadata.ts # Store de metadatos de tablas -├── tableDataFactory.ts # Factory para crear stores de datos -└── README.md # Este archivo -``` - -### Flujo de Datos - -```mermaid -graph TB - A[App Startup] --> B[Metadata Store] - B --> C{¿Tiene cache?} - C -->|Sí| D[Carga desde localStorage] - C -->|No| E[Fetch desde API] - E --> F[/api/metadata] - F --> G[Guarda en localStorage] - D --> H[Metadata disponible] - G --> H - - H --> I[Plugin Auto-registro] - I --> J[Crea stores por tabla] - - K[Usuario selecciona tabla] --> L{¿Store existe?} - L -->|Sí| M{¿Tiene cache?} - L -->|No| N[Crea store] - N --> M - M -->|Sí| O[Carga desde cache] - M -->|No| P[Fetch desde API] - P --> Q[/api/data/:table] - Q --> R[Guarda en cache] - O --> S[Datos disponibles] - R --> S -``` - ---- - -## 🗂️ Stores Disponibles - -### 1. **Metadata Store** (`metadata.ts`) - -Store global que gestiona la información de todas las tablas disponibles. - -**Responsabilidades:** -- Mantener lista de tablas disponibles -- Información estructural (columnas, claves primarias, etc.) -- Estadísticas de cada tabla -- Cache persistente de metadatos - -### 2. **Table Data Stores** (Factory pattern) - -Stores dinámicos, uno por cada tabla de la base de datos. - -**Responsabilidades:** -- Almacenar registros de una tabla específica -- Gestionar estado de carga -- Cache persistente por tabla -- Operaciones de filtrado y búsqueda - ---- - -## 🔍 Metadata Store - -### Estructura de Datos - -```typescript -interface TableMetadata { - table: string // Nombre de la tabla - rowCount: number // Total de registros - primaryKey: string // Clave primaria - approxSizeBytes: number // Tamaño aproximado en bytes - columns: string[] // Lista de columnas - createdAtRange?: { - from: string // Fecha de creación más antigua - to: string // Fecha de creación más reciente - } - lastRefreshed?: string // Última actualización de metadatos - sampleRow?: Record // Registro de ejemplo -} -``` - -### Estado del Store - -```typescript -{ - metadata: TableMetadata[] // Array de metadatos por tabla - loading: boolean // Estado de carga - error: string | null // Mensaje de error - lastUpdated: string | null // Timestamp última actualización - initialized: boolean // Si el store está inicializado -} -``` - -### Getters Disponibles - -| Getter | Tipo | Descripción | -|--------|------|-------------| -| `allTables` | `TableMetadata[]` | Todas las tablas disponibles | -| `getTableMetadata(name)` | `TableMetadata \| undefined` | Metadatos de una tabla específica | -| `totalTables` | `number` | Cantidad total de tablas | -| `totalRecords` | `number` | Suma de registros de todas las tablas | -| `tableNames` | `string[]` | Lista de nombres de tablas | -| `hasMetadata` | `boolean` | Si hay metadatos cargados | -| `isLoading` | `boolean` | Si está cargando actualmente | -| `hasError` | `boolean` | Si hay un error | -| `formattedLastUpdated` | `string` | Fecha formateada de última actualización | -| `isStale` | `boolean` | Si los datos tienen > 5 minutos | - -### Actions Disponibles - -| Action | Parámetros | Descripción | -|--------|------------|-------------| -| `loadMetadata(force?)` | `force: boolean` | Carga lazy, solo si necesario | -| `refreshMetadata()` | - | Actualización forzada | -| `clearMetadata()` | - | Limpia cache y datos | -| `initialize()` | - | Inicializa el store (cache + fetch) | - -### Fuente de Datos - -**Endpoint:** `GET /api/metadata` - -**Respuesta:** -```json -[ - { - "table": "usuarios", - "rowCount": 1523, - "primaryKey": "id", - "approxSizeBytes": 245760, - "columns": ["id", "nombre", "email", "created_at"], - "createdAtRange": { - "from": "2024-01-01T00:00:00Z", - "to": "2025-01-20T15:30:00Z" - } - }, - // ... más tablas -] -``` - -### Uso Básico - -```typescript -import { useMetadataStore } from '~/stores/metadata' - -// En un componente -const metadataStore = useMetadataStore() - -// Inicializar (carga desde cache o API) -await metadataStore.initialize() - -// Acceder a los datos -console.log(metadataStore.allTables) -console.log(metadataStore.totalTables) - -// Obtener metadatos de una tabla específica -const usuariosMetadata = metadataStore.getTableMetadata('usuarios') - -// Refrescar metadatos -await metadataStore.refreshMetadata() - -// Verificar frescura de datos -if (metadataStore.isStale) { - console.log('Los metadatos tienen más de 5 minutos') -} -``` - ---- - -## 🏭 Table Data Factory - -Sistema factory que crea stores dinámicos para cada tabla. - -### Estructura de Datos - -```typescript -interface TableDataState> { - data: T[] // Registros cacheados - loading: boolean // Estado de carga - error: string | null // Mensaje de error - lastUpdated: string | null // Timestamp última actualización - initialized: boolean // Si el store está inicializado - limit: number // Límite de registros -} -``` - -### Getters Disponibles - -| Getter | Tipo | Descripción | -|--------|------|-------------| -| `allRecords` | `T[]` | Todos los registros cacheados | -| `hasData` | `boolean` | Si hay datos disponibles | -| `isLoading` | `boolean` | Si está cargando actualmente | -| `hasError` | `boolean` | Si hay un error | -| `recordCount` | `number` | Cantidad de registros | -| `formattedLastUpdated` | `string` | Fecha formateada | -| `isStale` | `boolean` | Si los datos tienen > 5 minutos | - -### Actions Disponibles - -| Action | Parámetros | Descripción | -|--------|------------|-------------| -| `loadData(force?)` | `force: boolean` | Carga lazy | -| `refreshData()` | - | Actualización forzada | -| `clearData()` | - | Limpia cache y datos | -| `initialize()` | - | Inicializa (cache + fetch) | -| `getRecord(id)` | `id: string \| number` | Obtiene un registro por ID | -| `filterRecords(predicate)` | `predicate: (record: T) => boolean` | Filtra registros | - -### Fuente de Datos - -**Endpoint:** `GET /api/data/:tableName?limit=100` - -**Respuesta:** -```json -{ - "table": "usuarios", - "count": 100, - "limit": 100, - "records": [ - { - "id": 1, - "nombre": "Juan Pérez", - "email": "juan@example.com", - "created_at": "2024-01-15T10:30:00Z" - }, - // ... más registros - ] -} -``` - -### Creación de Stores - -#### Método 1: Usando la Factory Directamente - -```typescript -import { createTableDataStore } from '~/stores/tableDataFactory' - -// Crear un store para la tabla "usuarios" -const useUsuariosStore = createTableDataStore('usuarios', 100) - -// Usar el store -const usuariosStore = useUsuariosStore() -await usuariosStore.initialize() - -console.log(usuariosStore.allRecords) -``` - -#### Método 2: Usando el Helper - -```typescript -import { useTableDataStore } from '~/stores/tableDataFactory' - -// Crear y usar el store en un solo paso -const usuariosStore = useTableDataStore('usuarios', 100) -await usuariosStore.initialize() -``` - -#### Método 3: Usando el Plugin (Recomendado) - -```typescript -// El plugin auto-registra todos los stores -const { $getTableStore } = useNuxtApp() - -// Obtener un store ya registrado -const usuariosStore = $getTableStore('usuarios') - -if (usuariosStore) { - await usuariosStore.initialize() - console.log(usuariosStore.allRecords) -} -``` - ---- - -## 🔄 Flujo de Datos - -### Inicialización de la App - -```typescript -// 1. App.vue o plugin se ejecuta -onMounted(async () => { - // 2. Metadata store se inicializa - const metadataStore = useMetadataStore() - await metadataStore.initialize() - - // 3. Plugin auto-registra stores (tableStores.client.ts) - // Se crean stores para cada tabla encontrada en metadataStore.allTables - - // 4. Los stores están listos pero NO han cargado datos aún - // Esto es lazy loading - solo cargan cuando se necesitan -}) -``` - -### Carga de Datos de una Tabla - -```typescript -// Usuario selecciona tabla "usuarios" -const selectTable = async (tableName: string) => { - // 1. Obtener o crear store - const store = $getTableStore(tableName) || useTableDataStore(tableName) - - // 2. Inicializar (intenta cache primero) - await store.initialize() - - // Flujo interno de initialize(): - // ├─ loadFromCache() - // │ └─ Lee localStorage['table-data-usuarios'] - // │ ├─ Si existe → usa datos cacheados - // │ └─ Si no existe → continúa - // └─ loadData() - // └─ fetchData() - // └─ $fetch('/api/data/usuarios?limit=100') - // └─ Guarda en localStorage - // └─ Actualiza store - - // 3. Datos disponibles - console.log(store.allRecords) -} -``` - -### Actualización Manual - -```typescript -// Usuario hace click en "Actualizar datos" -const refreshTable = async () => { - // 1. Ejecutar refreshData() - await store.refreshData() - - // Flujo interno de refreshData(): - // └─ fetchData() - // ├─ loading = true - // ├─ $fetch('/api/data/usuarios?limit=100') - // ├─ Actualiza store.data - // ├─ Actualiza lastUpdated - // ├─ Guarda en localStorage - // └─ loading = false -} -``` - ---- - -## 💻 Uso en Componentes - -### Ejemplo Completo: Explorador de Tablas - -```vue - - - -``` - -### Ejemplo: Búsqueda y Filtrado - -```typescript -import { useTableDataStore } from '~/stores/tableDataFactory' - -const usuariosStore = useTableDataStore('usuarios') -await usuariosStore.initialize() - -// Filtrar usuarios activos -const usuariosActivos = usuariosStore.filterRecords( - user => user.activo === true -) - -// Buscar usuario por ID -const usuario = usuariosStore.getRecord(123) - -// Búsqueda personalizada -const usuariosPorEmail = usuariosStore.filterRecords( - user => user.email.includes('@gmail.com') -) - -console.log(`Encontrados ${usuariosPorEmail.length} usuarios con Gmail`) -``` - ---- - -## 📖 API Reference - -### Metadata Store API - -```typescript -interface MetadataStore { - // State - metadata: TableMetadata[] - loading: boolean - error: string | null - lastUpdated: string | null - initialized: boolean - - // Getters - allTables: TableMetadata[] - getTableMetadata(name: string): TableMetadata | undefined - totalTables: number - totalRecords: number - tableNames: string[] - hasMetadata: boolean - isLoading: boolean - hasError: boolean - formattedLastUpdated: string - isStale: boolean - - // Actions - loadMetadata(force?: boolean): Promise - refreshMetadata(): Promise - clearMetadata(): void - initialize(): Promise -} -``` - -### Table Data Store API - -```typescript -interface TableDataStore { - // State - data: T[] - loading: boolean - error: string | null - lastUpdated: string | null - initialized: boolean - limit: number - - // Getters - allRecords: T[] - hasData: boolean - isLoading: boolean - hasError: boolean - recordCount: number - formattedLastUpdated: string - isStale: boolean - - // Actions - loadData(force?: boolean): Promise - refreshData(): Promise - clearData(): void - initialize(): Promise - getRecord(id: string | number): T | undefined - filterRecords(predicate: (record: T) => boolean): T[] -} -``` - ---- - -## 💾 Cache y Persistencia - -### Estrategia de Cache - -El sistema usa **localStorage** para persistir datos: - -```typescript -// Estructura de cache en localStorage -{ - // Metadatos - "metadata-cache": { - metadata: TableMetadata[], - lastUpdated: "2025-01-20T10:30:00Z" - }, - - // Datos de tabla usuarios - "table-data-usuarios": { - data: [...registros], - lastUpdated: "2025-01-20T10:35:00Z", - limit: 100 - }, - - // Datos de tabla productos - "table-data-productos": { - data: [...registros], - lastUpdated: "2025-01-20T10:40:00Z", - limit: 100 - } -} -``` - -### Políticas de Cache - -1. **Freshness Check**: Los datos se consideran "stale" después de 5 minutos -2. **Lazy Loading**: Los stores solo cargan cuando se accede a ellos -3. **Cache-First**: Siempre intenta cargar desde cache primero -4. **Auto-Refresh**: El usuario controla manualmente cuándo refrescar -5. **Offline Support**: Datos disponibles incluso sin conexión - -### Limpieza de Cache - -```typescript -// Limpiar cache de metadatos -const metadataStore = useMetadataStore() -metadataStore.clearMetadata() - -// Limpiar cache de una tabla -const usuariosStore = useTableDataStore('usuarios') -usuariosStore.clearData() - -// Limpiar TODO el cache (localStorage) -if (process.client) { - localStorage.clear() -} -``` - ---- - -## 🎯 Ejemplos Avanzados - -### Sincronización Automática - -```typescript -// Auto-refrescar cada 5 minutos si los datos están stale -const autoRefresh = () => { - const store = useTableDataStore('usuarios') - - setInterval(async () => { - if (store.isStale && !store.isLoading) { - console.log('Refrescando datos automáticamente...') - await store.refreshData() - } - }, 5 * 60 * 1000) // 5 minutos -} -``` - -### Validación de Datos - -```typescript -// Verificar integridad de datos cacheados -const validateCache = async (tableName: string) => { - const store = useTableDataStore(tableName) - const metadata = metadataStore.getTableMetadata(tableName) - - if (!metadata) return false - - // Verificar que el número de registros sea razonable - if (store.recordCount > metadata.rowCount) { - console.warn('Cache corrupto, limpiando...') - store.clearData() - await store.refreshData() - return false - } - - return true -} -``` - -### Composable Personalizado - -```typescript -// composables/useTableData.ts -export function useTableData(tableName: string) { - const store = useTableDataStore(tableName) - const initialized = ref(false) - - onMounted(async () => { - await store.initialize() - initialized.value = true - }) - - // Auto-refrescar si stale - watch(() => store.isStale, async (isStale) => { - if (isStale && initialized.value) { - await store.refreshData() - } - }) - - return { - data: computed(() => store.allRecords), - loading: computed(() => store.isLoading), - error: computed(() => store.error), - refresh: () => store.refreshData() - } -} -``` - -### TypeScript Tipado - -```typescript -// Definir tipos para tus tablas -interface Usuario { - id: number - nombre: string - email: string - activo: boolean - created_at: string -} - -interface Producto { - id: number - nombre: string - precio: number - stock: number -} - -// Usar stores tipados -const usuariosStore = useTableDataStore('usuarios') -const productosStore = useTableDataStore('productos') - -// TypeScript ahora conoce la estructura -usuariosStore.allRecords.forEach(user => { - console.log(user.email) // ✅ TypeScript sabe que existe - // console.log(user.foo) // ❌ Error: Property 'foo' does not exist -}) -``` - ---- - -## 🐛 Debugging - -### Inspeccionar Estado - -```typescript -// En DevTools Console -import { useMetadataStore } from '~/stores/metadata' -import { useTableDataStore } from '~/stores/tableDataFactory' - -const metadataStore = useMetadataStore() -const usuariosStore = useTableDataStore('usuarios') - -// Ver estado completo -console.log('Metadata:', metadataStore.$state) -console.log('Usuarios:', usuariosStore.$state) - -// Ver cache -console.log('Cache:', { - metadata: localStorage.getItem('metadata-cache'), - usuarios: localStorage.getItem('table-data-usuarios') -}) -``` - -### Logs Detallados - -Descomenta los `console.log` en los stores para ver el flujo completo: - -```typescript -// En tableDataFactory.ts línea 144 -console.log(`[${tableName}] Fetching data...`) - -// En tableDataFactory.ts línea 158 -console.log(`[${tableName}] Data fetched: ${this.recordCount} records`) - -// En tableDataFactory.ts línea 164 -console.log(`[${tableName}] Saved to cache`) -``` - ---- - -## 📝 Notas Importantes - -### ⚠️ Limitaciones - -1. **Límite de localStorage**: ~5-10MB por dominio - - Solución: Implementar estrategia LRU si se excede - -2. **Límite de registros**: Por defecto 100 registros por tabla - - Modificable en la factory: `useTableDataStore('tabla', 500)` - -3. **Sin paginación**: Todos los registros se cargan de una vez - - Para tablas grandes, considerar implementar paginación - -4. **Sin sincronización real-time**: Los datos se actualizan manualmente - - Para real-time, considerar WebSockets - -### 🚀 Performance Tips - -1. **Lazy Loading**: Los stores no cargan datos hasta que se necesitan -2. **Cache-First**: Usa cache para respuesta instantánea -3. **Selectores Computados**: Usa `computed()` para filtros complejos -4. **Debounce**: Para búsquedas, usa debounce para evitar renders excesivos - -### 🔒 Seguridad - -- Los datos en localStorage NO están encriptados -- No almacenes información sensible (contraseñas, tokens) -- El API backend debe validar permisos y autenticación - ---- - -## 📚 Referencias - -- [Pinia Documentation](https://pinia.vuejs.org/) -- [Nuxt 4 Documentation](https://nuxt.com/) -- [LocalStorage API](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) - ---- - -**Última actualización:** 2025-01-20 -**Versión:** 1.0.0 -**Autor:** Equipo Núcleo \ No newline at end of file diff --git a/nuxt4-app/app/stores/metadata.ts b/nuxt4-app/app/stores/metadata.ts deleted file mode 100644 index a55cff7..0000000 --- a/nuxt4-app/app/stores/metadata.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { defineStore } from 'pinia' -import { useRequestFetch } from '#imports' - -export interface TableMetadata { - table: string - rowCount: number - primaryKey: string - approxSizeBytes: number - columns: string[] - createdAtRange?: { - from: string - to: string - } - lastRefreshed?: string - sampleRow?: Record -} - -export interface MetadataState { - metadata: TableMetadata[] - loading: boolean - error: string | null - lastUpdated: string | null - initialized: boolean -} - -export const useMetadataStore = defineStore('metadata', { - state: (): MetadataState => ({ - metadata: [], - loading: false, - error: null, - lastUpdated: null, - initialized: false - }), - - getters: { - /** - * Get metadata for all tables - */ - allTables: (state): TableMetadata[] => state.metadata, - - /** - * Get metadata for a specific table - */ - getTableMetadata: (state) => (tableName: string): TableMetadata | undefined => { - return state.metadata.find(meta => meta.table === tableName) - }, - - /** - * Get total number of tables - */ - totalTables: (state): number => state.metadata.length, - - /** - * Get total number of records across all tables - */ - totalRecords: (state): number => { - return state.metadata.reduce((sum, meta) => sum + (meta.rowCount || 0), 0) - }, - - /** - * Get list of all table names - */ - tableNames: (state): string[] => { - return state.metadata.map(meta => meta.table) - }, - - /** - * Check if metadata is available - */ - hasMetadata: (state): boolean => state.metadata.length > 0, - - /** - * Check if metadata is currently loading - */ - isLoading: (state): boolean => state.loading, - - /** - * Check if there's an error - */ - hasError: (state): boolean => !!state.error, - - /** - * Get formatted last updated time - */ - formattedLastUpdated: (state): string => { - if (!state.lastUpdated) return 'Nunca' - - try { - return new Date(state.lastUpdated).toLocaleString('es-ES', { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }) - } catch { - return 'Fecha inválida' - } - }, - - /** - * Check if metadata is stale (older than 5 minutes) - */ - isStale: (state): boolean => { - if (!state.lastUpdated) return true - - const lastUpdate = new Date(state.lastUpdated) - const now = new Date() - const fiveMinutes = 5 * 60 * 1000 - - return (now.getTime() - lastUpdate.getTime()) > fiveMinutes - } - }, - - actions: { - /** - * Load metadata lazily (only if not already loaded) - */ - async loadMetadata(force = false): Promise { - // Don't load if already loading - if (this.loading) return - - // Don't load if already initialized and not forced - if (this.initialized && !force && !this.isStale) return - - await this.fetchMetadata() - }, - - /** - * Force refresh metadata - */ - async refreshMetadata(): Promise { - await this.fetchMetadata() - }, - - /** - * Internal method to fetch metadata from API - */ - async fetchMetadata(): Promise { - this.loading = true - this.error = null - - try { - const requestFetch = useRequestFetch() - const response = await requestFetch('/api/metadata') - - if (Array.isArray(response)) { - this.metadata = response.map((meta: any) => ({ - ...meta, - lastRefreshed: meta.lastRefreshed || new Date().toISOString() - })) - this.lastUpdated = new Date().toISOString() - this.initialized = true - - // Persist to localStorage for offline access - if (process.client) { - try { - localStorage.setItem('metadata-cache', JSON.stringify({ - metadata: this.metadata, - lastUpdated: this.lastUpdated - })) - } catch (error) { - console.warn('Failed to persist metadata to localStorage:', error) - } - } - } else { - throw new Error('Invalid metadata response format') - } - } catch (error: any) { - this.error = this.extractErrorMessage(error) - console.error('Error fetching metadata:', error) - - // Try to load from cache if available - if (process.client && !this.hasMetadata) { - this.loadFromCache() - } - } finally { - this.loading = false - } - }, - - /** - * Load metadata from localStorage cache - */ - loadFromCache(): void { - if (!process.client) return - - try { - const cached = localStorage.getItem('metadata-cache') - if (cached) { - const parsedCache = JSON.parse(cached) - this.metadata = parsedCache.metadata || [] - this.lastUpdated = parsedCache.lastUpdated || null - this.initialized = true - } - } catch (error) { - console.warn('Failed to load metadata from cache:', error) - } - }, - - /** - * Clear all metadata - */ - clearMetadata(): void { - this.metadata = [] - this.error = null - this.lastUpdated = null - this.initialized = false - - if (process.client) { - try { - localStorage.removeItem('metadata-cache') - } catch (error) { - console.warn('Failed to clear metadata cache:', error) - } - } - }, - - /** - * Extract error message from various error types - */ - extractErrorMessage(error: unknown): string { - if (error && typeof error === 'object' && 'statusMessage' in error) { - return String((error as { statusMessage: string }).statusMessage) - } - - if (error instanceof Error) { - return error.message - } - - return 'Error inesperado al cargar metadatos' - }, - - /** - * Initialize store (called on app startup) - */ - async initialize(): Promise { - // Load from cache first for immediate availability - this.loadFromCache() - - // Then try to fetch fresh data - await this.loadMetadata() - } - }, - - // Enable persistence for better UX - persist: process.client ? { - key: 'metadata-store', - storage: localStorage, - pick: ['metadata', 'lastUpdated', 'initialized'] - } : false -}) \ No newline at end of file diff --git a/nuxt4-app/app/stores/tableDataFactory.ts b/nuxt4-app/app/stores/tableDataFactory.ts deleted file mode 100644 index 3dfed50..0000000 --- a/nuxt4-app/app/stores/tableDataFactory.ts +++ /dev/null @@ -1,518 +0,0 @@ -import { defineStore } from 'pinia' -import { tableDataStorage } from '~/utils/storage' - -export interface TableDataState> { - data: T[] - loading: boolean - error: string | null - lastUpdated: string | null - initialized: boolean - limit: number -} - -export interface TableDataGetters> { - allRecords: (state: TableDataState) => T[] - hasData: (state: TableDataState) => boolean - isLoading: (state: TableDataState) => boolean - hasError: (state: TableDataState) => boolean - recordCount: (state: TableDataState) => number - formattedLastUpdated: (state: TableDataState) => string - isStale: (state: TableDataState) => boolean -} - -export interface TableDataActions> { - loadData(force?: boolean): Promise - refreshData(): Promise - fetchData(): Promise - clearData(): Promise - loadFromCache(): Promise - extractErrorMessage(error: unknown): string - initialize(): Promise - getRecord(id: string | number): T | undefined - filterRecords(predicate: (record: T) => boolean): T[] - loadAllDataInBatches(onProgress?: (progress: number) => void): Promise - loadLatestDataInBatches(onProgress?: (progress: number) => void): Promise -} - -/** - * Factory function to create a Pinia store for table data - * @param tableName - The name of the table - * @param defaultLimit - Default limit for data fetching (default: 100) - */ -export function createTableDataStore>( - tableName: string, - defaultLimit: number = 100 -) { - const storeId = `table-${tableName}` - const cacheKey = `table-data-${tableName}` - - return defineStore(storeId, { - state: (): TableDataState => ({ - data: [], - loading: false, - error: null, - lastUpdated: null, - initialized: false, - limit: defaultLimit - }), - - getters: { - /** - * Get all records - */ - allRecords(): T[] { - return this.data as T[] - }, - - /** - * Check if data is available - */ - hasData(): boolean { - return this.data.length > 0 - }, - - /** - * Check if data is currently loading - */ - isLoading(): boolean { - return this.loading - }, - - /** - * Check if there's an error - */ - hasError(): boolean { - return !!this.error - }, - - /** - * Get total number of records - */ - recordCount(): number { - return this.data.length - }, - - /** - * Get formatted last updated time - */ - formattedLastUpdated(): string { - if (!this.lastUpdated) return 'Nunca' - - try { - return new Date(this.lastUpdated).toLocaleString('es-ES', { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }) - } catch { - return 'Fecha inválida' - } - }, - - /** - * Check if data is stale (older than 5 minutes) - */ - isStale(): boolean { - if (!this.lastUpdated) return true - - const lastUpdate = new Date(this.lastUpdated) - const now = new Date() - const fiveMinutes = 5 * 60 * 1000 - - return (now.getTime() - lastUpdate.getTime()) > fiveMinutes - } - }, - - actions: { - /** - * Load data lazily (only if not already loaded) - */ - async loadData(force = false): Promise { - // Don't load if already loading - if (this.loading) return - - // Don't load if already initialized and not forced - if (this.initialized && !force && !this.isStale) return - - await this.fetchData() - }, - - /** - * Force refresh data - */ - async refreshData(): Promise { - await this.fetchData() - }, - - /** - * Internal method to fetch data from API - */ - async fetchData(): Promise { - this.loading = true - this.error = null - - try { - const response = await $fetch(`/api/data/${tableName}`, { - query: { limit: String(this.limit) } - }) - - if (response && typeof response === 'object' && 'records' in response) { - const dataset = response as { records?: any[] } - this.data = Array.isArray(dataset.records) ? dataset.records : [] - } else if (Array.isArray(response)) { - this.data = response - } else { - this.data = [] - } - - this.lastUpdated = new Date().toISOString() - this.initialized = true - - // Persist to IndexedDB for offline access - if (process.client) { - try { - await tableDataStorage.setItem(cacheKey, { - data: this.data, - lastUpdated: this.lastUpdated, - limit: this.limit - }) - } catch (error) { - console.warn(`Failed to persist ${tableName} data to IndexedDB:`, error) - } - } - } catch (error: any) { - this.error = this.extractErrorMessage(error) - console.error(`Error fetching ${tableName} data:`, error) - - // Try to load from cache if available - if (process.client && !this.hasData) { - this.loadFromCache() - } - } finally { - this.loading = false - } - }, - - /** - * Load data from IndexedDB cache - */ - async loadFromCache(): Promise { - if (!process.client) { - console.log(`[${tableName}] loadFromCache: Not on client, skipping`) - return - } - - try { - console.log(`[${tableName}] loadFromCache: Attempting to load from key: ${cacheKey}`) - const parsedCache = await tableDataStorage.getItem(cacheKey) - if (parsedCache) { - console.log(`[${tableName}] loadFromCache: Found ${parsedCache.data?.length || 0} records in cache`) - this.data = parsedCache.data || [] - this.lastUpdated = parsedCache.lastUpdated || null - this.limit = parsedCache.limit || defaultLimit - this.initialized = true - console.log(`[${tableName}] loadFromCache: Successfully loaded, data.length: ${this.data.length}`) - } else { - console.log(`[${tableName}] loadFromCache: No cached data found`) - } - } catch (error) { - console.error(`[${tableName}] loadFromCache: Failed to load data from cache:`, error) - } - }, - - /** - * Clear all data - */ - async clearData(): Promise { - this.data = [] - this.error = null - this.lastUpdated = null - this.initialized = false - - if (process.client) { - try { - await tableDataStorage.removeItem(cacheKey) - } catch (error) { - console.warn(`Failed to clear ${tableName} data cache:`, error) - } - } - }, - - /** - * Extract error message from various error types - */ - extractErrorMessage(error: unknown): string { - if (error && typeof error === 'object' && 'statusMessage' in error) { - return String((error as { statusMessage: string }).statusMessage) - } - - if (error instanceof Error) { - return error.message - } - - return `Error inesperado al cargar datos de ${tableName}` - }, - - /** - * Initialize store (called on app startup) - */ - async initialize(): Promise { - // Load from cache first for immediate availability - await this.loadFromCache() - - // Then try to fetch fresh data - await this.loadData() - }, - - /** - * Get a specific record by ID - */ - getRecord(id: string | number): T | undefined { - return this.data.find((record: any) => { - return record.id === id || String(record.id) === String(id) - }) - }, - - /** - * Filter records by a predicate function - */ - filterRecords(predicate: (record: T) => boolean): T[] { - return (this.data as T[]).filter(predicate) - }, - - /** - * Load all data in batches with progress callback - */ - async loadAllDataInBatches(onProgress?: (progress: number) => void): Promise { - console.log(`[${tableName}] loadAllDataInBatches: Starting...`) - this.loading = true - this.error = null - - try { - // Clear existing data - console.log(`[${tableName}] loadAllDataInBatches: Clearing ${this.data.length} existing records`) - this.data.splice(0, this.data.length) - console.log(`[${tableName}] loadAllDataInBatches: Data cleared, length now: ${this.data.length}`) - - let offset = 0 - const limit = 500 - let hasMore = true - let totalFetched = 0 - let estimatedTotal = 0 - - while (hasMore) { - console.log(`[${tableName}] loadAllDataInBatches: Fetching batch at offset ${offset}`) - const response = await $fetch(`/api/data/${tableName}`, { - params: { limit, offset } - }) - - if (!response) { - console.log(`[${tableName}] loadAllDataInBatches: No response, stopping`) - hasMore = false - break - } - - // Get total count from first response - if (offset === 0 && response.count) { - estimatedTotal = response.count - console.log(`[${tableName}] loadAllDataInBatches: Estimated total: ${estimatedTotal}`) - } - - if (response.records && response.records.length > 0) { - console.log(`[${tableName}] loadAllDataInBatches: Got ${response.records.length} records`) - this.data.push(...response.records) - totalFetched += response.records.length - console.log(`[${tableName}] loadAllDataInBatches: Total fetched so far: ${totalFetched}, data.length: ${this.data.length}`) - - // Update progress - if (onProgress) { - if (estimatedTotal > 0) { - onProgress(Math.min(95, (totalFetched / estimatedTotal) * 100)) - } else { - onProgress(Math.min(95, (totalFetched / 10000) * 100)) - } - } - - // Check if we got fewer records than requested (means we're at the end) - hasMore = response.records.length === limit - offset += limit - } else { - console.log(`[${tableName}] loadAllDataInBatches: No records in response, stopping`) - hasMore = false - } - } - - this.lastUpdated = new Date().toISOString() - this.initialized = true - - console.log(`[${tableName}] loadAllDataInBatches: Finished loading ${this.data.length} records`) - - // Persist to IndexedDB - if (process.client) { - try { - console.log(`[${tableName}] loadAllDataInBatches: Persisting to IndexedDB with key: ${cacheKey}`) - const dataToSave = { - data: this.data, - lastUpdated: this.lastUpdated, - limit: this.limit - } - console.log(`[${tableName}] loadAllDataInBatches: Saving ${dataToSave.data.length} records`) - await tableDataStorage.setItem(cacheKey, dataToSave) - console.log(`[${tableName}] loadAllDataInBatches: Successfully saved to IndexedDB`) - - // Verify it was saved - const saved = await tableDataStorage.getItem(cacheKey) - if (saved) { - console.log(`[${tableName}] loadAllDataInBatches: Verification - IndexedDB contains ${saved.data?.length || 0} records`) - } - } catch (error) { - console.error(`[${tableName}] loadAllDataInBatches: Failed to persist data to IndexedDB:`, error) - } - } else { - console.log(`[${tableName}] loadAllDataInBatches: Not on client, skipping IndexedDB`) - } - - if (onProgress) { - onProgress(100) - } - } catch (error: any) { - this.error = this.extractErrorMessage(error) - console.error(`[${tableName}] loadAllDataInBatches: Error:`, error) - throw error - } finally { - this.loading = false - console.log(`[${tableName}] loadAllDataInBatches: Completed`) - } - }, - - /** - * Load only latest data (incremental) in batches with progress callback - */ - async loadLatestDataInBatches(onProgress?: (progress: number) => void): Promise { - console.log(`[${tableName}] loadLatestDataInBatches: Starting...`) - this.loading = true - this.error = null - - try { - // Find the most recent created_at in memory - let lastCreatedAt: string | null = null - if (this.data.length > 0) { - const sortedRecords = [...(this.data as any[])].sort((a, b) => { - const dateA = new Date(a.created_at || 0).getTime() - const dateB = new Date(b.created_at || 0).getTime() - return dateB - dateA - }) - lastCreatedAt = sortedRecords[0]?.created_at || null - console.log(`[${tableName}] loadLatestDataInBatches: Most recent created_at in memory: ${lastCreatedAt}`) - } else { - console.log(`[${tableName}] loadLatestDataInBatches: No data in memory, will fetch all`) - } - - let offset = 0 - const limit = 500 - let hasMore = true - let newRecordsCount = 0 - - while (hasMore) { - console.log(`[${tableName}] loadLatestDataInBatches: Fetching batch at offset ${offset}`) - const response = await $fetch(`/api/data/${tableName}`, { - params: { - limit, - offset, - orderBy: 'created_at', - orderDirection: 'desc' - } - }) - - if (!response || !response.records || response.records.length === 0) { - console.log(`[${tableName}] loadLatestDataInBatches: No response or records, stopping`) - hasMore = false - break - } - - console.log(`[${tableName}] loadLatestDataInBatches: Got ${response.records.length} records`) - - // Filter only records newer than what we have - const newRecords = lastCreatedAt - ? response.records.filter((r: any) => r.created_at > lastCreatedAt) - : response.records - - console.log(`[${tableName}] loadLatestDataInBatches: ${newRecords.length} new records after filtering`) - - if (newRecords.length > 0) { - this.data.unshift(...newRecords) - newRecordsCount += newRecords.length - console.log(`[${tableName}] loadLatestDataInBatches: Total new records: ${newRecordsCount}, data.length: ${this.data.length}`) - } - - // Update progress - if (onProgress) { - onProgress(Math.min(95, (newRecordsCount / 500) * 100)) - } - - // Stop if we found records older than our last one - if (lastCreatedAt && response.records.some((r: any) => r.created_at <= lastCreatedAt)) { - console.log(`[${tableName}] loadLatestDataInBatches: Found older records, stopping`) - hasMore = false - } else { - hasMore = response.records.length === limit - offset += limit - } - } - - this.lastUpdated = new Date().toISOString() - console.log(`[${tableName}] loadLatestDataInBatches: Added ${newRecordsCount} new records, total: ${this.data.length}`) - - // Persist to IndexedDB - if (process.client) { - try { - console.log(`[${tableName}] loadLatestDataInBatches: Persisting to IndexedDB with key: ${cacheKey}`) - const dataToSave = { - data: this.data, - lastUpdated: this.lastUpdated, - limit: this.limit - } - console.log(`[${tableName}] loadLatestDataInBatches: Saving ${dataToSave.data.length} records`) - await tableDataStorage.setItem(cacheKey, dataToSave) - console.log(`[${tableName}] loadLatestDataInBatches: Successfully saved to IndexedDB`) - - // Verify it was saved - const saved = await tableDataStorage.getItem(cacheKey) - if (saved) { - console.log(`[${tableName}] loadLatestDataInBatches: Verification - IndexedDB contains ${saved.data?.length || 0} records`) - } - } catch (error) { - console.error(`[${tableName}] loadLatestDataInBatches: Failed to persist data to IndexedDB:`, error) - } - } else { - console.log(`[${tableName}] loadLatestDataInBatches: Not on client, skipping IndexedDB`) - } - - if (onProgress) { - onProgress(100) - } - } catch (error: any) { - this.error = this.extractErrorMessage(error) - console.error(`[${tableName}] loadLatestDataInBatches: Error:`, error) - throw error - } finally { - this.loading = false - console.log(`[${tableName}] loadLatestDataInBatches: Completed`) - } - } - } - }) -} - -/** - * Helper to get or create a table data store - */ -export function useTableDataStore>( - tableName: string, - limit?: number -) { - const store = createTableDataStore(tableName, limit) - return store() -} \ No newline at end of file diff --git a/nuxt4-app/app/utils/storage.ts b/nuxt4-app/app/utils/storage.ts deleted file mode 100644 index 2093e37..0000000 --- a/nuxt4-app/app/utils/storage.ts +++ /dev/null @@ -1,305 +0,0 @@ -/** - * Storage utility that uses IndexedDB for table data and localStorage for config/secrets - * - * Strategy: - * - IndexedDB: ALL table/datasource data (can store hundreds of MB) - * - localStorage: Only for secrets, variables, configurations (small data) - */ - -const DB_NAME = 'analitica-nucleo-db' -const DB_VERSION = 1 -const STORE_NAME = 'table-data' - -let dbInstance: IDBDatabase | null = null - -/** - * Initialize IndexedDB - */ -async function initDB(): Promise { - if (dbInstance) return dbInstance - - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION) - - request.onerror = () => { - console.error('[Storage] IndexedDB error:', request.error) - reject(request.error) - } - - request.onsuccess = () => { - dbInstance = request.result - console.log('[Storage] IndexedDB initialized successfully') - resolve(dbInstance) - } - - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result - - // Create object store if it doesn't exist - if (!db.objectStoreNames.contains(STORE_NAME)) { - db.createObjectStore(STORE_NAME, { keyPath: 'key' }) - console.log('[Storage] Created object store:', STORE_NAME) - } - } - }) -} - -/** - * Deep clone to remove reactivity and make data serializable for IndexedDB - */ -function toPlainObject(obj: any): any { - // Use JSON parse/stringify to deep clone and remove all reactivity/proxies - try { - return JSON.parse(JSON.stringify(obj)) - } catch (error) { - console.error('[Storage] Failed to convert to plain object:', error) - return obj - } -} - -/** - * Save data to IndexedDB - */ -async function saveToIndexedDB(key: string, data: any): Promise { - try { - const db = await initDB() - - // Convert reactive objects to plain objects - const plainData = toPlainObject(data) - - return new Promise((resolve, reject) => { - const transaction = db.transaction([STORE_NAME], 'readwrite') - const store = transaction.objectStore(STORE_NAME) - - const request = store.put({ - key, - value: plainData, - timestamp: Date.now() - }) - - request.onsuccess = () => { - console.log(`[Storage] Saved to IndexedDB: ${key}`) - resolve(true) - } - - request.onerror = () => { - console.error(`[Storage] Failed to save to IndexedDB: ${key}`, request.error) - reject(request.error) - } - }) - } catch (error) { - console.error('[Storage] IndexedDB save error:', error) - return false - } -} - -/** - * Load data from IndexedDB - */ -async function loadFromIndexedDB(key: string): Promise { - try { - const db = await initDB() - - return new Promise((resolve, reject) => { - const transaction = db.transaction([STORE_NAME], 'readonly') - const store = transaction.objectStore(STORE_NAME) - const request = store.get(key) - - request.onsuccess = () => { - if (request.result) { - console.log(`[Storage] Loaded from IndexedDB: ${key}`) - resolve(request.result.value) - } else { - console.log(`[Storage] No data found in IndexedDB for: ${key}`) - resolve(null) - } - } - - request.onerror = () => { - console.error(`[Storage] Failed to load from IndexedDB: ${key}`, request.error) - reject(request.error) - } - }) - } catch (error) { - console.error('[Storage] IndexedDB load error:', error) - return null - } -} - -/** - * Remove data from IndexedDB - */ -async function removeFromIndexedDB(key: string): Promise { - try { - const db = await initDB() - - return new Promise((resolve, reject) => { - const transaction = db.transaction([STORE_NAME], 'readwrite') - const store = transaction.objectStore(STORE_NAME) - const request = store.delete(key) - - request.onsuccess = () => { - console.log(`[Storage] Removed from IndexedDB: ${key}`) - resolve(true) - } - - request.onerror = () => { - console.error(`[Storage] Failed to remove from IndexedDB: ${key}`, request.error) - reject(request.error) - } - }) - } catch (error) { - console.error('[Storage] IndexedDB remove error:', error) - return false - } -} - -/** - * Storage interface for table/datasource data (uses IndexedDB exclusively) - */ -export const tableDataStorage = { - /** - * Save table data to IndexedDB (for large datasets) - */ - async setItem(key: string, data: any): Promise { - if (!process.client) { - console.log('[TableDataStorage] Not on client, skipping storage') - return - } - - const dataString = JSON.stringify(data) - const dataSizeKB = new Blob([dataString]).size / 1024 - console.log(`[TableDataStorage] Saving ${dataSizeKB.toFixed(2)} KB to IndexedDB for key: ${key}`) - - try { - const success = await saveToIndexedDB(key, data) - if (success) { - console.log(`[TableDataStorage] ✓ Successfully saved ${dataSizeKB.toFixed(2)} KB to IndexedDB`) - return - } else { - throw new Error('Failed to save to IndexedDB') - } - } catch (error) { - console.error('[TableDataStorage] ✗ Failed to save to IndexedDB:', error) - throw new Error(`No se pudo guardar la tabla: ${error}`) - } - }, - - /** - * Load table data from IndexedDB - */ - async getItem(key: string): Promise { - if (!process.client) { - console.log('[TableDataStorage] Not on client, skipping storage') - return null - } - - console.log(`[TableDataStorage] Loading from IndexedDB: ${key}`) - - try { - const data = await loadFromIndexedDB(key) - if (data !== null) { - const dataString = JSON.stringify(data) - const dataSizeKB = new Blob([dataString]).size / 1024 - console.log(`[TableDataStorage] ✓ Loaded ${dataSizeKB.toFixed(2)} KB from IndexedDB`) - return data - } else { - console.log(`[TableDataStorage] No data found in IndexedDB for: ${key}`) - return null - } - } catch (error) { - console.error('[TableDataStorage] ✗ Failed to load from IndexedDB:', error) - return null - } - }, - - /** - * Remove table data from IndexedDB - */ - async removeItem(key: string): Promise { - if (!process.client) return - - console.log(`[TableDataStorage] Removing from IndexedDB: ${key}`) - - try { - await removeFromIndexedDB(key) - console.log(`[TableDataStorage] ✓ Removed from IndexedDB: ${key}`) - } catch (error) { - console.error('[TableDataStorage] ✗ Failed to remove from IndexedDB:', error) - } - } -} - -/** - * Storage interface for config/secrets (uses localStorage exclusively) - */ -export const configStorage = { - /** - * Save config/secrets to localStorage (for small data only) - */ - setItem(key: string, data: any): void { - if (!process.client) { - console.log('[ConfigStorage] Not on client, skipping storage') - return - } - - const dataString = JSON.stringify(data) - const dataSizeKB = new Blob([dataString]).size / 1024 - console.log(`[ConfigStorage] Saving ${dataSizeKB.toFixed(2)} KB to localStorage for key: ${key}`) - - try { - localStorage.setItem(key, dataString) - console.log(`[ConfigStorage] ✓ Saved to localStorage`) - } catch (error: any) { - if (error.name === 'QuotaExceededError') { - console.error('[ConfigStorage] ✗ localStorage quota exceeded') - throw new Error('No hay espacio en localStorage para guardar la configuración') - } - throw error - } - }, - - /** - * Load config/secrets from localStorage - */ - getItem(key: string): any | null { - if (!process.client) { - console.log('[ConfigStorage] Not on client, skipping storage') - return null - } - - console.log(`[ConfigStorage] Loading from localStorage: ${key}`) - - try { - const item = localStorage.getItem(key) - if (item) { - const data = JSON.parse(item) - const dataSizeKB = new Blob([item]).size / 1024 - console.log(`[ConfigStorage] ✓ Loaded ${dataSizeKB.toFixed(2)} KB from localStorage`) - return data - } else { - console.log(`[ConfigStorage] No data found in localStorage for: ${key}`) - return null - } - } catch (error) { - console.error('[ConfigStorage] ✗ Failed to load from localStorage:', error) - return null - } - }, - - /** - * Remove config/secrets from localStorage - */ - removeItem(key: string): void { - if (!process.client) return - - console.log(`[ConfigStorage] Removing from localStorage: ${key}`) - - try { - localStorage.removeItem(key) - console.log(`[ConfigStorage] ✓ Removed from localStorage: ${key}`) - } catch (error) { - console.error('[ConfigStorage] ✗ Failed to remove from localStorage:', error) - } - } -} \ No newline at end of file diff --git a/nuxt4-app/nuxt.config.ts b/nuxt4-app/nuxt.config.ts index c6fae0b..61c9703 100644 --- a/nuxt4-app/nuxt.config.ts +++ b/nuxt4-app/nuxt.config.ts @@ -6,7 +6,7 @@ export default defineNuxtConfig({ compatibilityDate: '2025-07-15', devtools: { enabled: true }, css: ['~/assets/css/main.css'], - modules: ['@nuxt/image', '@nuxt/ui', '@nuxt/test-utils', '@vite-pwa/nuxt', '@pinia/nuxt'], + modules: ['@nuxt/image', '@nuxt/ui', '@nuxt/test-utils', '@vite-pwa/nuxt'], // Performance optimizations experimental: { @@ -33,8 +33,7 @@ export default defineNuxtConfig({ rollupOptions: { output: { manualChunks: { - 'vendor-ui': ['@nuxt/ui'], - 'vendor-pinia': ['pinia', '@pinia/nuxt'] + 'vendor-ui': ['@nuxt/ui'] } } } diff --git a/nuxt4-app/package.json b/nuxt4-app/package.json index 5cd1b38..006b656 100644 --- a/nuxt4-app/package.json +++ b/nuxt4-app/package.json @@ -13,10 +13,8 @@ "@nuxt/image": "^1.11.0", "@nuxt/test-utils": "^3.19.2", "@nuxt/ui": "^4.0.0", - "@pinia/nuxt": "^0.11.2", "@vite-pwa/nuxt": "^0.9.1", "nuxt": "^4.1.2", - "pinia": "^3.0.3", "typescript": "^5.9.2", "vue": "^3.5.22", "vue-router": "^4.5.1" diff --git a/nuxt4-app/server/services/query-parser.ts b/nuxt4-app/server/services/query-parser.ts deleted file mode 100644 index bf43499..0000000 --- a/nuxt4-app/server/services/query-parser.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { ParsedQuery, QueryFilter } from '../data-sources/types' - -function isValidFilter(filter: Partial): filter is QueryFilter { - return typeof filter.field === 'string' && filter.field.length > 0 && filter.value !== undefined -} - -function decodeBase64Url(value: string) { - const normalized = value.replace(/-/g, '+').replace(/_/g, '/') - const paddingNeeded = (4 - (normalized.length % 4)) % 4 - const padded = normalized + '='.repeat(paddingNeeded) - return Buffer.from(padded, 'base64').toString('utf-8') -} - -export function parseQuerySegment(segment?: string | string[]): ParsedQuery | null { - if (!segment) { - return null - } - - const value = Array.isArray(segment) ? segment[0] : segment - - if (!value) { - return null - } - - try { - const decoded = decodeBase64Url(value) - const parsed = JSON.parse(decoded) - - const result: ParsedQuery = { - filters: [] - } - - if (Array.isArray(parsed.filters)) { - result.filters = parsed.filters.filter(isValidFilter) - } - - if (parsed.limit && Number.isInteger(parsed.limit) && parsed.limit > 0) { - result.limit = Math.min(parsed.limit, 500) - } - - if (parsed.offset && Number.isInteger(parsed.offset) && parsed.offset >= 0) { - result.offset = parsed.offset - } - - if (parsed.orderBy && typeof parsed.orderBy.field === 'string') { - result.orderBy = { - field: parsed.orderBy.field, - ascending: parsed.orderBy.ascending !== false - } - } - - return result - } catch (error) { - console.warn('Failed to parse query segment', error) - return null - } -} diff --git a/nuxt4-app/server/services/query-runner.ts b/nuxt4-app/server/services/query-runner.ts deleted file mode 100644 index 4e595a3..0000000 --- a/nuxt4-app/server/services/query-runner.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { ParsedQuery } from '../data-sources/types' - -type PostgrestOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'ilike' - -export function applyParsedQuery(builder: any, parsed: ParsedQuery | null) { - if (!parsed) { - return builder - } - - for (const filter of parsed.filters) { - const operator: PostgrestOperator = (filter.operator ?? 'eq') as PostgrestOperator - - if (typeof builder[operator] === 'function') { - const value = operator === 'like' || operator === 'ilike' ? String(filter.value) : filter.value - builder = builder[operator](filter.field, value) - } - } - - if (parsed.orderBy && typeof builder.order === 'function') { - builder = builder.order(parsed.orderBy.field, { - ascending: parsed.orderBy.ascending !== false - }) - } - - if (parsed.limit && parsed.offset !== undefined && typeof builder.range === 'function') { - const from = parsed.offset - const to = parsed.offset + parsed.limit - 1 - builder = builder.range(from, to) - } else if (parsed.limit && typeof builder.limit === 'function') { - builder = builder.limit(parsed.limit) - } - - return builder -} diff --git a/nuxt4-app/server/services/table-service.ts b/nuxt4-app/server/services/table-service.ts deleted file mode 100644 index a1827a0..0000000 --- a/nuxt4-app/server/services/table-service.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { getTableConfig, tableNames } from '../data-sources' -import type { ParsedQuery, TableConfig } from '../data-sources/types' -import { getSupabaseClient } from '../utils/supabase' -import { applyParsedQuery } from './query-runner' - -type GenericObject = Record - -function isRecord(value: unknown): value is GenericObject { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -interface MetadataOptions { - parsedQuery?: ParsedQuery | null -} - -interface DataFilters { - id?: string - createdFrom?: string - createdTo?: string -} - -interface DataOptions { - parsedQuery?: ParsedQuery | null - filters?: DataFilters - limit?: number - offset?: number -} - -function serializeRow(row: unknown, config: TableConfig): GenericObject | null { - if (!isRecord(row) || ('error' in row && (row as any).error === true)) { - return null - } - - const transform = config.transforms?.serializeRow - return transform ? transform(row) : row -} - -export async function fetchAllTablesMetadata() { - const results = await Promise.allSettled( - tableNames.map((name) => fetchTableMetadata(name)) - ) - - // Log any errors for debugging - results.forEach((result, index) => { - if (result.status === 'rejected') { - console.error(`Failed to fetch metadata for table ${tableNames[index]}:`, result.reason) - } - }) - - return results - .filter((result): result is PromiseFulfilledResult>> => - result.status === 'fulfilled' - ) - .map((result) => result.value) -} - -export async function fetchTableMetadata(tableName: string, options?: MetadataOptions) { - try { - const config = getTableConfig(tableName) - - if (!config) { - throw createError({ statusCode: 404, statusMessage: `Tabla ${tableName} no encontrada` }) - } - - const supabase = getSupabaseClient() - - const baseSelect = config.defaultSelect ?? '*' - - const samplePromise = applyParsedQuery( - supabase.from(config.table).select(baseSelect), - options?.parsedQuery ?? null - ) - .limit(1) - - // Try to get created_at range - // For views with 'fecha' field, try that first, otherwise use 'created_at' - const orderField = baseSelect.includes('fecha') ? 'fecha' : 'created_at' - - const earliestPromise = supabase - .from(config.table) - .select(baseSelect) - .order(orderField, { ascending: true }) - .limit(1) - - const latestPromise = supabase - .from(config.table) - .select(baseSelect) - .order(orderField, { ascending: false }) - .limit(1) - - const [sampleResult, earliestResult, latestResult] = - await Promise.all([samplePromise, earliestPromise, latestPromise]) - - const { data: sampleData, error: sampleError } = sampleResult - - // Handle created_at queries that may fail for views without this column - const earliest = earliestResult.error ? { data: null } : earliestResult as { data: any[] | null } - const latest = latestResult.error ? { data: null } : latestResult as { data: any[] | null } - - if (sampleError) { - console.error(`Error fetching sample for ${config.table}:`, { - message: sampleError.message, - details: sampleError.details, - hint: sampleError.hint, - code: sampleError.code, - full: sampleError - }) - const errorMsg = sampleError.message || sampleError.hint || sampleError.details || sampleError.code || 'Error desconocido al obtener muestra' - throw createError({ statusCode: 500, statusMessage: errorMsg }) - } - - const sampleSet = Array.isArray(sampleData) ? sampleData : [] - const sampleRow = serializeRow(sampleSet[0] ?? null, config) - const columnNames = sampleRow ? Object.keys(sampleRow) : [] - const approxRowSize = sampleRow ? JSON.stringify(sampleRow).length : 0 - const approxSizeBytes = null // We'll calculate this from in-memory data - - return { - name: config.name, - table: config.table, - primaryKey: config.primaryKey ?? 'id', - rowCount: 0, // Will be calculated from in-memory data - approxSizeBytes, - columns: columnNames, - createdAtRange: { - from: earliest.data?.[0]?.created_at ?? null, - to: latest.data?.[0]?.created_at ?? null - }, - sampleRow, - lastRefreshed: new Date().toISOString(), - description: config.description - } - } catch (error: any) { - console.error(`Unexpected error in fetchTableMetadata for ${tableName}:`, error) - throw createError({ - statusCode: 500, - statusMessage: error?.message || error?.toString() || 'Error inesperado al obtener metadatos' - }) - } -} - -export async function fetchTableRecordMetadata(tableName: string, id: string) { - const config = getTableConfig(tableName) - - if (!config) { - throw createError({ statusCode: 404, statusMessage: `Tabla ${tableName} no encontrada` }) - } - - const supabase = getSupabaseClient() - const primaryKey = config.primaryKey ?? 'id' - - const { data, error } = await supabase - .from(config.table) - .select(config.defaultSelect ?? '*') - .eq(primaryKey, id) - .maybeSingle() - - if (error) { - throw createError({ statusCode: 500, statusMessage: error.message }) - } - - if (!data || (typeof data === 'object' && data !== null && 'error' in data)) { - if (data && typeof data === 'object' && 'message' in data) { - throw createError({ statusCode: 500, statusMessage: String((data as any).message) }) - } - throw createError({ statusCode: 404, statusMessage: `Registro ${id} no encontrado` }) - } - - return { - table: config.table, - id, - metadata: serializeRow(data, config) - } -} - -export async function fetchTableRecord(tableName: string, id: string) { - const config = getTableConfig(tableName) - - if (!config) { - throw createError({ statusCode: 404, statusMessage: `Tabla ${tableName} no encontrada` }) - } - - const supabase = getSupabaseClient() - const primaryKey = config.primaryKey ?? 'id' - - const { data, error } = await supabase - .from(config.table) - .select(config.defaultSelect ?? '*') - .eq(primaryKey, id) - .maybeSingle() - - if (error) { - throw createError({ statusCode: 500, statusMessage: error.message }) - } - - if (!data || (typeof data === 'object' && data !== null && 'error' in data)) { - if (data && typeof data === 'object' && 'message' in data) { - throw createError({ statusCode: 500, statusMessage: String((data as any).message) }) - } - throw createError({ statusCode: 404, statusMessage: `Registro ${id} no encontrado` }) - } - - return serializeRow(data, config) -} - -export async function fetchAllData(limitPerTable = 100) { - const supabase = getSupabaseClient() - - const tablePromises = tableNames.map(async (name) => { - const config = getTableConfig(name)! - const { data, error, count } = await supabase - .from(config.table) - .select(config.defaultSelect ?? '*', { count: 'exact' }) - .limit(limitPerTable) - - if (error) { - throw createError({ statusCode: 500, statusMessage: error.message }) - } - - const rows = Array.isArray(data) ? data : [] - - return { - table: config.table, - count: count ?? 0, - limit: limitPerTable, - records: rows - .map((row) => serializeRow(row, config)) - .filter((row): row is GenericObject => row !== null) - } - }) - - return Promise.all(tablePromises) -} - -export async function fetchTableData(tableName: string, options?: DataOptions) { - const config = getTableConfig(tableName) - - if (!config) { - throw createError({ statusCode: 404, statusMessage: `Tabla ${tableName} no encontrada` }) - } - - const supabase = getSupabaseClient() - const primaryKey = config.primaryKey ?? 'id' - const limit = options?.limit ?? 100 - - let query = supabase - .from(config.table) - .select(config.defaultSelect ?? '*', { count: 'exact' }) - - query = applyParsedQuery(query as never, options?.parsedQuery ?? null) as never - - if (options?.filters?.id) { - query = query.eq(primaryKey, options.filters.id) - } - - if (options?.filters?.createdFrom) { - query = query.gte('created_at', options.filters.createdFrom) - } - - if (options?.filters?.createdTo) { - query = query.lte('created_at', options.filters.createdTo) - } - - if (!options?.parsedQuery?.limit) { - query = query.limit(limit) - } - - if (options?.offset !== undefined && options.offset > 0) { - query = query.range(options.offset, options.offset + limit - 1) - } - - const { data, error, count } = await query - - if (error) { - throw createError({ statusCode: 500, statusMessage: error.message }) - } - - const rows = Array.isArray(data) ? data : [] - const records = rows - .map((row) => serializeRow(row, config)) - .filter((row): row is GenericObject => row !== null) - - return { - table: config.table, - count: count ?? records.length, - limit, - records - } -}