refactor: remove Pinia state management and IndexedDB storage
Some checks failed
build-and-deploy / build (push) Failing after 3m3s
build-and-deploy / deploy (push) Has been skipped

Remove all client-side state management and data caching as the
application no longer needs to manage data analysis features.

Changes:
- Remove Pinia store system (app/stores/ directory)
  - metadata.ts: Store for table metadata
  - tableDataFactory.ts: Factory for creating table data stores
  - README.md: Comprehensive store documentation
- Remove IndexedDB storage utility (app/utils/storage.ts)
- Remove Pinia dependencies from package.json
  - @pinia/nuxt
  - pinia
- Remove Pinia module from nuxt.config.ts
- Remove vendor-pinia chunk configuration
- Remove server services (server/services/ directory)
  - table-service.ts: Supabase-dependent table operations
  - query-parser.ts: Query parsing utilities
  - query-runner.ts: Query execution utilities

The application now operates as a stateless authentication portal
without client-side data caching or state management.
This commit is contained in:
2025-10-13 15:34:46 -06:00
parent 226d807d76
commit b7a76911f9
9 changed files with 2 additions and 2254 deletions

View File

@@ -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<string, unknown> // 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<T = Record<string, unknown>> {
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
<template>
<div>
<!-- Selector de tabla -->
<select v-model="selectedTable" @change="onTableChange">
<option v-for="table in metadataStore.tableNames" :key="table">
{{ table }}
</option>
</select>
<!-- Información de cache -->
<div v-if="currentStore">
<p>Última actualización: {{ currentStore.formattedLastUpdated }}</p>
<p v-if="currentStore.isStale" class="warning">
Datos desactualizados
</p>
<button @click="refreshData" :disabled="currentStore.isLoading">
{{ currentStore.isLoading ? 'Actualizando...' : 'Actualizar' }}
</button>
</div>
<!-- Tabla de datos -->
<div v-if="currentStore?.hasData">
<p>{{ currentStore.recordCount }} registros</p>
<table>
<thead>
<tr>
<th v-for="col in columns" :key="col">{{ col }}</th>
</tr>
</thead>
<tbody>
<tr v-for="record in currentStore.allRecords" :key="record.id">
<td v-for="col in columns" :key="col">
{{ record[col] }}
</td>
</tr>
</tbody>
</table>
</div>
<!-- Estados -->
<div v-else-if="currentStore?.isLoading">Cargando...</div>
<div v-else-if="currentStore?.hasError">
Error: {{ currentStore.error }}
</div>
<div v-else>Selecciona una tabla</div>
</div>
</template>
<script setup lang="ts">
import { useMetadataStore } from '~/stores/metadata'
import { useTableDataStore } from '~/stores/tableDataFactory'
const metadataStore = useMetadataStore()
const selectedTable = ref('')
const currentStore = ref<ReturnType<typeof useTableDataStore> | null>(null)
// Columnas de la tabla actual
const columns = computed(() => {
if (!currentStore.value?.hasData) return []
const firstRecord = currentStore.value.allRecords[0]
return Object.keys(firstRecord)
})
// Cambio de tabla
async function onTableChange() {
if (!selectedTable.value) return
// Obtener store de la tabla
currentStore.value = useTableDataStore(selectedTable.value)
// Inicializar (usa cache si existe)
await currentStore.value.initialize()
}
// Refrescar datos
async function refreshData() {
if (!currentStore.value) return
await currentStore.value.refreshData()
}
// Inicializar metadatos al montar
onMounted(async () => {
await metadataStore.initialize()
// Auto-seleccionar primera tabla
if (metadataStore.tableNames.length > 0) {
selectedTable.value = metadataStore.tableNames[0]
await onTableChange()
}
})
</script>
```
### 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<void>
refreshMetadata(): Promise<void>
clearMetadata(): void
initialize(): Promise<void>
}
```
### Table Data Store API
```typescript
interface TableDataStore<T> {
// 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<void>
refreshData(): Promise<void>
clearData(): void
initialize(): Promise<void>
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<Usuario>('usuarios')
const productosStore = useTableDataStore<Producto>('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

View File

@@ -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<string, unknown>
}
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<void> {
// 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<void> {
await this.fetchMetadata()
},
/**
* Internal method to fetch metadata from API
*/
async fetchMetadata(): Promise<void> {
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<void> {
// 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
})

View File

@@ -1,518 +0,0 @@
import { defineStore } from 'pinia'
import { tableDataStorage } from '~/utils/storage'
export interface TableDataState<T = Record<string, unknown>> {
data: T[]
loading: boolean
error: string | null
lastUpdated: string | null
initialized: boolean
limit: number
}
export interface TableDataGetters<T = Record<string, unknown>> {
allRecords: (state: TableDataState<T>) => T[]
hasData: (state: TableDataState<T>) => boolean
isLoading: (state: TableDataState<T>) => boolean
hasError: (state: TableDataState<T>) => boolean
recordCount: (state: TableDataState<T>) => number
formattedLastUpdated: (state: TableDataState<T>) => string
isStale: (state: TableDataState<T>) => boolean
}
export interface TableDataActions<T = Record<string, unknown>> {
loadData(force?: boolean): Promise<void>
refreshData(): Promise<void>
fetchData(): Promise<void>
clearData(): Promise<void>
loadFromCache(): Promise<void>
extractErrorMessage(error: unknown): string
initialize(): Promise<void>
getRecord(id: string | number): T | undefined
filterRecords(predicate: (record: T) => boolean): T[]
loadAllDataInBatches(onProgress?: (progress: number) => void): Promise<void>
loadLatestDataInBatches(onProgress?: (progress: number) => void): Promise<void>
}
/**
* 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<T = Record<string, unknown>>(
tableName: string,
defaultLimit: number = 100
) {
const storeId = `table-${tableName}`
const cacheKey = `table-data-${tableName}`
return defineStore(storeId, {
state: (): TableDataState<T> => ({
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<void> {
// 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<void> {
await this.fetchData()
},
/**
* Internal method to fetch data from API
*/
async fetchData(): Promise<void> {
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<void> {
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<void> {
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<void> {
// 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<void> {
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<void> {
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<T = Record<string, unknown>>(
tableName: string,
limit?: number
) {
const store = createTableDataStore<T>(tableName, limit)
return store()
}

View File

@@ -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<IDBDatabase> {
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<boolean> {
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<any | null> {
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<boolean> {
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<void> {
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<any | null> {
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<void> {
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)
}
}
}

View File

@@ -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']
}
}
}

View File

@@ -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"

View File

@@ -1,57 +0,0 @@
import type { ParsedQuery, QueryFilter } from '../data-sources/types'
function isValidFilter(filter: Partial<QueryFilter>): 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
}
}

View File

@@ -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
}

View File

@@ -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<string, unknown>
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<Awaited<ReturnType<typeof fetchTableMetadata>>> =>
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
}
}