🔍 ANÁLISIS PROFUNDO: Arquitectura de Persistencia
📊 Estado Actual de la Arquitectura
🏗️ Componentes Principales
1. IndexedDB (Dexie.js) - src/utils/db.js
- Schema Version: 9 (última migración)
- Stores: 17 tablas diferentes
- Tamaño: Sin límite (depende del almacenamiento del dispositivo)
- Performance: Asíncrono, no bloquea UI
Stores Actuales:
// CORE DATA
projects // Metadata de proyectos
users // Usuarios del sistema
auth // Tokens y customer data (persistencia de login)
// GRANULAR DATA (optimización)
tasks // Tareas individuales (6 índices)
workflowNodes // Nodos de workflow separados
dataRows // Filas de DATA_TABLE (separado de tasks)
// WORKFLOW ENGINE
workflows // Configuración de workflows
executions // Historial de ejecuciones
schedules // Programación de workflows
variables // Variables de contexto
// SYNC & MONITORING
sync // Estado de sincronización
projectSyncMeta // Timestamp último sync por proyecto
syncQueue // Cola de operaciones pendientes (offline mode)
nodeErrors // Errores de nodos para debugging
// SCHEDULER (5 stores)
schedulerActiveExecutions
schedulerScheduledWorkflows
schedulerHistory
schedulerState
schedulerStorageKv // Reemplazo de localStorage
// LOCKS (multi-device)
workflowLocks // Locks para ejecuciones concurrentes
⚠️ PROBLEMAS IDENTIFICADOS
🔴 CRÍTICOS
1. Guardado Completo de Proyectos
// ACTUAL (INEFICIENTE):
for (const project of projects) {
await saveProject(project); // Guarda proyecto COMPLETO
}
// Problema: Si un proyecto tiene 10,000 tasks, guarda TODO cada vez
Impacto:
- 📉 Performance degradada con proyectos grandes
- 💾 Escrituras innecesarias en disco
- 🔥 CPU spikes en auto-save (cada 2.5s)
Solución Propuesta:
// OPTIMIZADO (GUARDADO GRANULAR):
// 1. Solo guardar metadata del proyecto
await saveProject({
id, name, projectType, createdAt, updatedAt,
isDeleted, workflowConfig, workflowData
// SIN tasks, SIN dataRows
});
// 2. Guardar tasks modificados individualmente
for (const task of changedTasks) {
await saveTask(task); // Ya existe pero NO se usa
}
// 3. Guardar dataRows por lotes
await saveDataRowsBatch(projectId, changedRows);
2. Detección de Cambios Ineficiente
// ACTUAL:
const changedProjects = projects.filter(project => {
const prev = previousProjects.find(p => p.id === project.id);
return !prev || prev.updatedAt !== project.updatedAt;
});
Problemas:
- Solo compara
updatedAtdel proyecto - No detecta cambios en tasks/dataRows individuales
updatedAtpuede cambiar sin cambios reales (falso positivo)
Solución:
- Usar dirty flags en memoria
- Track cambios granulares (task-level, node-level)
- Implementar Change Tracking System
3. Google Drive Sync Polling (5 segundos)
// ACTUAL:
setInterval(async () => {
// Check remoto CADA 5 segundos
const response = await gapi.client.drive.files.get({
fileId: driveFileId,
fields: 'appProperties'
});
}, 5000);
Problemas:
- 🔥 12 requests/minuto = 720 requests/hora
- 💸 Cuota de Google Drive API limitada
- 🔋 Drena batería en móviles
- ⚡ Latencia innecesaria (5s de delay)
Solución:
- Usar Google Drive Push Notifications (Webhooks)
- Aumentar intervalo a 30-60 segundos
- Implementar exponential backoff en errores
4. Sin Compresión en Drive
// ACTUAL:
const fileContent = JSON.stringify({
projects, // Puede ser MB de datos
allUsers,
userData,
schedulerSnapshot,
locksSnapshot
}, null, 2); // Sin compresión!
await media.upload({
body: fileContent // Texto plano
});
Impacto:
- 📦 10 MB sin comprimir → 10 MB en Drive
- ⏱️ Upload/download lentos
- 💸 Cuota de almacenamiento desperdiciada
Solución:
// OPTIMIZADO:
import pako from 'pako'; // gzip compression
const fileContent = JSON.stringify(data);
const compressed = pako.gzip(fileContent);
const base64 = btoa(String.fromCharCode(...compressed));
// 10 MB → 1-2 MB (80-90% compresión)
5. Sync Queue Sin Retry Exponencial
// ACTUAL:
const RETRY_DELAY = 5000; // Siempre 5s
const MAX_RETRIES = 3;
// Problema: Falla rápidamente si servidor está saturado
Solución:
// EXPONENTIAL BACKOFF:
const delay = Math.min(
INITIAL_DELAY * Math.pow(2, attempt),
MAX_DELAY
);
// Intento 1: 5s
// Intento 2: 10s
// Intento 3: 20s
// Intento 4: 40s
// Max: 5 minutos
🟡 ADVERTENCIAS
6. IndexedDB Sin Transacciones Complejas
// ACTUAL:
await saveProject(project);
await saveTask(task1);
await saveTask(task2);
// ¿Qué pasa si falla task2? project y task1 ya guardados
Problema:
- Sin atomicidad (todo o nada)
- Posible corrupción de datos
Solución:
await db.transaction('rw', [db.projects, db.tasks], async () => {
await db.projects.put(project);
await db.tasks.bulkPut([task1, task2, task3]);
// Si falla cualquiera, rollback automático
});
7. Backend Sync Sin Debounce
// ACTUAL:
const handleProjectUpdate = async (project) => {
await updateProject(projectId, updates);
// Inmediatamente:
if (isBackendSyncEnabled) {
await projectsApi.update(projectId, updates);
}
};
// Problema: 10 cambios rápidos = 10 API calls
Solución:
// DEBOUNCED:
const debouncedBackendSync = debounce(async (projectId, updates) => {
await projectsApi.update(projectId, updates);
}, 2000);
// 10 cambios en 1s = 1 API call después de 2s
8. Drive File ID en localStorage
// ACTUAL:
localStorage.setItem('drive_file_id', fileId);
localStorage.setItem('drive_last_modified', timestamp);
// Problema: localStorage es síncrono, bloquea UI
Solución:
// Ya existe tabla 'config' en IndexedDB, usarla:
await db.config.put({ key: 'drive_file_id', value: fileId });
await db.config.put({ key: 'drive_last_modified', value: timestamp });
9. Sin Índices Compuestos Optimizados
// ACTUAL:
tasks: 'id, projectId, status, assignedTo, dueDate, priority, createdAt, updatedAt'
// Queries comunes:
// 1. Todas las tasks de un proyecto por status
db.tasks.where('projectId').equals(id).filter(t => t.status === 'pending')
// ^ Ineficiente: filtra en memoria
// 2. Tasks asignadas a un usuario pendientes
db.tasks.where('assignedTo').equals(userId).filter(t => t.status === 'pending')
// ^ Ineficiente: filtra en memoria
Solución:
// ÍNDICES COMPUESTOS:
tasks: 'id, projectId, [projectId+status], [assignedTo+status], [projectId+updatedAt]'
// Ahora estos queries son ÓPTIMOS:
db.tasks.where('[projectId+status]').equals([projectId, 'pending'])
db.tasks.where('[assignedTo+status]').equals([userId, 'pending'])
🟢 MEJORAS MENORES
10. Auto-Save Timer Fijo (2.5 segundos)
// ACTUAL:
autoSaveTimerRef.current = setTimeout(async () => {
await saveToIndexedDB();
}, 2500); // Siempre 2.5s
Problema:
- Usuario escribiendo rápido: guardados innecesarios
- Usuario inactivo: 2.5s de delay innecesario
Solución:
// ADAPTIVE AUTO-SAVE:
const getAutoSaveDelay = () => {
const recentChanges = getChangesInLastMinute();
if (recentChanges > 10) return 5000; // Muchos cambios: esperar más
if (recentChanges > 5) return 2500; // Cambios moderados: default
return 1000; // Pocos cambios: guardar rápido
};
🚀 ARQUITECTURA PROPUESTA OPTIMIZADA
Tier-Aware Persistence Strategy
┌─────────────────────────────────────────────────────────────┐
│ FREE TIER │
├─────────────────────────────────────────────────────────────┤
│ IndexedDB (Local Only) │
│ ├── Guardado granular (task-level) │
│ ├── Sin sync (offline-only) │
│ └── Sin límite de almacenamiento │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ PERSONAL TIER │
├─────────────────────────────────────────────────────────────┤
│ IndexedDB + Google Drive (OBLIGATORIO) │
│ ├── Sync con Drive (comprimido) │
│ ├── Push notifications (Webhooks) │
│ ├── Multi-dispositivo vía Drive │
│ └── Backend SOLO para customer/devices (NO proyectos) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ STANDARD/PRO TIER │
├─────────────────────────────────────────────────────────────┤
│ Backend D1 (PRIMARY) + IndexedDB (Cache) │
│ ├── Backend sync completo (proyectos + tasks) │
│ ├── IndexedDB como cache (offline-first) │
│ ├── Google Drive OPCIONAL (user preference) │
│ ├── Sync inteligente (solo cambios) │
│ └── Multi-dispositivo vía backend │
└─────────────────────────────────────────────────────────────┘
Change Tracking System
class ChangeTracker {
constructor() {
this.changes = new Map(); // entityId → { type, timestamp, data }
this.dirtyProjects = new Set();
this.dirtyTasks = new Set();
this.dirtyDataRows = new Set();
}
markProjectDirty(projectId, changeType = 'metadata') {
this.dirtyProjects.add(projectId);
this.changes.set(`project:${projectId}`, {
type: changeType,
timestamp: Date.now()
});
}
markTaskDirty(taskId, projectId) {
this.dirtyTasks.add(taskId);
this.dirtyProjects.add(projectId); // Proyecto también cambió
this.changes.set(`task:${taskId}`, {
projectId,
timestamp: Date.now()
});
}
async flush() {
// Guardar solo entidades dirty
const projectsToSave = Array.from(this.dirtyProjects);
const tasksToSave = Array.from(this.dirtyTasks);
await db.transaction('rw', [db.projects, db.tasks], async () => {
// Guardar metadata de proyectos (sin tasks)
for (const projectId of projectsToSave) {
const project = getProject(projectId);
await db.projects.put(stripTasks(project));
}
// Guardar tasks modificados
const tasks = tasksToSave.map(id => getTask(id));
await db.tasks.bulkPut(tasks);
});
this.clear();
}
clear() {
this.changes.clear();
this.dirtyProjects.clear();
this.dirtyTasks.clear();
}
}
Optimized IndexedDB Schema (v10)
db.version(10).stores({
// CORE DATA (sin cambios)
projects: 'id, name, projectType, createdAt, updatedAt, isDeleted, tags',
// GRANULAR DATA (MEJORADO con índices compuestos)
tasks: 'id, projectId, [projectId+status], [projectId+updatedAt], [assignedTo+status], status, assignedTo, dueDate, priority, createdAt, updatedAt',
dataRows: 'id, projectId, [projectId+createdAt], [projectId+updatedAt], createdAt, updatedAt',
workflowNodes: 'id, workflowId, projectId, [workflowId+type], type, status, updatedAt',
// SYNC (MEJORADO)
syncQueue: 'id, type, [type+status], [projectId+status], entityId, projectId, status, createdAt, retries',
syncLog: '++id, operation, entityType, entityId, timestamp, success', // Nuevo: audit log
// CONFIG (consolidar localStorage aquí)
config: 'key, value, updatedAt',
// ... resto sin cambios
});
Compression Strategy
// utils/compression.js
import pako from 'pako';
export async function compressDriveData(data) {
const json = JSON.stringify(data);
// Solo comprimir si vale la pena (>10 KB)
if (json.length < 10240) {
return { compressed: false, data: json };
}
try {
const uint8Array = new TextEncoder().encode(json);
const compressed = pako.gzip(uint8Array);
const base64 = btoa(String.fromCharCode(...compressed));
const compressionRatio = (1 - base64.length / json.length) * 100;
console.log(`🗜️ Compresión: ${json.length} → ${base64.length} bytes (${compressionRatio.toFixed(1)}%)`);
return { compressed: true, data: base64 };
} catch (error) {
console.error('Compression failed, using uncompressed:', error);
return { compressed: false, data: json };
}
}
export async function decompressDriveData(data, isCompressed) {
if (!isCompressed) {
return JSON.parse(data);
}
try {
const binaryString = atob(data);
const uint8Array = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
uint8Array[i] = binaryString.charCodeAt(i);
}
const decompressed = pako.ungzip(uint8Array);
const json = new TextDecoder().decode(decompressed);
return JSON.parse(json);
} catch (error) {
console.error('Decompression failed:', error);
throw error;
}
}
Drive Push Notifications (Webhooks)
// utils/driveWebhooks.js
export async function setupDrivePushNotifications(fileId) {
const webhookUrl = `https://projectos.gonzalomonzonc.workers.dev/api/drive-webhook`;
const response = await gapi.client.drive.files.watch({
fileId: fileId,
requestBody: {
id: `channel-${Date.now()}`,
type: 'web_hook',
address: webhookUrl,
expiration: Date.now() + (7 * 24 * 60 * 60 * 1000) // 7 días
}
});
// Guardar channel ID para poder cancelar después
await db.config.put({
key: 'drive_webhook_channel',
value: response.result.id
});
console.log('✅ Push notifications configuradas para Drive');
}
// Backend webhook handler (functions/api/drive-webhook.js)
export async function onRequestPost({ request, env }) {
const channelId = request.headers.get('X-Goog-Channel-ID');
const resourceState = request.headers.get('X-Goog-Resource-State');
if (resourceState === 'change') {
// Notificar a cliente conectado vía WebSocket o SSE
await notifyClient(channelId, { type: 'drive_changed' });
}
return new Response('OK', { status: 200 });
}
🎯 APLICACIONES ELECTRON
Arquitectura Actual (ml-trainer-local)
// main.js
const store = new Store({
name: 'ml-trainer-hub-config',
defaults: {
customerEmail: '',
customerId: '',
userId: '',
agentId: '',
modelsPath: path.join(app.getPath('userData'), 'models'),
cachePath: path.join(app.getPath('userData'), 'cache')
}
});
// Problema: electron-store es key-value plano, sin queries complejas
Propuesta: SQLite en Electron
// db/electron-sqlite.js
import Database from 'better-sqlite3';
import path from 'path';
import { app } from 'electron';
const dbPath = path.join(app.getPath('userData'), 'projectos.db');
const db = new Database(dbPath);
// Mismo esquema que IndexedDB (portabilidad)
db.exec(`
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT,
projectType TEXT,
data TEXT, -- JSON
createdAt TEXT,
updatedAt TEXT,
isDeleted INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_projects_type ON projects(projectType);
CREATE INDEX IF NOT EXISTS idx_projects_updated ON projects(updatedAt);
`);
// API compatible con IndexedDB
export const electronDB = {
projects: {
async put(project) {
const stmt = db.prepare(`
INSERT OR REPLACE INTO projects VALUES (?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(
project.id,
project.name,
project.projectType,
JSON.stringify(project),
project.createdAt,
project.updatedAt,
project.isDeleted ? 1 : 0
);
},
async get(id) {
const row = db.prepare('SELECT data FROM projects WHERE id = ?').get(id);
return row ? JSON.parse(row.data) : null;
},
async toArray() {
const rows = db.prepare('SELECT data FROM projects WHERE isDeleted = 0').all();
return rows.map(row => JSON.parse(row.data));
}
}
};
Sync entre Electron y Web
// Electron → Backend
ipcMain.handle('sync-projects', async () => {
const projects = await electronDB.projects.toArray();
const customerId = store.get('customerId');
// Sincronizar con backend
for (const project of projects) {
await fetch(`https://api.projectos.com/projects/${project.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Customer-ID': customerId
},
body: JSON.stringify(project)
});
}
});
📋 PLAN DE IMPLEMENTACIÓN
Fase 1: Optimizaciones Críticas (1-2 días)
✅ Prioridad ALTA:
Guardado Granular
- Separar metadata de proyecto de tasks/dataRows
- Usar
saveTask()ysaveDataRowsBatch()existentes - Implementar Change Tracker básico
Índices Compuestos
- Migración a v10 con índices mejorados
- Medir performance antes/después
Drive Polling → 30 segundos
- Cambiar intervalo de 5s a 30s
- Implementar exponential backoff en errores
Fase 2: Compresión y Webhooks (2-3 días)
Compresión Drive
- Instalar
pako - Implementar compress/decompress helpers
- Migrar archivos existentes (opcional)
- Instalar
Drive Push Notifications
- Setup webhook en backend
- Configurar watch en Drive files
- Client-side listener para notificaciones
Fase 3: Transacciones y Debounce (1 día)
Transacciones Atómicas
- Usar
db.transaction()para operaciones multi-tabla - Rollback automático en errores
- Usar
Backend Sync Debounce
- Implementar debounce helper (lodash o custom)
- Aplicar a todas las API calls
Fase 4: Electron Optimization (2-3 días)
- SQLite en Electron
- Reemplazar electron-store con better-sqlite3
- API compatible con IndexedDB
- Sync bidireccional Web ↔ Electron
🧪 MÉTRICAS DE ÉXITO
Antes (Estado Actual)
📊 Guardado de 100 proyectos con 1,000 tasks c/u:
- Tiempo: ~5,000 ms (5 segundos)
- Operaciones de escritura: 100 (proyectos completos)
- Tamaño Drive: 50 MB sin comprimir
- Polling: 12 req/min (720 req/hora)
Después (Optimizado)
📊 Guardado de 100 proyectos con 1,000 tasks c/u:
- Tiempo: ~500 ms (10x más rápido)
- Operaciones de escritura: 10-20 (solo cambios)
- Tamaño Drive: 5-10 MB comprimido (80-90% reducción)
- Polling: 2 req/min (120 req/hora) + Push notifications
⚠️ RIESGOS Y CONSIDERACIONES
Compresión
- ❌ Mayor uso de CPU (compresión/descompresión)
- ✅ Menos uso de red y almacenamiento
- Decisión: Vale la pena para archivos >10 KB
Drive Webhooks
- ❌ Requiere backend siempre disponible
- ❌ Cuota de webhooks limitada (10,000/día)
- ✅ Elimina 99% del polling
- Decisión: Usar como complemento, mantener polling como fallback
Guardado Granular
- ❌ Más complejidad en código
- ❌ Posible desincronización metadata ↔ tasks
- ✅ 10x mejora en performance
- Decisión: Implementar con Change Tracker robusto
🎯 RECOMENDACIÓN FINAL
Implementar en orden:
- ✅ Fase 1 (1-2 días) - Impacto inmediato
- ✅ Fase 2 (2-3 días) - Mejora significativa
- ⚠️ Fase 3 (1 día) - Prevención de bugs
- 📌 Fase 4 (2-3 días) - Solo si hay demanda Electron
Total estimado: 6-9 días de trabajo para optimización completa.
ROI esperado:
- 🚀 10x mejora en performance de guardado
- 💾 80-90% reducción de uso de Drive
- 🔋 90% reducción de API calls a Drive
- 🐛 Menos bugs por race conditions (transacciones)