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:
| Forma | Qué es en realidad |
|---|---|
rows single-shot replace/upsert/append | primitiva 1 — writeIcebergNativeSnapshot |
| parquet server-side (EDC) | primitiva 2 — writeIcebergNativeFromParquet |
| compute-SQL → replace (pipelines) | primitiva 1, con las filas de un SELECT |
re-deploy upsert / append | primitiva 1, otro writeMode |
| fan-out particionado | primitiva 1, otro régimen de ejecución |
append_new | leer 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:
WriteEngineAdapterera un router delante de otro router. El despacho por substrato ya lo hacesink.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-out | Es 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_new | Se compone con lo que ya hay: leer las PK del target por la puerta + filtrar + append. Si se repite, se promueve a modo |
| Delta incremental | Depende de una ventana temporal sobre dataset_rows que Iceberg no expresa. Trabajo de motor (Karma/DuckDB) |
| DML por SQL | Es 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 atribuible —
ratifydebe poder confirmarlo por su id. El brazo del fan-out tiraba eloperationIdy 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:
- paridad de contenido (reescribe las filas idénticas y las compara);
- una transacción de ledger — ni cero ni dos;
- atribución:
ratifyconfirma 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
- El siguiente escritor.
cdc.appendes el candidato natural: su brazo PG no está entrelazado con una txn ajena. - Alinear el
aborteddel camino PG con el invariante de B1/B2 — ahora en un solo sitio. - 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:
| Significado | Ejemplo |
|---|---|
| «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=pgsignifica «escribedataset_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 conwrite-not-enabled»— ya no es cierta. F4b está cableado y desplegado (532bb52;junction.ts:1209): hoyrunQuerysólo rechaza el DML cuyo dataset resuelvesink ≠ iceberg, así que con el dataset flipeado aiceberg_nativeel 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.