0026. Colonne dei nodi del DAG via introspezione degli schemi Zod + propagazione
- Stato: Accettata
- Data: 2026-07-22
- Decisori: Team BI/ETL
Aggiornamento (2026-09-14, ETL-3 / ETL-4). La decisione resta valida; due precisazioni. (a) Oltre a
validate(), espongono colonne proprie anchelookup,mapTransformegroupByche dichiarano unoutputSchemaopzionale: serve solo alle colonne, non valida. Senza, quei nodi ereditano come prima. (b) Gli edge dalla portacatchdi untry-catchnon propagano colonne (portanoDeadLetter): la regola era già scritta nella skilletl-graph, il codice ora la rispetta. Dettaglio in node-columns.md.
Contesto
etl plan proietta ogni pipeline in un PlanSpec (JSON-safe) consumato da noeva-etl-ui per disegnare il DAG. Il contratto PlanNode prevede da sempre un campo columns: {id,name,dataType,isKey}[] che la UI sa già renderizzare (noeva-etl-ui/src/lib/plan-loader.ts), ma toPlanSpec lo lasciava hardcoded a [] per tutti i nodi di tutte le pipeline (vedi ADR-0020, che dichiarava l'introspezione Zod fuori scope). Risultato: nel canvas nessun nodo mostrava le colonne del dato che ci scorre, pur essendo l'informazione già presente nei contratti validate(schema) di ogni pipeline.
Decisione
Popolare PlanNode.columns derivandole dagli schemi Zod già dichiarati, senza introdurre una nuova fonte di verità e senza toccare la UI. Tre meccanismi componibili (src/core/schema-columns.ts, src/core/graph.ts):
- Introspezione dei contratti
validate().columnsFromZodObject(schema)mappa unz.object({...})a una colonna per campo, nell'ordine di dichiarazione. IldataTypeè ricavato dal tipo Zod di base dopo aver rimosso i wrapper (optional/nullable/default/catch/readonly/branded/effects);isKeyusa la convenzione dei contratti di questo repo (ido suffisso_id). Ogni schema senza colonne fisse (z.record,z.array, unioni, primitive) →[]. La funzione è difensiva: su forme Zod inattese ritorna[], non solleva mai — una proiezione topologica serializzabile non deve fallire per colpa dell'introspezione.Transformguadagna un campo opzionalecolumns?chevalidate()valorizza: ogni pipeline che usavalidate()ottiene le colonne senza altre modifiche. - Colonne dichiarate esplicitamente su Source/Sink.
Source/Sinkguadagnano uncolumns?opzionale, per i connettori schema-agnostici o comunque privi di uno schema introspezionabile (es.cross-workspace-csv-merge, la cui riga èz.record(string,string)): lì le colonne rappresentative (l'header noto del file) si dichiarano a mano, coerentemente con la regola "non si inventano colonne per introspezione dove non esistono". - Propagazione ai nodi adiacenti. In
toPlanSpec, un nodo senza colonne proprie (join, transform generici, sink/source in-memory) le eredita dai predecessori a punto fisso, ma solo se tutti i predecessori con colonne concordano (stesso insieme diid). PoichévalidateGraph(ADR-0016) impone a transform/sink esattamente un edge in ingresso, l'eredità è deterministica: un sink eredita dal contratto a monte; un join con due lati discordi (es.enrich: ordini vs clienti) resta[].
Alternative scartate
- Introspezione generica automatica anche di source/sink DB (query al catalogo informativo di Postgres/SQL Server per le colonne reali della tabella) — fuori scope: richiede connessione live durante
etl plan, che il comando evita di proposito. Le colonne dei sink DB arrivano comunque, quando c'è un contrattovalidate()a monte, per propagazione. - Colonne hardcoded per singola pipeline demo — scartato: duplicherebbe la conoscenza già negli schemi Zod (drift garantito, contro §4 CLAUDE.md). L'introspezione tiene le colonne allineate al contratto per costruzione.
- Propagazione con union/merge dei predecessori discordi — scartato in favore della regola conservativa "solo se concordi": inventare una colonna unendo due contratti diversi (i due lati di un join) sarebbe fuorviante. Nel dubbio,
[].
Conseguenze
- Pro: le colonne restano allineate ai contratti Zod per costruzione; cambiamento additivo (
columns?opzionale ovunque, retrocompatibile); la UI non cambia; ogni pipeline che valida i dati mostra colonne "gratis". - Contro: il
dataTypeè una sintesi (es.enum/literalnon riportano i valori;array/objectnon descrivono l'elemento) — sufficiente per il canvas, non un catalogo completo. L'euristicaisKeyè convenzionale (id/*_id), non una chiave dichiarata. - Caso statico: le pipeline che hand-authorano il
getSpec()(es.langfuse-sessions-daily) impostanocolumnsesplicitamente nel letterale, riusandocolumnsFromZodObjectsui propri schemi — nessuna introspezione automatica del grafo. - Sync UI: il contratto
columnsera già presente e validato lato UI; questa decisione ne è il produttore lato server, non cambia la forma. Supera il punto "Introspezione Zod fuori scope" di ADR-0020.
Riferimenti
src/core/schema-columns.ts(columnsFromZodObject),src/core/plan-column.ts(PlanColumn),src/core/graph.ts(toPlanSpec,resolvePlanColumns),src/core/transform.ts(validate),src/core/connector.ts(Source/Sinkcolumns?).- Convenzione: docs/conventions/node-columns.md. Descriptor (campo gemello): ADR-0020, docs/conventions/node-descriptor.md.
- Esempi:
src/pipelines/orders-enriched-daily/(introspezione + propagazione),src/pipelines/cross-workspace-csv-merge/(colonne dichiarate). - Piano di implementazione: docs/plans/2026-07-22-plan-node-columns.md.

