Lo innovador, en cinco líneas
- DuckDB-WASM convierte el navegador en un motor analítico: el diff de un millón de filas se calcula en el cliente y el servidor solo recibe el resultado.
- Concurrencia optimista a nivel de fila con Delta Row Tracking, usando el mecanismo nativo del motor en lugar de una columna
updated_atque hay que disciplinar a mano. - Un parser de Excel escrito en Rust y compilado a WASM, en 436 KB, con presupuesto de bundle explícito.
- Streaming Arrow de extremo a extremo, sin pasar por JSON en ningún punto entre el warehouse y la memoria del navegador.
- Un agente LLM con tool-calling que crea proyectos y aprovisiona tablas desde una instrucción en lenguaje natural — y que reutiliza los servicios de dominio existentes, así que hereda sus validaciones y su auditoría.
Qué resuelve
SaaS interno para que equipos de negocio creen, editen y auditen tablas maestras en Databricks Unity Catalog sin escribir SQL ni tener acceso directo al warehouse. Antes, cada cambio de catálogo pasaba por ingeniería de datos; ahora el ciclo lo cierra quien conoce el dato.
Rol: diseño y desarrollo full-stack — frontend, backend, módulo WASM y modelo de datos.
Arquitectura
| Capa | Tecnología |
|---|---|
| Frontend | React 18 + Vite + TypeScript + Tailwind 4, TanStack Query |
| Cómputo en el navegador | DuckDB-WASM (motor analítico embebido) |
| Parser de Excel | Rust (calamine) compilado a WASM — 436 KB |
| Backend | FastAPI (Python 3.13) + Uvicorn, arquitectura hexagonal |
| Metadata y auditoría | MongoDB (change streams) |
| Data warehouse | Databricks Unity Catalog / Delta Lake |
| Despliegue | Databricks Apps; logging en Google Cloud Logging |
El monorepo separa apps/client (UI), packages/core (dominio y adaptadores compartidos), packages/ui, backend y wasm_modules. El backend define un puerto IDatabricksAdapter con su adaptador concreto, de modo que los servicios y las reglas de negocio no dependen del SDK de Databricks.
Lo técnicamente interesante
1. Edición masiva con concurrencia optimista a nivel de fila
El problema clásico de “descarga un Excel, edítalo, súbelo”: entre la descarga y la subida otra persona pudo modificar las mismas filas. La solución habitual —una columna updated_at— exige disciplina de todos los escritores y miente si alguien escribe por fuera de la app.
En su lugar uso las pseudo-columnas de Delta Row Tracking (_metadata.row_id, _metadata.row_commit_version): las gestiona el motor automáticamente, no agregan columnas físicas ni bytes de storage, y son robustas ante escrituras externas. El apply se ejecuta como un único MERGE INTO con guard de versión por fila:
WHEN MATCHED AND s.__op__='update'
AND t._metadata.row_commit_version = s.__row_version__
THEN UPDATE SET ...
Si la versión cambió, la fila no se toca y se reporta como conflicto. Un anti-join previo (~1-3 s sobre 500k filas) devuelve la lista exacta de PKs en conflicto, para poder decirle al usuario cuáles filas fallaron y no solo cuántas. La activación del feature es lazy e idempotente, así que las tablas registradas antes de la funcionalidad se migran solas al primer uso.
El mismo patrón se aplica a la edición fila por fila en la UI: el UPDATE/DELETE versionado se resuelve con un MERGE que hace join contra el propio target filtrando por row_commit_version; si la versión no coincide, el join no produce filas, se detectan 0 afectadas y se levanta un error de bloqueo optimista.
2. El diff se calcula en el navegador, no en el servidor
El template de Excel lleva una hoja oculta _meta con row_id, row_version y un SHA-256 de cada fila — no la data duplicada, que para un millón de filas pesaría 200 MB. Al subir el archivo:
- El módulo Rust→WASM convierte la hoja a CSV RFC 4180 en el propio navegador.
- DuckDB-WASM ingiere ambas hojas y calcula inserts, updates y deletes con SQL puro: anti-joins más comparación de hash, recomputado con la misma fórmula que el backend (
sha256(CONCAT_WS(CHR(31), ...))con columnas en orden alfabético). - Exporta el changeset como Parquet comprimido con ZSTD y lo sube por multipart.
El servidor solo deposita el Parquet en un Unity Catalog Volume y lanza un MERGE INTO ... USING read_files(...). Cero procesamiento fila a fila, cero SQL gigantes con miles de VALUES: el diff escala con el CPU del cliente y el apply es una sola sentencia. El archivo de staging se borra siempre en el finally, haya éxito o fallo.
3. Streaming Arrow de extremo a extremo, sin JSON
Las tablas de dimensión se leen de Databricks en formato ARROW_STREAM con EXTERNAL_LINKS: el backend descarga los chunks vía URLs presignadas, los concatena y entrega un Arrow IPC stream que DuckDB-WASM inserta directamente en memoria (insertArrowFromIPCStream) preservando el schema tipado del warehouse. No hay serialización a JSON en ningún punto del camino. Sobre esa tabla en el navegador el usuario filtra, ordena y edita con latencia de milisegundos.
4. Paginación keyset genérica sobre claves compuestas
Para las fact tables, que no caben en memoria, implementé paginación por cursor en vez de OFFSET, con soporte para PKs compuestas y direcciones de orden mixtas: cuando todo el orden es ascendente usa la forma compacta de tupla (a, b) > (?, ?); con direcciones mezcladas genera la expansión lexicográfica disyuntiva. Los filtros del cliente pasan por una lista blanca de operadores y viajan como statement parameters tipados, con quoting de identificadores — sin concatenación de strings del usuario en el SQL.
5. Agente de creación de proyectos con tool-calling
Un endpoint recibe una instrucción en lenguaje natural más archivos Excel adjuntos y ejecuta un bucle de tool-calling —máximo 12 turnos— contra un endpoint de Databricks Model Serving vía su API HTTP compatible con OpenAI. Las herramientas expuestas al modelo no son wrappers nuevos: despachan a los mismos servicios de dominio que usa la UI (create_project, provision_table_from_excel, register_table_in_project, add_users_to_project), de modo que el agente hereda las validaciones y el registro de auditoría existentes. Cada paso —pensamiento, llamada a herramienta, resultado— se emite como evento SSE y el frontend lo renderiza en vivo.
El provisioning de tablas desde Excel valida que la tabla destino no exista, hace el CREATE TABLE ... USING DELTA con Row Tracking activado desde el nacimiento, sube el Parquet al Volume e inserta con read_files.
6. Autorización en cascada e integración con la identidad corporativa
La identidad llega por headers del proxy de Databricks Apps (X-Forwarded-Email). El rol se resuelve centralizadamente contra grupos SCIM de Databricks con caché y fallback en vivo cuando la caché devuelve vacío, para no denegar acceso por un miss. El permiso se evalúa en cascada proyecto → tabla, con herencia si la tabla no declara restricciones, y por email o por grupo. Toda operación mutante queda registrada en una colección de auditoría con validación de rol y acción previa.
7. Observabilidad en tiempo real con fanout de un solo change stream
El dashboard de operaciones se alimenta de un WebSocket sobre MongoDB change streams. En vez de abrir un stream por cliente, un único stream compartido hace fanout a todos los conectados, con filtrado por proyecto en el servidor y un envelope normalizado que aísla al frontend del formato interno de Mongo. Los KPIs y las series temporales se calculan con pipelines de agregación acotadas a los proyectos accesibles del usuario.
8. Assets WASM servidos desde un Volume de Databricks
El entorno bloquea CDNs externos, así que los binarios de DuckDB-WASM se sirven desde un Unity Catalog Volume a través de un endpoint proxy que hace streaming con los Content-Type y las cabeceras CORS/CORP correctos (application/wasm, Cross-Origin-Resource-Policy), y el worker se instancia vía blob para evitar restricciones de origen cruzado.
Decisiones que definen el proyecto
- Empujar cómputo al cliente. DuckDB-WASM convierte al navegador en un motor analítico real: el diff de un millón de filas no toca el servidor.
- Elegir el mecanismo del motor antes que inventar el propio. Row Tracking en lugar de timestamps manuales;
MERGEsobre Volume en lugar de N queries. - Presupuesto de bundle explícito. Se descartó Arrow IPC desde el WASM (+500 KB) y un writer de XLSX en Rust (+300 KB); CSV con
ALL_VARCHARyopenpyxlen modo write-only logran lo mismo manteniendo el módulo en 436 KB. - El agente reutiliza el dominio, no lo duplica. Las herramientas del LLM son una fachada sobre los servicios existentes.
Alternativas evaluadas y descartadas
| Idea | Por qué se descartó |
|---|---|
Columna física updated_at para concurrencia | Requiere disciplina de cada writer y backfill manual; Row Tracking es automático y robusto ante escrituras externas |
Hoja _meta con toda la data duplicada | Duplica el peso del XLSX (~200 MB para 1M filas); el hash basta para detectar updates |
| Motor de diff propio en Rust/WASM | DuckDB-WASM ya estaba integrado; ANTI JOIN en SQL es trivial y rápido |
| Arrow IPC desde el módulo WASM | +500 KB de bundle; CSV con cast implícito en el MERGE da el mismo resultado |
MERGE INTO ... USING (VALUES ...) inline | Límite práctico de ~1000 filas por sentencia SQL; no escala |
Diff en el backend vía endpoint /preview | Desaprovecha el cómputo del cliente y añade un round-trip por iteración |