refactor: remove Pinia state management and IndexedDB storage
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:
@@ -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
|
||||
@@ -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
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user