Skip to content

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 anche lookup, mapTransform e groupBy che dichiarano un outputSchema opzionale: serve solo alle colonne, non valida. Senza, quei nodi ereditano come prima. (b) Gli edge dalla porta catch di un try-catch non propagano colonne (portano DeadLetter): la regola era già scritta nella skill etl-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):

  1. Introspezione dei contratti validate(). columnsFromZodObject(schema) mappa un z.object({...}) a una colonna per campo, nell'ordine di dichiarazione. Il dataType è ricavato dal tipo Zod di base dopo aver rimosso i wrapper (optional/nullable/default/catch/readonly/branded/effects); isKey usa la convenzione dei contratti di questo repo (id o 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. Transform guadagna un campo opzionale columns? che validate() valorizza: ogni pipeline che usa validate() ottiene le colonne senza altre modifiche.
  2. Colonne dichiarate esplicitamente su Source/Sink. Source/Sink guadagnano un columns? 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".
  3. 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 di id). 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 contratto validate() 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/literal non riportano i valori; array/object non descrivono l'elemento) — sufficiente per il canvas, non un catalogo completo. L'euristica isKey è convenzionale (id/*_id), non una chiave dichiarata.
  • Caso statico: le pipeline che hand-authorano il getSpec() (es. langfuse-sessions-daily) impostano columns esplicitamente nel letterale, riusando columnsFromZodObject sui propri schemi — nessuna introspezione automatica del grafo.
  • Sync UI: il contratto columns era 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

Noeva è un marchio registrato di 4D S.R.L.