Skip to content

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 id delle 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) ​

  1. Colonne proprie del nodo:

    • transform da un contratto validate(schema): le colonne sono ricavate dallo schema Zod per introspezione (columnsFromZodObject, vedi sotto). È il caso più comune e non richiede nulla oltre a usare validate().
    • source/sink: colonne dichiarate esplicitamente sul connettore (columns? su Source/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, mask con outputSchema: le colonne sono ricavate da quello schema per introspezione, come per validate(). Serve perché questi nodi cambiano la forma del record (merge, fn, l'aggregato). Lo schema non valida i record: per quello resta un validate() a valle.
    • parse senza outputSchema e senza merge: le colonne che il codec dichiara (FileCodec.columns, costruite con columnsFromNames: nomi noti, un solo dataType), quando le conosce prima di leggere il file — csvCodec con header: false e columns, xlsxCodec con columns. Con merge la forma cambia, e il nodo torna a ereditare.
    • unpivot e pivot: le colonne d'uscita sono nella loro configurazione (keep, nameAs, valueAs; key, columns), quindi le dichiarano sempre, senza outputSchema, con columnsFromNames. Di quelle colonne è noto il nome, non il tipo: dataType è "unknown", tranne nameAs che è "string".
    • mapColumns: con rest: "drop" dichiara le colonne dello spec, ognuna con sourceColumns; il dataType viene dal cast o dal type di derive, ed è "unknown" per un rename senza cast. Il cast integer dà "integer", che l'introspezione Zod non produce mai (z.number().int() resta "number"); timestamp dà "date". Con rest: "keep" eredita da monte, a meno di un outputSchema.
    • Gli stessi nodi senza outputSchema, e le transform custom che non dichiarano columns: 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.
  2. 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 un join, o gli ingressi di un union con forme diverse) o tutti vuoti ⇒ []. Poiché transform/sink hanno esattamente un edge in ingresso (fan-in solo via join o union, cfr. validateGraph), l'eredità è deterministica: un sink eredita dal contratto a monte.

    Il ramo catch non propaga. Un edge dalla porta catch di un try-catch porta envelope DeadLetter, non i dati: non conta come predecessore. I nodi del ramo partono quindi da [] e mostrano colonne solo se ne dichiarano di proprie (es. un mapTransform con outputSchema che 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 (anche nativeEnum), literal, array, object; tutto il resto → unknown.
  • isKey = true per un campo id o 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.

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