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. 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):

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 PlanSpecetl planplans.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)

  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, mapTransform, groupBy 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 (ETL-3, ETL-4).
    • 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 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 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 (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 (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: "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.

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