SQL Editor → DuckDB: retiro del shim legacy + DML tipado e2e
Plan de migración (2026-07-09). Síntesis de un workflow de 8 lectores sobre el subsistema SQL Editor. Objetivo del owner: retirar por completo la escritura/lectura legacy del SQL Editor y adoptar DuckDB (duck-server, vivo en
duck-production-ef4a.up.railway.app) en todo su espectro — escrituras sólidas sobre el Warehouse, SQL sólido y TIPADO, verificado E2E. Cruzar con: duckdbengine.md · compute-integration.md (Junction). Memoria: duckdb-engine-decision, warehouse-sql-query, facade-contract-review.
0 · El diagnóstico (una línea)
Hoy el SQL Editor no tiene motor: el modo internal traduce Spark→Postgres por regex (dialect.ts) y ejecuta un CTE sobre dataset_rows.data JSONB (translateQuery/baseCteString/jsonbCast), sirviendo un information_schema/SHOW/DESCRIBE falsos sintetizados desde la tabla PG datasets. El Warehouse real (Iceberg/R2/Lakekeeper) nunca se toca, y el tipo se pierde por el cable (la respuesta son filas sin schema; el frontend re-infiere por Object.keys(rows[0])). duck-server ya lee Iceberg real vía Arrow, pero es un ejecutor delgado (sin tipos-en-JSON, sin errores estructurados, sin resolución de nombre lógico, sin identidad/workspace, guard read-only).
1 · Arquitectura objetivo
SqlEditorTab / SqlConsoleDock
│ POST /api/sql-editor/execute (la route CONSERVA su rol de puerta PDP/PEP + emisor del envelope)
▼
clasificador (resuelve/valida — fase durable)
│
▼
JUNCTION (lib/compute/gateway.ts — semilla = la facade loadItemForConsumption)
│
▼
duck-server POST /query (Arrow IPC TIPADO)
│
▼
Lakekeeper (Iceberg REST) → R2
- La route deja de ser motor; sigue siendo la puerta (gobernanza) y el traductor del envelope
{success,data,totalRows,executionTimeMs,columns[],...}. - Lectura: la route resuelve el nombre lógico → coordenada física (
iceberg_namespacedesdedatasets.schema_id), DuckDB scanea Iceberg y devuelve Arrow con schema; la route decodifica Arrow→filas +columns:[{name,type}]. - Metadata:
information_schema/SHOW/DESCRIBE del catálogo REAL víaduckdb_tables()/duckdb_columns()(el catálogo REST no exponeinformation_schema— duck-server shimea su FORMA). Tier (datasets.tier) y DESCRIBE HISTORY (dataset_transactions) siguen PG-sourced y se fusionan. - Escritura: el gate read-only se abre solo para DML ruteado por la puerta, ejecutado por duck-server envuelto en
withNativeControlPlane(ledgerdataset_transactions→ DML → commit ledger +iceberg_sync_log+ stats dedatasets). Gobernanza (RLS/workspace/purpose) enforced aguas arriba, nunca en un path directo UI→duck-server.
2 · Fases (inert-first: shadow → flip → retire)
| Fase | Meta | Riesgo clave |
|---|---|---|
| F1.0 · Contrato duck-server + cliente Node (inerte) | Dar a duck-server lo que el editor necesita ANTES de enrutar tráfico: columnas tipadas, errores estructurados, resolución de nombre, claim de workspace. + lib/warehouse/query/duck-client.ts (fetch Arrow). Cero cambio observable. | Claim de workspace no firmado = IDOR (Bearer compartido, sin identidad de caller). |
| F1.1 · Read-shadow + harness diferencial | Cablear el SELECT internal a duck-server EN SOMBRA (ejecuta ambos, sirve PG, diffea) + construir la red de paridad que hoy NO existe. Extraer helpers durables de route.ts a classify.ts/catalog.ts para testear. | Sin harness el flip va a ciegas sobre divergencias Spark↔DuckDB. |
| F2 · information_schema / SHOW / DESCRIBE reales | Servir el catálogo desde DuckDB/Lakekeeper (arregla character_maximum_length de raíz); fusionar lo que solo vive en PG (tier, history). | El catálogo REST no expone information_schema; tipos Iceberg pueden dar NULL en precision. |
| F3 · Flip read + tipado + retiro del shim | DuckDB autoritativo en lecturas; respuesta con tipos reales; borrar dialect.ts + translator JSONB + info_schema sintetizado. | El tipado depende del path Arrow; retirar sin paridad completa rompe queries. |
| F4 · DML gobernado (la bestia) | Abrir el gate para DML por la puerta; reconciliar con el write-terrain Tier B; read-your-writes. | Split-brain ledger↔commit-Iceberg; CTAS huérfano; pérdida de tier/lineage. |
| F5 · Junction formal + retiro del stub PG | El editor deja de tener lógica de motor; llama a Junction; se elimina el WRITE legacy (item-consumption.ts:605-611 THROW). | Consolidar sin contrato Junction claro reintroduce doble fuente de verdad. |
3 · Retire list (lo que se elimina, con refs)
lib/warehouse/query/dialect.ts— archivo entero (Spark→PG). Y su call-sites enroute.ts:45,488,869,1157,1192.lib/warehouse/query/dialect.test.ts— entero (pinnea salida PG de un módulo que se borra; NO portar).route.ts:843-897translateQuery·:905-927baseCteString(el core del shim:data->>'col'::cast FROM dataset_rows) ·:299-310jsonbCast.route.ts:77-105extractTableNames+:113-118rewriteDottedFromRefs+:137-146mergeWithCtes(munging regex).route.ts:1155-1177ramainformation_schema+:813-831buildInfoSchemaRows(las 8 columnas fijas → bugcharacter_maximum_length).route.ts:175-182extractExplainPrefix(DuckDB tiene su EXPLAIN).route.ts:151-166isReadOnly+:1135-1140— NO se borra: se ABRE en F4 para DML gobernado.lib/lakehouse/sql-source-hydration.ts— rama hidratada inline-jsonb (la facade como puerta sobrevive; la materialización a jsonb no).item-consumption.ts:605-611— el THROW del PG-sink enwrite()(WRITE legacy del Paso 6) → ruta compute/Junction.- UI:
SqlEditorTab.tsx:40-56 inferColumnType+:641 Object.keysySqlConsoleDock.tsx:166→ degradan a fallback trasdata.columnstipadas.
4 · Plan de tipado (el "SQL tipado")
Cadena canónica sin vocabulario paralelo — todo desemboca en el mismo set BASE_TYPE:
- Inverso Arrow→BASE_TYPE: crear
arrowTypeToBaseType(field)co-ubicado enlib/types/iceberg-types.tsjunto aBASE_TYPE_TO_ICEBERG, para que los dos sentidos no diverjan. Consistente conarrow_type_for_iceberg(ml-runnertypes.py) → round-tripBASE_TYPE→Iceberg→Arrow→BASE_TYPEestable, con test. - Overlay semántico (crítico): los 12 JSON_ENCODED (Object/Map/Array/Vector/TimeSeries/GeoPoint/…/Media) se serializan a Iceberg
stringy vuelven como ArrowUtf8. El schema Arrow da el tipo físico pero no distingue String real de Object aplanado → fusionar el schema Arrow condatasets.schemapor nombre de columna para decode (decodeIcebergRow) + label. En el MISMO lugar queread-clientya lo hace para NDJSON. - Canal de schema: añadir
columns:[{name,type}]al envelope; el UI consume el tipo real (Object.keys/inferColumnType= fallback).TypeBadgesigue pornormalizeDbTypepero alimentado por tipo real. - Precisión:
DECIMAL/BIGINT>Number.MAX_SAFE_INTEGER→ string tipado (evita pérdida silenciosa);renderResultCelldecide por tipo declarado, no portypeof. - Dependencia dura: el tipado sólido solo existe por el path Arrow (
format=arrow). El JSON lossy (solocolumn_names) no sirve.
5 · Plan de gobernanza (sin bypass)
La puerta (loadItemForConsumption/Junction) es el único PDP/PEP; no hay ruta directa UI→duck-server. Reconciliación con el write-terrain Tier B: factorizar withNativeControlPlane (iceberg-native-write.ts:155) para que el paso DATA-plane sea pluggable al brazo "duck-server ejecuta el DML". Bundle atómico preservado: ledger open → duck DML (commit Iceberg) → commit ledger → iceberg_sync_log succeeded+snapshot (alimenta el oráculo de frescura; sin él los reads STRICT sirven stale) → update datasets (row_count/size/schema/PK/schema_version). Invariantes: unicidad de coordenada (workspace,schema_id,name) → un CTAS mint-ea datasets y reserva la coordenada ANTES del write físico; tier medallón vía tierFromName; lineage derivando sourceRefs del FROM/USING → mergeUpstreamProvenance + arista con el nodo Warehouse. Credencial: llaves R2 estáticas interim (vending roto #792); slot gobernado = norte.
6 · Plan E2E (hoy = CERO)
No hay NI UN test que ejerza route.ts o corra SQL real asertando resultados. El único test pinneado (dialect.test.ts) es de un módulo que se borra = falsa cobertura. Harness, en orden:
- Extracción helpers de
route.ts→classify.ts/catalog.ts→classify.test.ts(resolución de nombres, gate, EXPLAIN, LIMIT). - Diferencial (la red ausente):
scripts/duckdb/f1-differential-parity.ts— diffea PG-shim↔duck-server por query (filas, orden, NULLs, casts,DAYOFWEEK0/1-based,REGEXP_EXTRACT”vs NULL,DATEDIFFpor unidad). Gate del flip F3. - E2E tipado:
scripts/duckdb/f1-sql-editor-harness.ts— SELECT tipado + round-trip Arrow→BASE_TYPE + overlay de los 12 complejos, enmain.testdel Lakekeeper de prod (patrón =pipeline-output-canary.ts). - info_schema real (
character_maximum_lengthfunciona). - DML (F4):
scripts/duckdb/f4-dml-harness.ts— cada verbo,rows_affected/snapshot_id, read-your-writes, tier, mint dedatasets, MoR positional-delete.
7 · Top riesgos (con mitigación)
- Split-brain PG-ledger ↔ commit-Iceberg — un snapshot DuckDB committeado NO es reversible desde PG. →
reconcile-from-catalog(sweep que lee el snapshot real y repara el ledger) o compensación idempotente. Owner de la frontera atómica a decidir. - Divergencia semántica silenciosa Spark/PG↔DuckDB. → harness diferencial gatea el flip F3 ANTES de borrar
dialect.ts. - Doble fuente de verdad de nombres (
datasetsvs Lakekeeper). → un solo owner de la resolución nombre→FQN + un SSOT del catálogo. - IDOR de workspace — duck-server sin identidad corre cualquier SQL sobre TODO el catálogo. → claim firmado / header de confianza solo desde el tier Node.
- Tipado depende de Arrow — si UI/Junction consume JSON lossy, el tipo se pierde. → Arrow como path preferente; fijarlo en el contrato de Junction.
- Precisión numérica (BIGINT/DECIMAL) · Overlay semántico ignorado · Pérdida de tier/lineage · Vending roto (#792) · Posicionamiento de error tras rewrite.
8 · Decisiones abiertas (del owner, antes de implementar)
- Auth duck-server: ¿JWT firmado de workspace, mTLS, o
X-Workspace-Idde confianza del tier Node? (hoy Bearer compartido, sin identidad → riesgo IDOR). - Resolución nombre-lógico→FQN (el linchpin): ¿Node pre-cualifica el FROM (reescribe a
iceberg_namespace) o duck-server bindea? → define qué se retira deroute.tsy el SSOT del catálogo. - DML: directo vs control-plane + quién posee la frontera atómica (reconcile-from-catalog vs compensación) cuando el commit Iceberg tiene éxito pero el update PG falla.
- Transporte: ¿UI/Junction consume Arrow directo, o Node adapta a
{success,data,columns,...}? → define si el tipado va en JSON y si los edits de UI son aditivos. - Alcance del retiro del dialecto: hard-delete en F3 respaldado por el harness, o shim mínimo hasta cerrar paridad.
- CTAS e identidad · Tier (PG vs propiedades Iceberg) · Vistas (Iceberg nativa vs objeto-catálogo PG) · Multi-statement transaccional · DESCRIBE HISTORY · Nivel de test — decisiones de F2/F4 (se cierran al llegar).
9 · Estado de implementación
Decisiones §8.1-8.4 cerradas (2026-07-09): secuencia = harness-first (F1.0+F1.1) → checkpoint; FQN = Node pre-cualifica (datasets = SSOT); auth = JWT de workspace firmado; transporte = Node adapta → envelope (hop duck→Node en JSON tipado con BIGINT/DECIMAL como string; Arrow queda canónico para Junction/UI-directo).
-
F1.0 · contrato duck-server — ✅ HECHO + verificado local (2026-07-09):
/querydevuelvecolumns:[{name,type,nullable}](tipo lógico desde el schema Arrow víapa_type_to_logical), BIGINT/DECIMAL/binary serializados como string (precisión), errores estructurados ({error_type,message,line,detail}), y auth JWT de workspace (require_principal: JWT firmado → Bearer estático → abierto). Commit enfeat/duck-server. Falta el redeploy conDUCK_JWT_SECRETseteado en Railway (Duck + tier Node) para activar el JWT — inerte hasta que la route llame. -
F1.0 · cliente Node — ✅ HECHO + verificado (2026-07-09):
duck-client.ts(firma JWT HS256 + POST JSON tipado + overlay de los 12 JSON_ENCODED víaiceberg-decode.ts),fqn.ts(nombre lógico→lake.<ns>.ds_<id>, espejanative_table_identifierde writer.py),pre-qualify.ts(+test 7/7). Harnessscripts/duckdb/f1-sql-editor-harness.tsVERDE 15/15 contra duck-server: tipos, precisión BIGINT, overlay, timestamptz, error estructurado, write-guard. -
F1.1 · diferencial de paridad — ✅ VERIFICADO contra datasets reales (2026-07-09):
scripts/duckdb/f1-differential-parity.ts animal_events diets→ copias de prod (main.default) con paridad delta=0 (filas + valores idénticos). El diferencial destapó y arregló 2 bugs de integración (commit0402cd1): (a) FQN de namespaces multi-nivel —main.defaultes un schema con punto; DuckDB exige citarlo (lake."main.default"."ds_x"), sin citar daNameListToString NOT IMPLEMENTED; (b) clock-skew JWT →leeway=60s. Nuance F3:timestamptzse renderiza distinto (PG=UTC…Zvs DuckDB=local+02:00) — MISMO instante, normalizar el display en el flip. Nuance F2:duckdb_tables()/duckdb_schemas()NO enumeran namespaces multi-nivel (lazy) → el listado del catálogo debe resolverse por otra vía. Las copiasmain.test(PG=0/Iceberg>0) son artefactos de canary, no divergencia. -
F1.1 · read-shadow — ✅ cableado (flag-gated, INERTE):
shadow.ts+ rama enroute.ts(SQL_EDITOR_DUCK_SHADOW=1→ ejecuta el SELECT también en duck-server pre-cualificado y diffea vs PG, sin tocar la respuesta; skip vistas; try/catch total). Typecheck del proyecto limpio (mis ficheros 0 errores). El diferencial vive en el shadow (loguea rowCountDelta + columnas por query). Pendiente: activarSQL_EDITOR_DUCK_SHADOW=1en un entorno con duck-server alcanzable desde Node + prod PG, correr queries reales y barrer los diffs. El flip/retiro (F3) NO arranca hasta que el shadow salga limpio. -
Deploy consolidado a
main(2026-07-10, PR #26): Railway Duck y Vercel Next despliegan demain. DuckDB CONFIRMADO sirviendo datos reales (query directa al desplegado → filas reales deanimal_eventstipadas). El shadow loguea el diff en Railway (duck-server), no en Vercel: Node manda{pg_rows,pg_cols}en la misma/queryy duck-server emite[shadow] {ok,delta,cols_only_*}(excluye columnas__de identidad). FlagSQL_EDITOR_DUCK_SHADOWacepta1/true/yes/on. Fix colateral:buildCatalogresuelve nombres duplicados al MÁS RECIENTE (arreglabaCOUNT=0sobre tabla con datos). -
F2 · information_schema real — backend ✅ HECHO+verificado (2026-07-10): endpoint
POST /columnsenservices/duck-server/app/routers/metadata.py(motorDuckEngine.columns_for) → carga la tabla (LIMIT 0, el catálogo REST es lazy) y devuelveinformation_schema.columnsREAL con la forma estándar COMPLETA:character_maximum_length,numeric_precision/scale,is_nullable,ordinal_position,column_default. Arregla de raíz el bugSELECT character_maximum_length FROM information_schema.columns→ "column does not exist" (el shim tenía 8 columnas fijas). Verificado contraanimal_events. -
F2 · Node ✅ HECHO+verificado (2026-07-10):
lib/warehouse/query/metadata-client.ts(duckColumns) +buildInfoSchemaRows(route.ts) ahora enriquece con metadata real de/columns(best-effort, gated enDUCK_SERVER_URL, fallback total a null si falla) —data_typese mantiene como el BASE_TYPE (compatibilidad, no divergir) y se AÑADENcharacter_maximum_length/numeric_precision/numeric_scalereales; la CTE (route.ts:1167) ampliada con esas 3 columnas → el bug queda arreglado (la columna existe, real o null).duckColumnsverificado e2e (8 cols, prec 64/32).fetchUserWarehouse+workspace_id. Pendiente F2 opcional: SHOW/DESCRIBE también desde metadata real (hoy PG-sourced, aceptable); tier + DESCRIBE HISTORY siguen PG-sourced. -
F3 · FLIP de lectura ✅ HECHO (flag-gated, REVERSIBLE — el RETIRO NO): rama en
route.ts(helperbuildDuckRefscompartido con el shadow): conSQL_EDITOR_DUCK_READ=1/true/yes/on, el SELECT interno se ejecuta en duck-server (motor real sobre Iceberg, pre-cualificado) devolviendo filas +columns:[{name,type}]TIPADAS, con fallback TOTAL al shim PG si off/vista/EXPLAIN/error. Oculta las cols__de identidad (paridadSELECT *). Verificado e2e (preQualify→duckQuery→envelope tipado sobreanimal_events). DELIBERADAMENTE NO se retira nada (dialect.ts,translateQuery,baseCteString,jsonbCast, info_schema sintetizado siguen como fallback) — el RETIRO es el gate de "shadow limpio" (validación e2e diferida). EXPLAIN + vistas siguen por PG. Nuance: el flip NO reescribe dialecto Spark→PG (DuckDB habla SQL real) → funciones Spark-only (DATEDIFF, REGEXP_EXTRACT…) pueden diferir; el shadow/validación las caza antes del retiro. -
F3-fin · DuckDB AUTORITATIVO por defecto ✅ (2026-07-10): el flip dejó de ser opt-in → el SELECT interno va a DuckDB por DEFECTO (kill-switch
SQL_EDITOR_DUCK_READ=0/false/off), con fallback PG en error + PG para vistas/EXPLAIN. Gate pasado: sweep de 12/12 queries diversas (CTE+window, subquery, CASE, HAVING, string/math/cast, LIKE/ILIKE, EXTRACT, UNION) verde en DuckDB + diferencial delta=0 +POST /query 200en prod. DML del motor probado (CTAS/INSERT/UPDATE/DELETE/DROP e2e sobremain.test, aislado). HALLAZGO — el BORRADO FÍSICO del shim está BLOQUEADO:translateQuery/dialect.ts/baseCteString/jsonbCastsiguen VIVOS para (a) expandir VISTAS (CREATE VIEW → SELECT expandido) y (b) el EXPLAIN de PG. Retirarlos ahora rompe esos dos. Retire-list real = primero migrar vistas+EXPLAIN a DuckDB, LUEGO borrar. -
F3-RETIRO · Fase A — vistas + EXPLAIN en DuckDB ✅ (2026-07-10, inert-first): desbloquea el borrado. Nuevos módulos:
lib/warehouse/query/duck-plan.ts(buildDuckReadPlan— expande VISTAS a CTEs DuckDB recursivas en orden topológico, base→FQN in-place, guard de ciclos+profundidad,hasViews;duckExplainPrefix— normaliza aEXPLAIN/EXPLAIN ANALYZEnativo, descarta opciones PG) +lib/warehouse/query/sql-parse.ts(extraídos de route.ts:extractTableNames/extractCteNames/rewriteDottedFromRefs— puros, COMPARTIDOS; NO son el shim de dialecto, sobreviven porque DuckDB tampoco parsea namespaces con punto sin comillas). Verificado con unitduck-plan.test.ts14/14. -
F3-RETIRO · Fase B — BORRADO del shim ✅ (2026-07-10). El SQL Editor ya NO tiene shim PG; DuckDB es el motor de lectura de las TABLAS del Warehouse.
⚠️ Matiz verificado en F4 (2026-07-28): "motor ÚNICO" era inexacto. Queda un path SQL sobre Postgres en la route:
information_schema.columns(route.ts, víaexecuteReadOnlyQuery), que sirve las filas como CTE desdejsonb_to_recordset. No es shim: es una tabla virtual del plano de metadata —la propia rama rechaza unirla con tablas reales— y por decisión del owner se queda ahí, coherente conDESCRIBE/SHOW. Lo retirado por F3-RETIRO fue el shim de traducción (dialect.ts/translateQuery/baseCteString/jsonbCast), no toda ejecución en PG. Ver junction-f4-sql-editor.md §1.- GATE PASADO:
scripts/duckdb/f3-views-explain-harness.tsVERDE contra duck-server vivo sobre 2 datasets reales (animal_events,diets): vista simple/anidada/agregación corren sobre el motor + paridad columnas/rowcount vista↔directo + EXPLAIN y EXPLAIN ANALYZE nativos. (Paridad de tablas base ya estaba: sweep 12/12 + diferencial delta=0 en F3-fin.) - BORRADO: ficheros
lib/warehouse/query/dialect.ts+dialect.test.ts+shadow.ts(git rm). Funciones enroute.ts:translateQuery,baseCteString,jsonbCast,buildDuckRefs,qIdent,sLit(muertas). Imports retirados:rewriteDialect,runReadShadow,resolveSqlSource/hydratedFrom/SqlSourcePlan,DuckRef. Call-sites derewriteDialecteliminados (handleCreateView valida solo detectando nombres; info_schema sin rewrite). El read-path es ahora: detectar tablas →buildDuckReadPlan→duckQuery(EXPLAIN con prefijo nativo); en fallo devuelve error estructurado (errorType/linepara el marker Monaco), sin fallback (no hay a qué caer). Kill-switchSQL_EDITOR_DUCK_READy flagSQL_EDITOR_DUCK_VIEWSretirados con el shim. - Verificado: tsc 0 errores en todo el proyecto + vitest
lib/warehouse/query/21/21 + grep sin referencias de código colgando. - SOBREVIVE en PG (self-contained, NO es el shim): el path
information_schema.columns(rows sintéticas +mergeWithCtes/ensureLimit/withExplain/extractExplainPrefix— enriquecido con metadata real de duck en F2); DESCRIBE/SHOW/tier/DESCRIBE HISTORY (metadata-plane PG); DDL curado (CREATE/DROP VIEW, ALTER RENAME).rewriteDottedFromRefssobrevive (movido a sql-parse). EljsonbCastdeapp/api/dashboards/query/route.tses OTRO módulo (dashboards), fuera de alcance.
- GATE PASADO:
Plan vivo. Siguiente: F4 · DML gobernado (la bestia) — abrir el gate para DML por la puerta, ejecutado por duck-server envuelto en withNativeControlPlane (ledger dataset_transactions → duck DML allow_writes:true → commit ledger + iceberg_sync_log succeeded+snapshot + update datasets stats). CTAS con identidad (mint fila datasets + reservar coordenada única (ws,schema_id,name) ANTES del write físico), estampar tier (tierFromName) + lineage (sourceRefs del FROM/USING → arista con el nodo Warehouse), y la decisión de frontera atómica (riesgo #1 split-brain PG-ledger↔commit-Iceberg → reconcile-from-catalog vs compensación idempotente). El motor de lectura ya es DuckDB ✅; el DML del motor ya está probado aislado ✅ — falta la GOBERNANZA. Luego F5 · Junction (lib/compute/gateway.ts). Ver §5/§7.