Tema
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. Campo gemello: node-descriptor.md (che connettore è / dove punta).
Il contratto
Ogni PlanNode espone columns (src/core/plan-column.ts):
ts
interface PlanColumn {
readonly id: string; // = name
readonly name: string; // nome del campo
readonly dataType: string; // "string" | "number" | "integer" | "boolean" | "date" | "enum" | ...
readonly isKey: boolean; // convenzione: `id` o suffisso `_id`
readonly sourceColumns?: readonly string[]; // lineage: vedi sotto
}toPlanSpec (src/core/plan-spec.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).
Lineage di colonna
sourceColumns dice da quali colonne d'ingresso deriva una colonna del nodo. È opzionale e additivo: un consumatore del piano che non lo conosce lo ignora.
- I valori sono i nomi delle colonne del record in ingresso al nodo, cioè gli
iddelle colonne del nodo a monte. Il lineage è di un passo: per risalire il grafo si seguono gli edge. - Assente = origine non dichiarata. Vale per ogni nodo che non la conosce, e non vuol dire «nessuna origine».
[]= nessuna origine: una costante.
Oggi lo valorizza solo mapColumns: rename e cast danno [from], una colonna derive il suo from dichiarato.
Non si eredita. La propagazione (punto 2 qui sotto) copia le colonne senza sourceColumns: nomina colonne dell'ingresso del nodo che lo dichiara, e su un nodo a valle punterebbe a colonne che lì non arrivano. Un sink dopo un mapColumns mostra quindi le stesse colonne, senza origine.
Nella UI il pannello di dettaglio di un nodo mostra l'origine sotto il nome di ogni colonna che la dichiara (← a, b, oppure ← costante per []) e, al passaggio del mouse, evidenzia quelle colonne sul nodo a monte.
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,ai,action,mapTransform,flatMapTransform,explode,groupBy,parse,serialize,hashRow,surrogateKey,maskconoutputSchema: 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.parsesenzaoutputSchemae senzamerge: le colonne che il codec dichiara (FileCodec.columns, costruite concolumnsFromNames: nomi noti, un solodataType), quando le conosce prima di leggere il file —csvCodecconheader: falseecolumns,xlsxCodecconcolumns. Conmergela forma cambia, e il nodo torna a ereditare.unpivotepivot: le colonne d'uscita sono nella loro configurazione (keep,nameAs,valueAs;key,columns), quindi le dichiarano sempre, senzaoutputSchema, concolumnsFromNames. Di quelle colonne è noto il nome, non il tipo:dataTypeè"unknown", trannenameAsche è"string".mapColumns: conrest: "drop"dichiara le colonne dello spec, ognuna consourceColumns; ildataTypeviene dal cast o daltypediderive, ed è"unknown"per un rename senza cast. Il castintegerdà"integer", che l'introspezione Zod non produce mai (z.number().int()resta"number");timestampdà"date". Conrest: "keep"eredita da monte, a meno di unoutputSchema.- 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 gli ingressi di ununioncon forme diverse) o tutti vuoti ⇒[]. Poiché transform/sink hanno esattamente un edge in ingresso (fan-in solo viajoinounion, 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.
Esempio (orders-enriched-daily): i nodi validate-orders, validate-customers e i confini dell'istanza riepilogo-ordini (l'ingresso e le uscite fatti e per-tier) 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:
ts
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: "read-workspace-a",
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). 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(rawSchema) sullo stub di source), e i nodi intermedi le ricavano da validate() o outputSchema. Non si scrive un PlanSpec letterale a mano.
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.

