Junction F2 — abstracción de motor (EngineAdapter) + substrato ⊥ motor
Entregable de F2 (2026-07-10) · ✅ IMPLEMENTADO 2026-07-28. Companion de junction.md + junction-execution-map.md. F2 = la capa-2 de Furnace: convertir el binario
'postgres'|'iceberg'(que MEZCLABA substrato y ejecutor) en dos ejes limpios, y dar a cada motor (DuckDB / Karma / pyiceberg / PG) un adaptador intercambiable detrás de la puerta. Inert-first: se estableció el contrato + los adaptores, sin reescribir los ~30 call-sites. El primer consumidor real =runQuery(F4).Estado: F2.0 + F2.1 + F2.2 hechos, tsc 0, 1030 + 12 tests verdes. Dos premisas del plan original resultaron FALSAS al contrastarlas con el código — corregidas abajo (§2·bis y §3·bis). Siguiente = F3 (harness diferencial).
1 · El modelo afinado: substrato ⊥ motor
El hecho que F2 corrige (verificado en read-router.ts:54,109 y write-router.ts:64,118): el enum 'postgres'|'iceberg' es el SUBSTRATO (dónde viven los bytes), y el MOTOR que ejecuta el substrato iceberg es hoy implícito — el closure iceberg que CADA caller pasa a run<T>({postgres, iceberg}) llama siempre a ml-runner (read-client/iceberg-native-write).
SUBSTRATO (dónde viven los bytes) MOTOR (quién computa)
pg → JSONB en dataset_rows → pg (la query PG del caller — legacy, en retiro)
iceberg → Parquet/Iceberg en R2 → { mlrunner | duckdb | karma } ← PLURAL
La clave: la pluralidad de motor vive en el substrato iceberg — los tres motores hablan Iceberg sobre la misma cintura (Lakekeeper + FQN física). PG es el substrato legacy con su propio motor (la query JSONB). Así, EngineAdapter abstrae los motores de iceberg; el motor pg queda como el closure legacy que se retira a medida que los datasets pasan a nativo.
Substrate (pg|iceberg) y Engine (mlrunner|duckdb|karma|pg) ya existen como tipos (añadidos en F1, lib/compute/contract.ts). F2 los CONECTA al código.
2 · El contrato EngineAdapter (el corazón de F2)
Un adaptador por motor, detrás de la MISMA frontera. Es lo que runQuery (stub de F1) despachará:
⚠️ La firma que este §2 proponía originalmente era FALSA (ponía
runQueryen el contrato base). Se conserva el diagnóstico, pero la firma de abajo es la REAL, la implementada; el porqué está en §2·bis. Si lees §2 y paras, implementas mal.
// lib/compute/engine-adapter.ts — LA FIRMA REAL (implementada 2026-07-28)
export interface EngineCapabilities {
substrate: Substrate; // sobre qué substrato opera (iceberg | pg)
sql: boolean; // ¿acepta SQL de usuario? ⇔ es SqlEngineAdapter
read: boolean; // ¿ejecuta lecturas?
write: boolean; // ¿ejecuta escritura (DML o ingest)?
dialect: 'duckdb' | 'substrait' | 'none'; // (NO existe ningún dialecto 'spark')
available: boolean; // ⊥ capacidades: ¿cableado en ESTE deployment?
unavailableReason?: string;
features: readonly string[]; // whitelist DERIVADA por el harness (F3)
}
export interface EngineAdapter { // el contrato BASE: identidad + capacidades
readonly engine: Engine;
capabilities(): EngineCapabilities; // ⚠️ lee process.env EN CADA LLAMADA (no cachear)
}
export interface SqlEngineAdapter extends EngineAdapter { // EXTENSIÓN: los que sí
runQuery(req: QueryRequest, opts?: { signal?: AbortSignal }): Promise<QueryResult>;
}
export function isSqlEngine(a: EngineAdapter): a is SqlEngineAdapter;
Adaptadores (ninguno inventa un motor; envuelven lo YA construido — o solo declaran):
| Engine | Qué es el adapter en realidad | Estado |
|---|---|---|
duckdb | Wrapper REAL de duck-client (duckQuery, write_target, envelope tipado de F4.1). Delgado en lectura; en escritura NO — ver el aviso de §2·ter. | ✅ implementado |
mlrunner | Solo declaración de capacidades (sql:false). NO envuelve read-client/iceberg-native-write: esa cara ya la consume el composer, y re-envolverla sería una capa hueca sin consumidor. | ✅ implementado |
karma | Declaración con available atado a KARMA_SQL_URL (hoy ausente ⇒ no seleccionable). Sin runQuery hasta acoplar. | inerte por diseño |
pg | Declaración del substrato legacy (sql:false — es la query JSONB del propio caller, no un servicio). | legacy; se retira con el substrato pg |
2·ter · ⚠️ El adapter duckdb NO cierra el control-plane en escritura (prerequisito de F4)
✅ RESUELTO (2026-07-29) — esta sección es registro histórico. F4b cableó DuckDB como 4º data-plane por la puerta (
532bb52;junction.ts:1209): el DML abre ledger, insertaiceberg_sync_logy estampa stats, con fail-loud (ControlPlaneUnrecordedError) en las dos escrituras obligatorias.Dos matices que la sección de abajo no podía anticipar. (1) El razonamiento del split-brain se corrigió: el oráculo no cambia tras un write de DuckDB; el peor modo de fallo real es que un
sync.fullposterior lo oblitere. (2) Cerrar el control-plane no hace atribuible al motor: la txn se abre conattributable:falseporque duck-server no estampasnapshot_properties, así queratifyno puede rescatarla. Ese es hoy el hueco vivo — ver warehouse-scaffolding-retirement.md §7.
Llamar al adapter "un wrapper delgado" es exacto en lectura y engañoso en escritura. engines/duckdb.ts devuelve un CommitResult que refleja lo que el motor commiteó en el catálogo (el CAS de Lakekeeper), pero no toca el control-plane: no registra dataset_transactions, no actualiza datasets.row_count ni el sync log.
Consecuencia si F4 enruta escrituras por ahí tal cual: el oráculo de frescura (icebergFreshness → RPC iceberg_freshness, que lee justo esas tablas) seguirá diciendo "no caught up" después de una escritura exitosa → los lectores STRICT caerán a PG indefinidamente, y PG quedará como proyección desfasada de un Iceberg que sí avanzó. Split-brain silencioso.
Por qué no se resolvió en F2: la primitiva que lo hace bien es withNativeControlPlane (lib/lakehouse/iceberg-native-write.ts:156) y no está exportada — su contrato es un control-plane, N data-planes con el data-plane como closure. Cablear DuckDB como 4º data-plane es trabajo de F4/F5 (f4-governed-dml-approach.md §2.3), no algo que un adaptador deba improvisar. F2 es inerte, así que hoy no lo ejecuta nadie; queda anotado en el punto exacto del código para que F4 no lo descubra en producción.
El registry lib/compute/engines/index.ts mapea Engine → EngineAdapter. runQuery selecciona el adapter por la política de selección (capa-3, hoy: iceberg→duckdb para live-SQL; F4).
2·bis · ⚠️ CORRECCIÓN — runQuery NO puede ser un método del contrato base
La firma de arriba (§2) da por hecho que los cuatro motores implementan runQuery(sql). Al contrastarla con el código real (2026-07-28) resultó falsa en dos de los cuatro:
mlrunnerNO tiene endpoint SQL. Su superficie son ops estructuradas —/lakehouse/read,/aggregate,/ingest,/ingest-parquet,/checksum,/plan-files,/register… (verificado enservices/ml-runner/app/routers/lakehouse.py). No existe/queryni/sql. El doc lo listaba como "✅ construido — wrapper", pero lo construido es un cliente de ops estructuradas, no un ejecutor de SQL.pgno es un servicio al que mandar SQL. Es la query JSONB que ejecuta el propio caller sobredataset_rows. SQL de usuario ahí no significa nada — ese fue exactamente el shim que F3-RETIRO borró (dialect.ts/translateQuery/jsonbCast). Reintroducirlo por la puerta de atrás sería revertir F3.
Un runQuery universal que lanzara en 2 de 4 motores es una capa hueca — justo lo que junction.md §9·principio 12 prohíbe. Forma implementada:
// lib/compute/engine-adapter.ts
export interface EngineAdapter { // el contrato BASE: identidad + capacidades
readonly engine: Engine;
capabilities(): EngineCapabilities;
}
export interface SqlEngineAdapter extends EngineAdapter { // EXTENSIÓN: los que sí aceptan SQL
runQuery(req: QueryRequest, opts?: { signal?: AbortSignal }): Promise<QueryResult>;
}
export function isSqlEngine(a: EngineAdapter): a is SqlEngineAdapter;
Así el tipo dice la verdad sobre qué puede cada motor y runQuery (F4) solo despacha a los que la tienen. mlrunner/pg declaran sql:false y no cargan un método que lanzaría.
Tercer eje añadido — available ⊥ capacidades. Karma puede leer SQL nativo pero hoy no está cableado (falta KARMA_SQL_URL). Mezclarlo con sql haría que un motor no desplegado mintiera sobre sí mismo. Separarlos permite que la capa-3 degrade con criterio: EngineCapabilityError ("no sabe hacerlo" → prueba otro motor) vs EngineUnavailableError ("no está desplegado" → reintentar con el mismo no sirve).
export interface EngineCapabilities {
substrate: Substrate; sql: boolean; read: boolean; write: boolean;
dialect: 'duckdb' | 'substrait' | 'none';
available: boolean; unavailableReason?: string; // ⊥ de las capacidades
features: readonly string[]; // vacío: se puebla con el harness de F3, no por fe
}
Matriz real de motores (implementada):
| Engine | substrate | sql | read | write | dialect | available (hoy) |
|---|---|---|---|---|---|---|
duckdb | iceberg | ✅ | ✅ | ✅ | duckdb | sii DUCK_SERVER_URL |
mlrunner | iceberg | ❌ | ✅ | ✅ | none | sii ML_RUNNER_URL+_TOKEN |
karma | iceberg | ✅ (al cablear) | ✅ | ❌ | substrait | ❌ (KARMA_SQL_URL ausente) |
pg | pg | ❌ | ✅ | ✅ | none | ✅ (la BD de control) |
3 · Touch-points EXACTOS (inert-first)
Tipos (lib/compute/contract.ts):
FreshnessVerdict.source: 'postgres'|'iceberg'(:102) →Substrate(rename de valorpostgres→pg).CommitResult.sink: 'postgres'|'iceberg'(:267) →Substrate.
Routers (mantienen su rol: resuelven SUBSTRATO; ganan el campo engine):
read-router.ts:DatasetRowSource.source(:54) →Substrate; añadirengine: Engineal resuelto (defaultpgsi substrato pg,port.engine ?? 'mlrunner'si iceberg).run<T>(:63,151) — firma sin cambio en F2 (los closures{postgres, iceberg}siguen; elengineviaja en el resolved para observabilidad + para querunQuerysepa a qué adapter ir).write-router.ts:DatasetRowSink.sink(:64) →Substrate; añadirengine: Engine. Idemrun<T>(:74,135).junction.ts(composer): lossink: 'iceberg'(:623,635) → alinear alSubstrate+ estamparengine..run<T>({postgres, iceberg})(:533,579,603) sin cambio.
Registro (port-registry.ts): ReaderPort/WriterPort ganan engine?: Engine (hoy NUNCA declaraban motor — el gap de capa-3). Default: iceberg→mlrunner. capabilities?: EngineCapabilities por puerto NO se implementó, deliberadamente: las capacidades son del MOTOR, no del puerto, y duplicarlas por puerto sin harness sería declarar por fe dos veces.
Nuevo: lib/compute/engine-adapter.ts (el contrato) + lib/compute/engines/{duckdb,mlrunner,karma,pg}.ts (adaptores) + lib/compute/engines/index.ts (registry). Todo inerte — nada los llama hasta runQuery (F4).
Migración 'postgres'→'pg': rename de valor mecánico, verificable por tsc, en los 6 sitios del enum + comparaciones (=== 'postgres', === 'iceberg'). Limpia el legacy que mezclaba los ejes.
3·bis · ⚠️ CORRECCIÓN — el blast-radius del rename NO eran 6 ficheros, y tsc NO lo blindaba entero
El plan decía "6 sitios, mecánico, verificable por tsc". Lo real (barrido exhaustivo, 2026-07-28) fueron 14 ficheros, y —lo importante— dos de ellos tsc NO los detecta:
Los que tsc SÍ delata (comparación con el tipo → TS2367), y por tanto eran seguros:
lib/compute/contract.ts (:103 source, :268 sink) · read-router.ts (:54,109,156) · write-router.ts (:64,118,139) · junction.ts (:485,496) · lib/pipelines/transformPaths.ts:1404 · app/api/pipelines/[id]/manual-table/route.ts:149,175,196 · scripts/dataspaces/pipeline-output-canary.ts:167 · los tests read-router.test.ts (×4), write-router.test.ts (×3), sql-source-hydration.test.ts (×2).
Los que tsc NO delata — el riesgo real de un "rename mecánico":
components/workspace/tabs/warehouse/WarehouseTab.tsx:107—sourceLabel(src?: string)comparabasrc === 'postgres'con el parámetro tipadostring, noSubstrate. Tras el rename habría degradado en silencio a'—'en la UI del Warehouse ("Data source", 2 sitios + el badge del sample). Arreglado con un mapa exhaustivoRecord<Substrate, string>: siSubstratecambia, ahora rompe en tsc.app/api/dataspace/item/sample/route.ts:45— devuelvesource: handle.freshness.sourcepor el wire; es passthrough (no rompe), pero confirma que el valor del substrato es observable fuera del proceso.
Lo que sí se verificó y hacía el rename seguro: el valor NO se persiste en ningún sitio — no hay columna de BD, ni metadata JSONB, ni flag que lo guarde. La única salida no-UI es un log estructurado (lib/workers/polling/dataset-writer.ts:923). Sin persistencia no hay migración de datos ni ventana de skew.
Homónimos que NO se tocaron (mismo string, otro dominio — renombrarlos habría roto cosas de verdad): lib/cdc/source-profiles.ts (CdcMechanism), datasets.storage_backend='postgres' (dataset-writer.ts), los dialectos de lib/handshake/** (SqlDialect), lib/canvas/helper/credentials.ts, lib/ai/capability-resolver.ts.
Lección para las fases siguientes: "verificable por tsc" solo vale donde el valor viaja tipado. En las fronteras donde se degrada a
string(UI, wire, logs) hay que re-tipar la frontera — no confiar en el compilador.
4 · Las 2 decisiones de F2
-
¿Reescribir los
run<T>ahora, o solo establecer el contrato?- ✅ Recomendado — inert-first: F2 ESTABLECE
Substrate⊥Engineen los tipos +EngineAdapter+ registry + adaptores (wrappers de los clientes existentes) + el campoengineen el resolved, SIN tocar los ~30 caller-closures derun<T>. Primer consumidor =runQuery(F4). Los readers/writers estructurados migran a adaptores mucho después (o nunca — siguen con sus closures; el adapter es para el pathrunQuery/live-SQL). Cero riesgo. - ❌ Eager: reescribir los ~30
run<T>a un mapa keyed-por-motor ahora = gran churn, riesgo, poco valor inmediato (el único motor de iceberg hoy es ml-runner).
- ✅ Recomendado — inert-first: F2 ESTABLECE
-
'postgres'→'pg': ¿migrar el valor del enum en F2 (alinear aSubstrate, limpiar el legacy) o dejar'postgres'y solo añadir el ejeengine? Recomendado: migrar (es la limpieza que el owner pidió; tsc lo blinda). Coste: rename mecánico en 6 ficheros.
5 · Plan de F2 (inert-first, verificable) — ✅ EJECUTADO
- F2.0 · tipos ✅:
Substrateconectado asource/sink(renamepostgres→pg) en los 14 ficheros (§3·bis) +engineañadido aDatasetRowSource/Sink. tsc 0. - F2.1 ·
EngineAdapter+ registry ✅:lib/compute/engine-adapter.ts(contrato base +SqlEngineAdapter+isSqlEngine+ los 2 errores +bindingOverlaySchema) ylib/compute/engines/{duckdb,mlrunner,karma,pg}.ts+index.ts(registry TOTALRecord<Engine,…>→ añadir un motor sin adaptador rompe en tsc).duckdb= wrapper REAL deduck-client(mapeaQueryRequest→DuckQueryOptsyDuckQueryResult→QueryResult, incl. el envelope de escritura de F4.1 y la derivación deensureNamespacedesde elwriteTarget);mlrunner/pg/karma= declaración honesta de capacidades, sin wrapper hueco. Testengines/index.test.ts(12 casos). - F2.2 · estampar
engineen el resolved ✅: ambos routers estampanengine(substratopg→pg;iceberg→spec.engine ?? 'mlrunner'), yReaderPort/WriterPortgananengine?: Engine— hasta ahora un puerto nunca declaraba motor (el gap de capa-3). - Gate ✅: cero cambio de comportamiento; tsc 0; 1030/1030 tests del seam + 12/12 del registry. Nada sirve por adapter hasta que
runQuery(F4) lo consuma.
Deuda que F2 NO cerró (deliberado): capabilities().features va vacío en los 4 motores — poblarlo sin el harness diferencial sería declarar por fe. Es exactamente el trabajo de F3.
6 · Cómo encaja con lo ya construido
F2 no construye motores — envuelve los clientes que ya existen: el adapter duckdb es un wrapper de duck-client (que ya hace query tipado + write_target + el clasificador de F4.2); el mlrunner envuelve read-client + iceberg-native-write (el control-plane de escritura). Así, cuando runQuery (F4) despache por EngineAdapter, reutiliza TODO el stack de F3-RETIRO/F4 sin reimplementar. El EngineAdapter es la junta que convierte "duck-server es un servicio que llamo a pelo" en "DuckDB es un motor intercambiable detrás de la puerta".
Doc vivo. F2 = capa-2 de Furnace (abstracción de motor), inert-first — implementado 2026-07-28 con las 2 correcciones de §2·bis/§3·bis. Siguiente: F3 (equivalence harness → puebla features) → F4 (absorber sql-editor por runQuery, el primer consumidor de SqlEngineAdapter).