Published

Cerrar el espectro de escritura de la puerta

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...

Cerrar el espectro de escritura de la puerta

Entregable (2026-07-29). Prerequisito de la migración de los escritores.

El problema cabe en una frase: un escritor no puede soltar su brazo de Iceberg hasta que sus datasets estén flipeados a nativo, y un dataset no puede flipearse hasta que su escritor tenga ese brazo. Es un empate, y es por lo que la migración no arranca.

El cambio que lo deshace también: que el brazo PG de la puerta deje de lanzar y delegue en el escritor PG que ya existe. Todo lo demás se cae solo.

✅ HECHO (commit 9280006). Ver §8 para lo que costó de más de lo previsto y lo que queda.

Contexto medido: junction-write-paradigm.md.


1 · El espectro es más pequeño de lo que parece

Contando los brazos escritos a mano salen diez formas de escribir. Pero primitivas distintas hay dos:

FormaQué es en realidad
rows single-shot replace/upsert/appendprimitiva 1writeIcebergNativeSnapshot
parquet server-side (EDC)primitiva 2writeIcebergNativeFromParquet
compute-SQL → replace (pipelines)primitiva 1, con las filas de un SELECT
re-deploy upsert / appendprimitiva 1, otro writeMode
fan-out particionadoprimitiva 1, otro régimen de ejecución
append_newleer las PK del target + filtrar + primitiva 1
dual-target (split)primitiva 1, dos veces
delta incremental(sin equivalente Iceberg — trabajo de motor, no de puerta)
DML por SQL(otro intent: runQuery, no write)

Y la puerta ya soporta las dos primitivas y los tres modos (contract.ts:263, junction.ts:629). El brazo de Iceberg de DatasetWriter.writeDatasetIcebergNative y el de la puerta hacen literalmente la misma llamada.

No falta espectro de escritura. Falta que la puerta acepte el substrato en el que aún vive la mayoría de los datos.


2 · Lo único que bloquea

junction.ts:622 — el brazo PG de write():

postgres: () => {
  throw new Error(`… las escrituras vía PG aún no se enrutan por la facade (Fase 2 · paso 6)`);
},

Mientras eso lance, pasar un escritor por la puerta exige flipear antes sus datasets. Y flipear un dataset exige que su escritor ya tenga brazo nativo — escrito a mano. De ahí el empate.

Lo que hace falta para deshacerlo ya existe: DatasetWriter.writeDatasetStreaming(datasetName, columns, batches, mode, metadata) sabe escribir PG con los tres modos. El brazo PG de la puerta no tiene que aprender a escribir Postgres — tiene que llamar al que ya sabe.

Y no hay obstáculo técnico: dataset-writer.ts no importa la puerta, así que no hay ciclo de imports que resolver.


3 · El cambio

Una función. El resto del contrato no se toca: WriteRequest ya tiene fuente (rows | parquet), modo (replace | append | upsert) y gobernanza (sourceRefs / metadata).

Y con eso, migrar un escritor pasa a ser borrar código:

// antes — el patrón que se repite en 15 ficheros
const sink = await resolveDatasetRowSink('cdc.append', datasetId);
if (sink.sink === 'iceberg') { /* …30 líneas a mano… */ } else { /* …el INSERT… */ }

// después
await handle.write({ kind: 'rows', columns, rows, writeMode });

El flip deja de ser un cambio de código y pasa a ser lo que siempre debió ser: un flag por dataset.


4 · Lo que descarté, y por qué

La primera versión de este entregable proponía un WriteEngineAdapter, un contrato v1.3 con «tres ejes ortogonales» y un plan de seis fases. Está mal, y conviene dejar escrito por qué para no repetirlo:

  • WriteEngineAdapter era un router delante de otro router. El despacho por substrato ya lo hace sink.run({ postgres, iceberg }). Añadir un adaptador encima es una segunda capa de despacho para la misma decisión — exactamente lo que junction.md §0 prohíbe: «Hay exactamente una puerta; nunca un router delante de otro router.»
  • El «contrato de tres ejes» era renombrar lo que ya existe. Fuente, modo y gobernanza ya están en WriteRequest. Cambiar el contrato tenía coste y cero efecto.
  • El ciclo de imports que había que mitigar no existe. Era una defensa contra un problema inventado.

Regla que se saca de esto: si un cambio de arquitectura no reduce el número de piezas, probablemente no es el cambio.


5 · Lo que queda fuera a propósito

Ninguna de estas bloquea la migración, y meterlas aquí era parte del sobre-diseño:

QuéPor qué fuera
Fan-outEs un régimen de ejecución, no una forma de escritura. Absorberlo dentro del path nativo es un refactor invisible al contrato y a todos los llamantes. Se puede hacer antes, después o nunca
append_newSe compone con lo que ya hay: leer las PK del target por la puerta + filtrar + append. Si se repite, se promueve a modo
Delta incrementalDepende de una ventana temporal sobre dataset_rows que Iceberg no expresa. Trabajo de motor (Karma/DuckDB)
DML por SQLEs otro intent — runQuery, F4b — no write()

6 · Cómo se prueba

Harness diferencial, el patrón que ya funcionó en F3 y F4a: la misma escritura por el brazo viejo y por la puerta.

  • Sujeto main.test. Invariante: paridad de filas y contenido, y una transacción de ledger por escritura (ni cero ni dos).
  • Y una comprobación que esta semana demostró que hace falta: que el commit sea atribuibleratify debe poder confirmarlo por su id. El brazo del fan-out tiraba el operationId y nadie lo vio hasta preguntarle al catálogo.

7 · La decisión del owner

Una sola: ¿door-first o flip-first?

El repo asume hoy flip-first: engines/pg.ts dice que el brazo PG «nunca se implementa, se extingue». Es coherente si la meta fuera solo llegar a Iceberg — pero con flip-first el individualismo de los escritores es lo último que desaparece, porque cada uno conserva sus dos brazos hasta que el último de sus datasets esté flipeado.

Recomendación: door-first. Migrar un escritor pasa a ser borrar código en vez de escribirlo, y desacopla dos riesgos que hoy van atados: cambiar quién escribe, y cambiar dónde aterriza.

El coste, dicho claro: la puerta gana un brazo PG que el diseño actual daba por inexistente. Vive hasta que no queden datasets en PG — igual que engines/pg.ts, que ya está escrito para desaparecer ese mismo día.

Decidido: door-first (2026-07-29).


8 · Lo que costó de más de lo previsto

El §2 decía «llamar al que ya sabe». El destino obvio era el equivocado: DatasetWriter.writeDatasetStreaming resuelve el sink otra vez (y podría re-enrutar a Iceberg), crea datasets si no existen y exige carpeta de salida. Es un orquestador de sync, no un escritor de filas.

La primitiva real eran las ~60 líneas de control-plane + write que vivían dentro. Están extraídas tal cual a writeDatasetRowsPg (exportada del mismo fichero, sin fichero nuevo y sin ciclo de imports): el sync y la puerta comparten ahora exactamente ese camino. Es el gemelo de withNativeControlPlane para el substrato legacy.

Lo que NO se comparte, a propósito: las stats del dataset. El sync reconcilia esquema y PK porque su adaptador acaba de introspeccionar la fuente; la puerta no es autoridad de esquema — las columns de un WriteRequest son la proyección del llamante. Persistirlas borraría la PK que el dataset ya estableció, que es el mismo fallo que el brazo nativo evita con carryOverPrimaryKey. Aquí se evita no escribiéndolas.

Deuda heredada, anotada donde vive: el camino PG escribe aborted desde un catch — el patrón que B1/B2 eliminó del lado Iceberg, y aquí las filas parciales ya están en dataset_rows. Se extrajo sin tocarlo para que el cambio fuera un refactor puro. El beneficio de haberlo extraído es que alinearlo con el invariante pasa a ser un arreglo en un sitio en vez de dos.

parquet sobre PG se rechaza en vez de fingir: materializar un Parquet fila a fila sería una primitiva nueva, no una delegación.

9 · El primer escritor migrado: manual.table (commit 819e84f)

writeIcebergNativeSnapshot(...) a mano desaparece de la ruta; queda handle.write({kind:'rows', writeMode:'replace'}). Gate en vivo 8/8 sobre animal_events.

Verificado que es el mismo comportamiento, no asumido — y el punto verificado fue el namespace, que ya ha mordido tres veces: la llamada directa no lo pasaba (⇒ resolveWriteNamespace hacía el lookup), y por la puerta llega ds.iceberg_namespace ?? DEFAULT (⇒ si está fijado retorna inmediato con el mismo valor; si es null cae al mismo camino). Idéntico en ambos casos.

⚠️ El hallazgo: no todo brazo es lógica duplicada

El brazo PG de manual.table NO se migra, y no por pereza. No es una implementación duplicada de la escritura: es una forma transaccional distinta. Las filas se co-commitean con el artefacto en la propia txn de PG de la ruta, etiquetadas con el transactionId que ella acuña y registra en pipeline_build_transactions. Pasarlas por la puerta abriría una txn de dataset_transactions aparte ⇒ dos entradas de ledger para una escritura, con las filas etiquetadas por una y el row_count declarado por la otra — exactamente la desincronización que causó la pérdida de datos del mirror.

Para los 34 que quedan: un if (sink === 'iceberg') puede esconder dos cosas distintas. Si el brazo PG escribe dentro de una transacción que la ruta ya tenía abierta, ese escritor no se migra hasta que la puerta sepa participar en una txn ajena.

El gate, reusable

scripts/dataspaces/door-write-parity.ts afirma las tres cosas que una migración debe demostrar:

  1. paridad de contenido (reescribe las filas idénticas y las compara);
  2. una transacción de ledger — ni cero ni dos;
  3. atribución: ratify confirma el commit por su id contra el catálogo.

La tercera es la que justifica el harness: ninguna prueba de paridad de filas habría cazado el fan-out tirando el operationId.

De paso midió algo que conviene saber al migrar: la puerta separa intents — un handle de escritura no lee. Un escritor que necesite leer su target (p.ej. append_new) abre dos.

Lo que queda

  1. El siguiente escritor. cdc.append es el candidato natural: su brazo PG no está entrelazado con una txn ajena.
  2. Alinear el aborted del camino PG con el invariante de B1/B2 — ahora en un solo sitio.
  3. Los modos y regímenes del §5, cuando hagan falta y no antes.

10 · Efecto colateral: quitar la pared cambió qué significa pg

Quitar el throw del brazo PG tuvo una consecuencia que no estaba en el plan y que conviene dejar escrita, porque afecta a cómo se lee todo el registro de puertos.

pg estaba haciendo dos trabajos a la vez:

SignificadoEjemplo
«este escritor escribe Postgres legítimamente»ingest.stream, row.edit, dataset.manifest — y ingest.api / pipeline.output, que tienen su propia implementación PG
«este escritor está apagado»sql-editor.dml

Mientras el brazo PG de la puerta era una pared, los dos significados coincidían: cualquiera de los dos acababa en un fallo en alto. Al delegar, divergen.

El caso concreto: sql-editor.dml

  • Antes: su inertidad era prestada. El registro lo documentaba así — «defaultMode=pg (INERTE) → el sink resuelve al PG-sink que lanza (fail-loud)». La seguridad venía de una pared en otro componente.
  • Ahora: defaultMode=pg significa «escribe dataset_rows». La pared no está.
  • Exposición real hoy: cero. El puerto no tiene consumidores (ningún código resuelve ese writerId). ⚠️ RANCIO (corregido 2026-07-29): la segunda mitad de esta frase —«su camino real, runQuery, sigue rechazando todo DML con write-not-enabled»— ya no es cierta. F4b está cableado y desplegado (532bb52; junction.ts:1209): hoy runQuery sólo rechaza el DML cuyo dataset resuelve sink ≠ iceberg, así que con el dataset flipeado a iceberg_native el DML EJECUTA. La guarda real es el canary por dataset, no un rechazo global.
  • Lo que sí quedó mal: el comentario del registro, que afirmaba una propiedad de seguridad que había dejado de ser cierta. Corregido.

Y su pg nunca fue una decisión: es el valor por defecto del helper writer(). Nadie eligió Postgres para el DML del SQL Editor — cuyo sentido entero es DuckDB engine-side sobre Iceberg. Al cablear F4b hay que fijarle el modo a propósito.

Regla que se saca: una propiedad de seguridad que depende de que otro componente falle no es una guarda, es una coincidencia. Si algo debe estar apagado, que lo declare.


Cruza con: junction-write-paradigm.md (la foto medida) · junction.md (una sola puerta) · paso6-write-approach.md.