Published

SPEC · El pilar dialecto — formato — parser

Connect any source, model it as an ontology, transform it, and operationalize it, analytics, automation and machine learning, under one governed, self-hostable roof. --- Most teams stitch the...

SPEC · El pilar dialecto — formato — parser

Especificación (2026-07-31). Fija el contrato de cómo la aplicación le habla al
Warehouse. Nace de auditar lo que tenemos y de investigar lo que documentan los
líderes (Iceberg, Databricks/Unity Catalog, Trino, Snowflake, sqlglot, DuckDB),
con las decisiones medidas contra nuestro propio stack, no supuestas.
▶ Plan de ejecución: carbon-sql-milestones.md — M0…M7,
con el inventario medido de lo que cada milestone BORRA.
Contexto: warehouse-sql-layer-sota.md ·
junction.md · warehouse-index.md.
Memoria: warehouse-index-pillar, warehouse-narrow-waist, router-junction.


0 · La tesis, en tres frases

El dialecto es un DATO, no una convención. Todo SQL que persistimos declara en qué lengua está escrito y contra qué coordenada resolvía — como hace el spec de vistas de Iceberg, y por el mismo motivo: sin eso, un SQL guardado es una cadena cuyo significado depende de quién la lea.

El parser es UNO, y es el del motor que ejecuta. Dos parsers que deben coincidir son un bug esperando fecha; nuestro caso ya tiene expediente (63 bypasses write-as-read encontrados por el red-team de F4.2). La única fuente sintáctica que no puede divergir del ejecutor es el ejecutor.

La resolución de nombres no es parsing: es un pase aparte, contra Index. Es lo que hacen Trino, Databricks y sqlglot, y lo que nosotros hacemos hoy con cinco heurísticas de expresiones regulares.


1 · Lo que tenemos (auditado, 2026-07-31)

✅ El formato está resuelto — y alineado con el estado del arte

Iceberg v2 + Parquet sobre R2, catálogo Iceberg REST (Lakekeeper), y Apache Arrow IPC como formato de cable (duck-server lo devuelve como canónico; el JSON tipado es sólo el envoltorio del editor). Mismo substrato que Databricks, Snowflake y Trino. Aquí no hay deuda. Con una excepción: las vistas (§4.1).

❌ El lenguaje no está resuelto: son tres lenguas en un endpoint

SuperficieLenguaQuién la implementa
Catálogo / DDLdialecto Databricks: SHOW …, DESCRIBE [EXTENDED|HISTORY], ALTER TABLE … RENAME, CREATE/DROP VIEWregex a mano en la route
MotorSQL de DuckDB, prácticamente verbatim (sólo sustitución de tokens + expansión de vistas a CTE)duck-server
information_schema.columnstabla sintética servida desde PG (jsonb_to_recordset) — y enriquecida preguntándole al motor (duckColumns)una tercera semántica

Ninguna declara un contrato de dialecto. Escrituras: sólo DELETE y MERGE. Funciones de tabla: sólo cuatro.

❌ Y no hay parser: hay dos clasificadores heurísticos que deben coincidir

lib/warehouse/query/sql-parse.ts (Node: extractTableNames, scanTableRefs, extractCteNames, rewriteDottedFromRefs, extractDmlTarget) y classify_statement + _clean_sql (Python, dentro de duck-server). En dos lenguajes distintos, sobre la decisión más peligrosa del sistema: si algo es lectura o escritura, y qué tabla escribe.

❌ Las vistas son una cadena sin contexto

datasets.view_definition guarda el SELECT verbatim: sin dialecto, sin catálogo ni namespace por defecto, sin versión, y mutable (CREATE OR REPLACE machaca). Si mañana el motor cambia, o si el usuario renombra un schema, el cuerpo de la vista significa otra cosa —o deja de significar— y no hay forma de saberlo.


2 · Lo que documentan los líderes (evidencia)

FuenteQué fijaQué nos dice
Iceberg View Specrepresentations[] con dialect requerido; default-catalog (opt) y default-namespace (requerido, es una LISTA de niveles); versiones inmutables; una representación por dialectoEl estándar ya resolvió «¿en qué lengua está este SQL y contra qué resolvía?». No hay que inventarlo
Unity CatalogINFORMATION_SCHEMA lo sirve el catálogo, por catálogo, consciente de privilegios y auto-filtradoLas preguntas de catálogo no las contesta el motor: no sabe quién pregunta
Trinoparser ANTLR en un módulo propio, separado del análisis y de la planificaciónParse ≠ análisis ≠ resolución. Son fases
sqlglotqualify = qualify_tables(db, catalog) + qualify_columns(schema), con el schema como {catalog: {db: {table: {col: type}}}}La resolución es un pase sobre el AST alimentado por el catálogo — y su forma es exactamente Warehouse›Catalog›Schema›Table
Federación de vistas Iceberg (2026)buenas prácticas: referencias totalmente cualificadas, representaciones duales, test en cada motor; propuesta de dialecto "ANSI"Portabilidad = cualificar + declarar, no = «escribir SQL estándar y confiar»
SubstraitIR de planes; DataFusion produce y consume, Velox consume, Gluten serializa el plan físico de SparkEl escalón siguiente, cuando el motor deje de ser uno

2-bis · Cotejo con Cloudflare, Databricks, Snowflake y Google

Medido en documentación oficial el 2026-07-31. Siete ejes; en cada uno, qué hacen ellos, qué hacemos nosotros, y el veredicto.

EjeCloudflare (R2 SQL)DatabricksSnowflakeGoogle (BigQuery)Carbon
MotorDataFusion (comprado, no construido)Spark + Photon (C++ vectorizado)propiopropioDuckDB (comprado) ✅
Dialectoel de DataFusionANSI ON por defecto (Spark 4 / DBR 17+)Snowflake SQLGoogleSQL, ANSI; Legacy SQL se retirasin declarar ❌
Nº de gramáticas11 (ANTLR)112 que deben coincidir
Superficiedeliberadamente estrecha y documentadacompletacompletacompletaestrecha, sin documentar ⚠️
Formato de cableArrow IPC sobre gRPCArrowArrowArrowArrow IPC
Quién contesta la metadatael catálogoUnity Catalog (por catálogo, filtrado por privilegios)el catálogoBigLake MetastoreIndex… salvo un hilo ⚠️
Escritura sobre catálogo AJENOforeign Iceberg = read-onlyexternal-catalog Iceberg = read-onlycatálogo propio ⇒ escribimos ✅

Dónde coincidimos (y no por casualidad)

El motor se compra. Cloudflare —que tiene toda la ingeniería del mundo para escribir el suyo— construyó R2 SQL sobre Apache DataFusion. Nosotros sobre DuckDB. Lo que ellos construyeron es lo que de verdad les distingue: el planificador que poda con las estadísticas de Iceberg, y el reparto del trabajo. La lección no es «usa DataFusion», es dónde ponen su ingeniería propia: en el plano de control y la planificación, no en el ejecutor. Es exactamente donde está Junction.

Arrow es el cable. R2 SQL devuelve resultados por gRPC serializados en Arrow IPC. Nosotros ya servimos Arrow IPC como formato canónico de duck-server. Coincidencia plena, y sin habérnoslo propuesto.

La gobernanza vive en el catálogo, no en el motor. Unity Catalog sirve el INFORMATION_SCHEMA por catálogo y filtrado por privilegios. Es nuestro diseño (Index contesta, el motor queda ciego) — con un hilo suelto que la spec cierra en F4: buildInfoSchemaRows todavía le pregunta al motor longitudes y precisiones.

Quien no es dueño del catálogo, no escribe. Databricks marca las foreign Iceberg tables como read-only; Snowflake, las de catálogo externo, igual. Es la misma disciplina que ya aplicamos con el perímetro de escritura (warehouse-writer como único escritor). Nosotros somos dueños de nuestro Lakekeeper, así que escribimos — pero la regla de que la capacidad depende de quién posee el catálogo es la que sostiene el perímetro, y conviene tenerla escrita.

Dónde nos apartamos — y sólo una de las tres es defendible

① Nadie tiene dos gramáticas. Nosotros sí. Cloudflare hereda la de DataFusion, Trino y Spark tienen una ANTLR, Snowflake y Google la suya. Todos: exactamente una. Nosotros tenemos dos heurísticas de regex, en dos lenguajes, decidiendo si algo es lectura o escritura. Esto no es una elección de diseño distinta: es la única divergencia sin precedente en el sector, y es la que resuelve C3.

② Todos nombran su dialecto; nosotros no. «GoogleSQL». «Snowflake SQL». «ANSI por defecto». Y Google no sólo nombra el suyo: está retirando el otro — Legacy SQL deja de estar disponible tras el 1 de junio de 2026 para quien no lo use. Un dialecto es algo que se declara, se versiona y se retira. Nosotros decimos «paridad Databricks», que es una aspiración de superficie, no un contrato. Lo resuelve C2.

③ Nuestra superficie estrecha es correcta — pero por accidente. Aquí está el matiz más útil del cotejo, y es a favor de Cloudflare. R2 SQL también tiene una superficie deliberadamente incompleta: ordena sólo por columnas de la clave de partición, va bien en filtros y las agregaciones complejas aún no. Y lo publican. Estrechar la superficie no es una vergüenza que ocultar: es una forma legítima de enviar antes, siempre que esté declarada.

Lo nuestro no está declarado, y es peor que eso. El SQL Editor admite sólo DELETE y MERGE, y el propio código dice por qué ya no debería:

«el MOTIVO de esta restricción ha desaparecido — se rechazaban por preservar una
identidad de fila sintética que ya no existe […] La matriz de verbos está PENDIENTE
DE RE-MEDIR»
lib/compute/junction.ts:1295

La de Cloudflare es una decisión; la nuestra es un resto. Esa es la diferencia real, y no se arregla ampliando la superficie a lo loco: se arregla midiendo la matriz de verbos y publicando el resultado, como hacen ellos. Va a F5.

Un eje donde la industria valida algo que ya elegimos

Databricks activó ANSI por defecto y con ello cambió el comportamiento ante entradas inválidas: lanza excepción en vez de devolver null. Es la misma tesis que ya gobierna nuestra puerta —fail-loud sobre fail-silent— aplicada al dialecto. Cuando C2 fije duckdb como dialecto de registro, la pregunta hermana es qué hacemos ante desbordamiento, división por cero y casts imposibles. Que el motor sea estricto es una propiedad del contrato, no un detalle de configuración, y conviene fijarla en la misma decisión.


3 · La medición que decide el diseño

Los candidatos de parser para Node, comprobados:

OpciónEstado realVeredicto
sqlglot-tsv0.1.5, 5 releases, npm 2026-03La forma correcta, DuckDB cubierto — pero demasiado joven para ser el eje
node-sql-parserv5.4.0, 180 releases, maduroNo tiene dialecto DuckDB (PostgreSQL es aproximación, no equivalencia)
parser del propio DuckDB (json_serialize_sql)medido abajoDivergencia CERO con el ejecutor, por construcción

Medido en DuckDB 1.5.4 (C:\tmp\parser-probe.py), la cobertura real:

SELECT · CTE+JOIN · FQN citado 3-partes · SHOW ALL TABLES · DESCRIBE  → PARSEA (SELECT_NODE)
EXPLAIN · DELETE · MERGE · INSERT · UPDATE · CTAS · CREATE VIEW
ALTER RENAME · WITH…INSERT · ATTACH · COPY                            → "Only SELECT statements…"

Dos consecuencias, y son las que arman la spec:

① Es un oráculo de lectura/escritura infalsificable. Si json_serialize_sql acepta, es un SELECT — lo dice la gramática del motor que va a ejecutarlo. Si rechaza, no lo es. WITH … INSERT y ATTACH/COPY caen del lado correcto sin una sola expresión regular. Eso cierra por construcción la clase de bypass write-as-read que el red-team encontró 63 veces.

② Permite reescribir por AST, no por sustitución de texto. Verificado el bucle completo:

SELECT a, SUM(b) FROM ventas WHERE a > 1 GROUP BY a
   → json_serialize_sql → AST → reescribir nodos BASE_TABLE → json_deserialize_sql →
SELECT a, sum(b) FROM lake."main.default".ds_38b26211… WHERE (a > 1) GROUP BY a

El motor descubre las tablas (no las adivina un escáner) y cita la identidad física él mismo. rewriteDottedFromRefs, scanTableRefs, extractCteNames y replaceTableToken dejan de tener trabajo.


4 · Los tres contratos

C1 · FORMATO — adoptar la forma del Iceberg View Spec para las vistas

Iceberg v2 + Parquet + Arrow se quedan como están. Lo que cambia es cómo se persiste un SQL. Toda vista (y todo artefacto SQL guardado: la definición de un paso de pipeline, una expectativa, una consulta programada) lleva, en Index:

CampoOrigenPor qué
sqlel cuerpo, verbatimlo único que hay hoy
dialectrequerido — "duckdb"sin esto, cambiar de motor invalida silenciosamente el corpus
default_catalogopcional; si falta, el catálogo de la vistaresuelve las referencias sin catálogo
default_namespacerequerido, lista de niveles (["main","default"])resuelve las referencias de un solo identificador — y es multi-nivel de nacimiento, como nuestro main.default
schema_idel esquema con el que se creódetectar deriva de esquema
version_identero, inmutableun CREATE OR REPLACE crea versión nueva; no machaca

No inventamos un esquema: adoptamos el del spec, campo por campo. Cuando las vistas migren a objetos-vista de Iceberg de verdad, la migración será un movimiento de sitio, no una traducción. Y la puerta queda abierta a lo que el spec ya permite: varias representaciones de la misma vista, una por dialecto, el día que haya un segundo motor.

Regla de escritura de vistas: el cuerpo se persiste cualificado, no bare. Es la primera recomendación de la guía de federación de 2026, y elimina la dependencia del contexto de sesión.

C2 · DIALECTO — declarado, y uno

Nuestro dialecto de registro es duckdb. No «SQL estándar» (no lo es, y decir que lo es es la mentira que hace perder tardes), no «paridad Databricks» (eso es una aspiración de superficie, no un contrato).

Se separa explícitamente en dos gramáticas, porque tienen dueños distintos:

Gramática de catálogoGramática de query
QuéSHOW …, DESCRIBE …, ALTER TABLE … RENAME, CREATE/DROP VIEWtodo lo demás
Tamañocerrada, ~6 comandosabierta, la de DuckDB
Dueñonuestrodel motor
Quién la reconoceun reconocedor explícito, versionado y testeado — no un dialectoel parser del motor
Quién la contestaIndexel motor

Que la de catálogo sea nuestra es una decisión, no una deuda: es pequeña, cerrada y de forma Databricks a propósito (es lo que el usuario espera teclear). Lo que no vale es que se confunda con la otra ni que crezca.

C3 · PARSER — una autoridad, un verificador que falla cerrado

SQL del usuario
   ├─ ¿casa la gramática de CATÁLOGO (cerrada)?  ──sí──▶  INDEX  (nunca llega al motor)
   └─ no
      ├─ pelar EXPLAIN (caso cerrado y conocido)
      ├─ json_serialize_sql  ──acepta──▶  ES LECTURA
      │        └─ AST → PASE DE RESOLUCIÓN contra Index
      │                 · descubre tablas (nodos BASE_TABLE)
      │                 · autoriza cada una (tenencia, purpose, RLS)
      │                 · reescribe a identidad física
      │                 · expande vistas (con SU dialecto y SU default_namespace)
      │           → json_deserialize_sql → SQL cualificado
      └─ rechaza ──▶ NO ES LECTURA
               └─ gramática de ESCRITURA (cerrada: DELETE | MERGE)
                     · destino DECLARADO fuera de banda (ya es así hoy)
                     · autorizado contra Index

Y el cambio de rol que resuelve el problema de los dos clasificadores:

Node deja de mandar SQL a secas: manda SQL + INTENCIÓN DECLARADA (read |
write + destino). duck-server deja de ser una segunda autoridad semántica y
pasa a ser un verificador que falla cerrado
: comprueba con su propia heurística
gruesa que lo recibido concuerda con la intención declarada y, ante cualquier
discrepancia, rechaza
.

Eso convierte «dos parsers que deben coincidir» —una carrera que se pierde tarde o temprano— en «una autoridad + un guardián que se cierra cuando no entiende». La defensa en profundidad de F4.2 (write_target impuesto server-side) se mantiene intacta: sigue siendo el cerrojo que no depende de que Node acierte.


5 · La sinergia con Index (el tercer pilar)

Todo lo anterior converge en que Index es el analizador, y es lo que ya sostiene el resto de la arquitectura:

  1. Index alimenta el pase de resolución. El mapa que necesita — {catalog: {schema: {table: {col: type}}}}, la forma exacta que consume el qualify de sqlglot — es literalmente lo que Index ya guarda (coordenada + schema SSOT). No hay que construir un catálogo nuevo: hay que exponer el que existe con esa forma.
  2. Index contesta information_schema y SHOW/DESCRIBE, como Unity Catalog: por catálogo y filtrado por privilegios. Esto cierra el hilo suelto que queda hoy — buildInfoSchemaRows le pregunta al motor (duckColumns) longitudes y precisiones que el catálogo conoce.
  3. Index posee el dialecto y la procedencia de cada SQL guardado (C1). Es donde ya viven el tier, el linaje y la gobernanza: el dialecto es metadato del mismo tipo — lo que el catálogo sabe y el motor no puede saber.
  4. El motor sigue ciego. Recibe SQL cualificado e intención declarada. Nunca un nombre de usuario, nunca una coordenada lógica, nunca una decisión de política.

6 · Fases

  • F0 · Oráculo de lectura (inerte, medible). Cablear json_serialize_sql como clasificador en sombra: se ejecuta junto a classifyForRouting, se LOGuea el diff y no decide nada. Cierra la pregunta «¿cuántas veces discrepan hoy?» con datos antes de tocar el camino real. Prerrequisito: exponer el serializador por duck-server (o duckdb en proceso en Node) — decisión de §7.
  • F1 · El oráculo pasa a decidir. classifyForRouting se retira. Node declara la intención; duck-server verifica y falla cerrado ante discrepancia.
  • F2 · Resolución por AST. El pase de resolución sustituye a rewriteDottedFromRefs
    • scanTableRefs + extractCteNames + replaceTableToken. Gate: paridad diferencial contra el plan actual sobre el corpus de queries reales — el mismo patrón de harness que ya usamos (f3-equivalence, f1-differential-parity).
  • F3 · Contrato de vistas (C1). Columnas nuevas en Index; escritura cualificada; versiones inmutables. Backfill: las vistas existentes se re-cualifican con el default_namespace que tuvieran al crearse — y las que no resuelvan se marcan, no se adivinan.
  • F4 · information_schema y SHOW/DESCRIBE 100% desde Index. Se corta duckColumns.
  • F5 · Re-medir la matriz de capacidades contra DuckDB (hoy marcada como no verificada en docs/warehouse-sql/), y completar los docs con la estructura real — que es lo que este pilar produce.
  • Diferido · Substrait. Cuando haya un segundo motor, el plan viaja como IR y el dialecto deja de ser el contrato de portabilidad. No antes: hoy sería complejidad sin consumidor.

7 · Decisiones del owner (antes de implementar)

  1. RATIFICADA (2026-07-31) · Dónde vive el parser: en duck-server, y en la MISMA llamada que ejecuta.

    Las tres opciones que se barajaban cayeron por medición. El suelo de un salto Node→duck-server es 221 ms de mediana (7 muestras), y una lectura real de main.test son 236 ms: el salto ES el coste; el trabajo de la query es ruido. Luego un endpoint /parse separado —la recomendación previa— son dos saltos: ~450 ms donde hoy hay ~236. Duplicar la latencia del editor para ganar corrección no es un intercambio aceptable. duckdb en proceso en Node mantiene dos binarios en sincronía; sqlglot-ts (v0.1.5) no es el parser del ejecutor.

    La forma adoptada — parsear, resolver y ejecutar en UNA llamada. Node manda: SQL lógico · la rebanada de catálogo ya autorizada (nombre → identidad física, que buildWarehouseCatalog ya construye entera hoy) · la intención declarada. duck-server parsea con su propio parser, resuelve estructuralmente contra esa rebanada y ejecuta.

    PropiedadCómo queda
    Latenciaun salto, igual que hoy
    Divergencia parser↔ejecutorinexistente: hay un solo DuckDB
    Tablas no autorizadasrechazo duro por construcción — hoy un escáner de regex puede no ver una tabla y dejarla pasar
    Tamaño de la rebanada~143 tablas del mayor workspace ≈ 11 KB

    La objeción, y por qué se acepta: contradice literalmente el principio «el motor recibe identidades ya resueltas». No lo contradice en lo que importa — el motor no decide: aplica una lista blanca que Node ya decidió. La política se toma aguas arriba; lo que baja es su resultado. El cerrojo de tenencia en escritura (write_target, F4.2) queda intacto.

    Consecuencia en el plan: M2 y M3 se funden en un solo milestone.

  2. SHOW/DESCRIBE parsean como SELECT en DuckDB (medido). ¿Los sigue interceptando Index antes del oráculo —recomendado, son preguntas de catálogo— o se deja que el motor conteste algunas? Afecta a qué ve el usuario en un warehouse multi-tenant.

  3. Alcance del contrato de dialecto (C1). ¿Sólo vistas, o todo SQL persistido (pasos de pipeline, expectativas, consultas guardadas)? Cuanto más tarde se extienda, mayor el backfill.

  4. La gramática de catálogo, ¿se congela? Proponemos cerrarla en los ~6 comandos actuales y que crecer requiera decisión explícita. Sin eso vuelve a ser un dialecto por acumulación.

  5. Pineado de DuckDB y extensiones. Toda la propiedad «un solo parser» descansa en que el parser y el ejecutor sean la misma versión. Hoy las extensiones se rebajan con install_mode=REPOSITORY en cada arranque. Es prerrequisito, no higiene.

  6. RATIFICADA (2026-07-31) · Estrictez: los defaults de DuckDB SON el contrato — pero se declaran explícitamente.

    Medido sobre DuckDB 1.5.4, no supuesto:

    CasoComportamiento por defecto¿ANSI?
    Desbordamiento de enterolanza OutOfRangeException
    Cast 'abc'::INTEGERlanza ConversionException
    Cast estrechante 300::TINYINTlanza
    Fecha inválida '2026-02-30'lanza
    Casting implícito desde VARCHARya desactivado (old_implicit_casting=false)
    División por ceroinf (float) · NULL (entera)

    DuckDB ya es ANSI-estricto exactamente donde Spark 4 rompió compatibilidad. No hay nada que activar. Y para el único hueco no existe palanca: con ieee_floating_point_ops=false, 1/0 pasa de inf a NULL pero sigue sin lanzar. Hacerlo ANSI exigiría reescribir el SQL del usuario — justo la clase de cosa que este pilar existe para no hacer.

    Lo adoptado:

    • Los defaults de DuckDB son la estrictez de Carbon SQL. No se pelea con el motor.
    • La divergencia se PUBLICA (división por cero → NULL/inf, no error). Principio Cloudflare: una limitación declarada es una decisión; una callada es un resto.
    • Se fijan EXPLÍCITAMENTE en build_engine aunque coincidan con el default. Sin declararlas, nuestra estrictez es lo que DuckDB decida y puede moverse entre versiones sin que nadie toque el repo. Es exactamente el fallo de SUPPORT_NESTED_NAMESPACES, que costó una tarde: un default ajeno gobernando nuestro contrato.
    • errors_as_json=true. No es estrictez: es otra rueda reinventada. Hoy parse_duck_error extrae el número de línea con una regex sobre el texto; el motor entrega exception_type, error_subtype, position y candidates (sugerencias «¿quisiste decir?») estructurados.
  7. ¿Publicamos la superficie? (sale del cotejo, §2-bis). Cloudflare documenta los límites de R2 SQL. Recomendado: publicar los nuestros —verbos admitidos, funciones de tabla, lo no soportado— en docs/warehouse-sql/. Es el entregable natural de F5.


Fuentes