Colonne dei nodi per la UI del DAG
Come far comparire le colonne note di un nodo del DAG in
noeva-etl-ui, senza duplicare la conoscenza già negli schemi Zod. Decisione e razionale: ADR-0026. Campo gemello: node-descriptor.md (che connettore è / dove punta).
Il contratto
Ogni PlanNode espone columns (src/core/plan-column.ts):
interface PlanColumn {
readonly id: string; // = name
readonly name: string; // nome del campo
readonly dataType: string; // "string" | "number" | "boolean" | "date" | "enum" | ...
readonly isKey: boolean; // convenzione: `id` o suffisso `_id`
}toPlanSpec (src/core/graph.ts) risolve le columns di ogni nodo e le proietta nel PlanSpec → etl plan → plans.generated.json → UI. Quando per un nodo non è nota alcuna colonna, il campo è [] (mai colonne inventate).
Da dove arrivano le colonne (in ordine di precedenza)
Colonne proprie del nodo:
transformda un contrattovalidate(schema): le colonne sono ricavate dallo schema Zod per introspezione (columnsFromZodObject, vedi sotto). È il caso più comune e non richiede nulla oltre a usarevalidate().source/sink: colonne dichiarate esplicitamente sul connettore (columns?suSource/Sink), per i casi schema-agnostici (vedi sotto).join: nessuna colonna propria (non ha uno schema proprio).lookup,mapTransform,groupByconoutputSchema: le colonne sono ricavate da quello schema per introspezione, come pervalidate(). Serve perché questi nodi cambiano la forma del record (merge,fn, l'aggregato). Lo schema non valida i record: per quello resta unvalidate()a valle (ETL-3, ETL-4).- Gli stessi nodi senza
outputSchema, e le transform custom che non dichiaranocolumns: nessuna colonna propria. Ereditano quelle del nodo a monte (punto 2), cioè i campi dell'input anche se la forma è cambiata. Dichiarare lo schema è ciò che lo evita; senza, le colonne mostrate possono non essere rappresentative.
Propagazione dai predecessori: un nodo senza colonne proprie eredita quelle dei predecessori, ma solo se tutti i predecessori con colonne concordano (stesso insieme di
id). Predecessori discordi (i due lati di unjoin) o tutti vuoti ⇒[]. Poiché transform/sink hanno esattamente un edge in ingresso (fan-in solo via join, cfr.validateGraph), l'eredità è deterministica: un sink eredita dal contratto a monte.Il ramo
catchnon propaga. Un edge dalla portacatchdi untry-catchporta envelopeDeadLetter, non i dati: non conta come predecessore. I nodi del ramo partono quindi da[]e mostrano colonne solo se ne dichiarano di proprie (es. unmapTransformconoutputSchemache riporta il record alla forma originale), e a cascata lo stesso vale per i nodi a valle sul ramo (ETL-4).
Esempio (orders-enriched-daily): i nodi validate-orders, validate-customers, fact-contract, tier-summary-contract espongono le colonne del proprio schema; i sink enriched_orders/tier_summary le ereditano dal contratto a monte; il join enrich (ordini vs clienti, discordi) e le source in-memory restano [].
Introspezione Zod (columnsFromZodObject)
src/core/schema-columns.ts. Regole:
- Solo
z.object({...})produce colonne, una per campo nell'ordine di dichiarazione. Qualunque altra forma (z.record,z.array, unioni, primitive nude) →[]. dataTypeè il tipo di base, dopo aver rimosso i wrapper che non cambiano la forma colonna:optional,nullable,default,catch,readonly,branded,effects/refine/coerce. Etichette:string,number,bigint,boolean,date,enum(anchenativeEnum),literal,array,object; tutto il resto →unknown.isKey=trueper un campoido con suffisso_id(convenzione dei contratti di questo repo). Non è una chiave dichiarata: è un'euristica per il canvas.- Difensiva: su forme Zod inattese ritorna
[], non solleva mai. Una proiezione topologica serializzabile non deve fallire per colpa dell'introspezione.
Dichiarare colonne su un connettore schema-agnostico
Quando la riga non ha uno schema fisso (es. un CSV letto come mappa colonna→valore, csvRowSchema = z.record(string, string)), l'introspezione non ricava colonne: si dichiarano a mano le colonne rappresentative (l'header noto del file) sul Source (o Sink), da cui poi si propagano a valle:
const CSV_COLUMNS: readonly PlanColumn[] = [
{ id: "id", name: "id", dataType: "string", isKey: true },
{ id: "val", name: "val", dataType: "string", isKey: false },
];
const source: Source<CsvRow> = {
name: "merged-csv",
columns: CSV_COLUMNS, // ← dichiarate, non introspette
descriptor: {
/* ... */
},
async *extract(ctx) {
/* ... */
},
};Vedi src/pipelines/cross-workspace-csv-merge/run.ts.
getSpec() senza credenziali: connettori stub
etl plan non apre connessioni, quindi le pipeline con connettori a DB o API derivano il piano dallo stesso build…Pipeline() di run() ma con connettori stub (vedi crm-leads-sync/run.ts, langfuse-sessions-daily/run.ts). Le colonne seguono le regole sopra, senza eccezioni: gli stub di source/sink dichiarano columns quando la forma non viene da uno schema a valle (es. columns: columnsFromZodObject(rawSessionSchema) sullo stub di source di langfuse), e i nodi intermedi le ricavano da validate() o outputSchema. Non si scrive un PlanSpec letterale a mano (ETL-5).
Sync con la UI
Il contratto columns è già presente e validato in noeva-etl-ui (schema Zod in plan-loader.ts, rendering nel nodo/pannello dettagli): questo lato server ne è il produttore, non ne cambia la forma. Se cambi la forma di PlanColumn (id/name/dataType/isKey), propaga su entrambi i repo nello stesso PR-set.

