Skip to content

Descriptor dei nodi per la UI del DAG ​

Come "dettagliare" un nodo del DAG affinché noeva-etl-ui mostri che connettore è e dove punta, senza mai esporre segreti. Campo gemello dello stesso PlanNode: le colonne del dato — quali campi scorrono nel nodo — vivono in columns, vedi node-columns.md.

Il contratto ​

Ogni nodo source/sink/lookup/ai/action/task può esporre un ConnectorDescriptor (src/core/connector.ts):

ts
type OrigineValore = "env" | "default" | "mancante" | "non impostata";

interface ConnectorDetail {
  readonly label: string; // es. "host", "database", "table", "query"
  readonly value: string;
  readonly env?: string; // variabile da cui arriva il valore, es. "IMAP_ORDINI_HOST"
  readonly origine?: OrigineValore; // da dove arriva il valore
}

interface ConnectorConnectionRef {
  readonly id: string; // nome logico dell'istanza nel registry, es. "dwh"
  readonly name?: string; // etichetta leggibile; la UI mostra name ?? id
}

interface ConnectorDescriptor {
  readonly kind: string; // es. "postgres", "sqlserver", "noeva-file", "file", "memory"
  readonly connection?: ConnectorConnectionRef; // assente senza registry
  readonly details: readonly ConnectorDetail[];
}

Un segreto non entra nel descriptor in nessuna forma, nemmeno mascherata: non c'è un valore da svelare, e quindi nemmeno un flag sensitive. Di una credenziale si dice solo quale variante è in uso e se è impostata, in una riga credenziale: password · impostata, access token · impostato, token · impostato (HTTP), chiave API · impostata (Noeva), OAuth · identità default · impostata. Se non ne è impostata nessuna, la riga elenca le alternative ammesse ed è mancante; se mancano variabili della variante scelta, le nomina (basic · mancano HTTP_USERNAME, HTTP_PASSWORD) e la sua variabile è la prima da impostare, quella che il badge deve indicare. Con OAuth2 dice anche se l'identità che rinnova il token è completa, o quali sue variabili mancano. Un utente di posta non si mostra mai, quindi se manca lo dice questa riga (… · manca IMAP_USER): senza, il nodo sembrerebbe sano. La riga credenziale non è mai il primo dettaglio: la card del nodo mostra il primo, e lì va il sistema a cui il nodo punta.

Anche un valore non segreto che sia un URL perde le credenziali scritte nella userinfo (utente:password@): lo fanno dettaglioVariabile per le righe dei nodi e variabileDelPiano per PlanSpec.configuration (withoutCredentials, src/connectors/shared/credenziali-url.ts). Un descriptor che compone un URL a mano passa da lì.

env e origine dicono quale variabile del .env ha prodotto un dettaglio e da dove arriva il valore: env (impostata), default (presa dal codice), mancante (obbligatoria e assente), non impostata (facoltativa, senza default). Servono al pannello del nodo per indicare la riga del .env da correggere e il nodo che ha davvero un problema. Un dettaglio corrisponde a una variabile: host e porta sono righe separate. Restano assenti sui dettagli che non vengono da una variabile (query, tabella, cartella scritte nella pipeline). Oggi li popolano IMAP, SMTP, SendGrid, HTTP, Jira, Zoho, Odoo, Noeva, Postgres, SQL Server e MySQL, che leggono la configurazione dal manifest (sotto); gli altri connettori arrivano un tipo alla volta. Le righe che vengono da variabili della pipeline (la cartella di salva-eml in test-posta) le annota la pipeline stessa, con conVariabili.

Il campo è opzionale su Source/Sink (descriptor?). toPlanSpec (src/core/plan-spec.ts) lo proietta su PlanNode.descriptor, da cui viaggia in etl plan → plans.generated.json → UI.

Solo i nodi che parlano con l'esterno: source, sink, lookup, ai, action e task. Il lookup (e allo stesso modo il nodo ai, per esempio noevaAiExtract con kind noeva-ai-extract) prende il descriptor dal suo LookupResolver (mssqlLookup: kind sqlserver-lookup; postgresLookup: kind postgres-lookup; entrambi con la query; httpLookup: kind http-lookup, con base, metodo ed endpoint col segnaposto {chiave}; jiraIssueLookup e i lookup delle liste Jira: kind jira-lookup, con sito, operazione e campi). L'action dalla sua Action, dove è obbligatorio. Il task dal suo Task (postgresSqlTask: kind postgres-task; mssqlSqlTask: kind sqlserver-task). transform/route/join/union/try-catch non sono connettori e non ricevono un descriptor (planNodeDescriptor ritorna undefined per quei kind).

Le tre regole vincolanti ​

  1. Whitelist esplicita, mai un dump. details elenca campi scelti a mano. Non serializzare l'oggetto opzioni: conterrebbe password/apiKey/pool.
  2. Niente segreti. host/porta/database si leggono dalla config pubblica del pool, o dalla risoluzione dell'istanza nel piano, mai da campi credenziale. Se un valore non è derivabile si omette il detail — non si inventa e non si mette undefined.
  3. Un test anti-segreto per connettore. Ogni connettore ha un descriptor.test.ts che verifica kind, i campi attesi e che nessun value contenga il segreto della fixture (vedi esempi in src/connectors/*/). Sopra c'è etl catalog check (regola piano-senza-segreti), che prende l'ambiente da .env.example, mette un valore riconoscibile al posto di ogni segreto e verifica che il piano di ogni pipeline del catalogo non lo contenga, anche con le credenziali alternative commentate. Vale in ogni repo: nel core lo lancia pnpm run check, e .env.example del core porta le variabili di tutte le pipeline del suo catalogo.

Campi esposti oggi, per connettore ​

Connettorekinddetails (label) — solo non sensibiliFonte
Postgrespostgreshost, porta, database, query (source) / table (sink)pool.options + query/table
SQL Serversqlserverhost, porta, database, query (source) / table (sink)pool.config (runtime) + query/table
MySQL, MariaDBmysqlhost, porta, database, query (source) / table (sink)
HTTP lookuphttp-lookupbase, richiesta, modalità, credenziale (la variante di HTTP_AUTH). Mai token, chiave, utente né nome dell'headerconfig + opzioni del lookup
HTTP actionhttp-actionbase, richiesta, su errore, scrittura, credenziale. Mai token, chiave, utente né nome dell'headerconfig + opzioni dell'action
Jira sourcejira-sourcesito, gateway (token con scope), jql, campi (come dichiarati, per id o per nome, updated compreso), raw (solo con raw: true), watermark (solo in incrementale: updated (timestamp) e la sovrapposizione), credenziale («token classico» o «token con scope» e lo stato). Mai token, email né headerconfig + opzioni di jiraIssues
Jira lookupjira-lookupsito, gateway (token con scope), operazione (issue per chiave; per le liste changelog per issue, commenti per issue, worklog per issue con l'endpoint), campi (come dichiarati, per id o per nome, updated compreso; per il changelog i fieldIds, se filtra), raw (solo con raw: true), credenziale («token classico» o «token con scope» e lo stato). Mai token, email, header né chiaviconfig + opzioni di jiraIssueLookup, jiraChangelogLookup, jiraCommentsLookup, jiraWorklogsLookup
Jira actionjira-actionsito, gateway (token con scope), operazione (con l'endpoint, senza la chiave della issue), su errore, idempotenza, limite (chiamate al secondo, letture comprese), ID esterno e creazione (solo l'upsert: il campo con la sua variabile, e la creazione mai ritentata), deduplica (solo commenti e worklog: la proprietà che porta la chiave e dove si cerca, mai la chiave), verso (solo i link: partenza → arrivo), testo ricco (i campi che accettano markdown), credenziale. Mai token, email, header né valori dei campiconfig + opzioni dell'action Jira
Zoho sourcezoho-sourceapi (la base delle API, con la sua variabile), modulo, lettura (Records API o COQL), campi (i nomi API dichiarati), credenziale (l'identità OAuth2 che rinnova il token e, nel piano, se è completa). Mai token, client secret né refresh tokenconfig + opzioni di zohoCrmRecords
Odoo sourceodoo-sourceistanza e database (con le loro variabili), modello, dominio (se c'è, troncato), lettura, campi (i nomi tecnici dichiarati), credenziale (il protocollo e se login e chiave sono impostati). Mai la chiave né il login; odooConteggi mostra i modelli al posto di modello e campiconfig + opzioni di odooRecords / odooConteggi
Noeva filenoeva-filebaseUrl, workspace, fileName/fileId/fuzzyQuery (source) / fileName, mode, folderId (sink)getter pubblici del client + opzioni
Filefilestorage, codec, path/prefixnome storage/codec + opzioni
Excelexcelstorage, path o prefix, foglio, riga d'intestazione (source); storage, path, foglio, modalità, destructive, e con cancellaQuando il campo e cancellaRighe (sink). Mai valori dei record
Excel taskexcel-taskstorage, path, una voce per operazione, destructive (true se c'è deleteSheet o deleteColumns)
Noeva cartellanoeva-folderbaseUrl, workspace, folderId, includeSubfolders, skipIfMetadataKey, maxFiles, skipVersion (se attivo)getter pubblici del client + opzioni
Noeva multi-filenoeva-filesbaseUrl, workspace, mode, folderIdgetter pubblici del client + opzioni
Noeva uploadnoeva-uploadbaseUrl, workspace, se già presente, elaborazione AI, folderId, categoriagetter pubblici del client + opzioni
Noeva elaborazionenoeva-elaborazionebaseUrl, workspace, pronto (la definizione usata)getter pubblici del client
Noeva testonoeva-contenutobaseUrl, workspacegetter pubblici del client
Noeva estrazione AInoeva-ai-extractbaseUrl, workspace, modello, campi, temperature, maxTokens (mai prompt né testi)getter pubblici del client + schema
Cartella localelocal-foldercartella (come configurata, non risolta), estensioni, età minima, maxFilesopzioni di localFolderSource
IMAPimaphost, porta; cartella, filtro, maxMessages (sorgente); aggiunge/toglie (flag); destinazione (spostamento); credenziale. Mai utente né segreticonfig + opzioni di sorgente e action
SMTPsmtphost, porta, mittente (SMTP_FROM), credenziale. Mai utente, segreti né destinatariconfig
SendGridsendgridmittente (SENDGRID_FROM), base, sandbox, credenziale (solo lo stato). Mai la chiave né i destinatariconfig
Solo loglogesitosink che registrano e non scrivono
SQL Server lookupsqlserver-lookuphost, porta, database, query (+ connection dal registry)pool.config (runtime) + query
MySQL lookupmysql-lookuphost, porta, database, query (+ connection dal registry)
Postgres lookuppostgres-lookuphost, porta, database, query (+ connection dal registry)pool.options + query
Postgres taskpostgres-taskhost, porta, database, statement o statement 1…N (letterali mascherati), params (soli nomi), destructive (+ connection)pool.options + opzioni del task
SQL Server tasksqlserver-taskhost, porta, database, statement o statement 1…N (letterali mascherati), params (soli nomi), destructive (+ connection)pool.config (runtime) + opzioni
MySQL taskmysql-taskhost, porta, database, statement o statement 1…N (letterali mascherati), params (soli nomi), destructive (+ connection)
Array (mem.)memory— (nessuno)arraySource/arraySink
Processo esternoprocesso-esternoeseguibile, argomenti fissi, versione, timeout, ambiente (solo i nomi delle variabili in allowlist, mai i valori)opzioni dell'action

Tutte le righe Noeva della tabella cominciano con baseUrl e workspace, da clientDetails. Con un client costruito dal manifest, subito dopo ci sono la riga configurazione (solo nel client d'esempio, per un valore non valido) e la riga credenziale con lo stato della chiave API (NOEVA_API_KEY). Un client scritto a mano non ha la credenziale.

La query è mostrata troncata a 120 caratteri (queryDetail): è un "puntamento", non il payload. Nel piano, dove non c'è un pool, host, porta e database vengono dalla risoluzione dell'istanza (vedi sotto).

Aggiungere un descriptor a un nuovo connettore ​

  1. Nel factory del Source/Sink, costruisci il descriptor con i soli campi non sensibili noti alla costruzione:

    ts
    return {
      name,
      descriptor: {
        kind: "mio-sistema",
        details: [
          { label: "endpoint", value: options.endpoint }, // non sensibile
          // NON: { label: "token", value: options.token }  ← mai
        ],
      },
      async *extract(ctx) {
        /* ... */
      },
    };
  2. Se i campi vengono da un client/pool con credenziali, esponi getter pubblici solo per i valori non sensibili (vedi NoevaHttpClient.baseUrl/workspaceId) e un helper dedicato (src/connectors/<sistema>/descriptor.ts).

  3. Scrivi il test anti-segreto colocato (descriptor.test.ts).

  4. Aggiorna la riga di questo file e la scheda del connettore in docs/connectors/.

getSpec() senza credenziali: connettori stub ​

etl plan non apre connessioni, quindi le pipeline con connettori a DB o API derivano il piano dal grafo reale costruito con connettori stub (vedi crm-leads-sync/run.ts). Il descriptor sta sullo stub, coi campi noti senza connessione (kind, query, tabella). Riusa queryDetail dal barrel del connettore per troncare la query in modo coerente con il connettore vero. Non si scrive un PlanSpec letterale a mano.

Host, porta e database di uno stub Postgres o SQL Server non vengono da un pool, ma dalla risoluzione dell'istanza del registry: istanzaPgPerIlPiano / istanzaMssqlPerIlPiano / istanzaMysqlPerIlPiano danno la ConnessioneSql dell'istanza, e dettagliConnessioneSql (src/connectors/shared/connessione-sql.ts) ne fa le righe host, porta e database con variabile e origine, poi quelle del nodo e in fondo la credenziale (password · impostata). L'istanza si carica con loadPgConnessione / loadMssqlConnessione, che non leggono le altre: un'istanza dwh incompleta non porta all'esempio la default.

ts
const c = configurazioneDelPiano({
  pg: istanzaPgPerIlPiano(PG_INSTANCE, process.env),
  mssql: istanzaMssqlPerIlPiano(MSSQL_INSTANCE, process.env),
});
const descriptor = {
  kind: "postgres",
  connection: { id: PG_INSTANCE },
  details: dettagliConnessioneSql(c.pg, [queryDetail(SOURCE_QUERY)]),
};

Le pipeline che costruiscono il piano coi connettori veri risolvono la configurazione un'istanza alla volta, con configurazioneDelPiano (src/connectors/shared/configurazione-istanze.ts). Ogni istanza di connettore, e la configurazione della pipeline, è un IstanzaPerIlPiano: la configurazione risolta e, se a un ConfigError non si costruisce, il suo esempio. Un'istanza rotta usa l'esempio solo nei nodi che la usano; le altre, anche dello stesso tipo, restano risolte. L'esempio porta le variabili dell'istanza vera, quindi le righe mostrano i valori impostati e il segnaposto compare solo accanto a una variabile mancante. Se a rompere l'istanza è un valore non valido, che nessun badge mancante spiega (due credenziali insieme, una porta non numerica, un id fuori da <PREFIX>_INSTANCES), i suoi nodi portano anche una riga configurazione: non valida: da controllare <VARIABILI>, coi soli nomi (righeNonValida): senza, il nodo sembrerebbe sano e la run fallirebbe.

ts
const istanze = istanzeTestPosta(process.env);
const c = configurazioneDelPiano({
  config: configTestPostaPerIlPiano(process.env),
  imap: istanzaImapPerIlPiano(istanze.imap, process.env),
  smtp: istanzaSmtpPerIlPiano(istanze.smtp, process.env),
});

Le istanze si caricano con loadImapInstance / loadSmtpInstance / loadHttpInstance, che non leggono le altre istanze del registry; istanzaHttpPerIlPiano è il loro IstanzaPerIlPiano per HTTP. Il client Noeva non ha un registry per istanza: istanzaNoevaPerIlPiano è il primo workspace servito (NOEVA_WORKSPACE_IDS, o il singolo NOEVA_WORKSPACE_ID), come lo carica loadNoevaWorkspaces. Le caselle IMAP e i server SMTP del piano portano anche la risoluzione della loro identità OAuth2 (risolviIdentitaOauth), che la riga credenziale usa. La run invece resta sul registry, che le carica tutte, e compone con configurazioneDellaRun: fail-fast, con un ConfigError che elenca le variabili mancanti di tutte le istanze insieme. Le righe che vengono da variabili della pipeline, non del connettore (la cartella di salva-eml), si annotano con conVariabili. Riferimenti: test-posta/run.ts e, per il client Noeva, cross-workspace-csv-merge/run.ts.

La ricaduta è sempre per istanza: non c'è più un ripiego sull'esempio per tutto il piano a qualsiasi ConfigError, né un dettaglio configurazione che dica «d'esempio» su ogni nodo. Un valore d'esempio si riconosce dall'origine mancante della sua riga.

La configurazione della pipeline nel piano ​

Alcune variabili della pipeline non finiscono in nessun nodo: il destinatario di test-posta, un tempo d'attesa. Per non lasciarle invisibili, il piano porta a livello di pipeline la configurazione della pipeline, PlanSpec.configuration: tutte le variabili del suo manifest, nell'ordine dichiarato, ognuna con env, origine e value. value c'è solo con origine env o default; di una variabile segreta c'è solo lo stato (impostata), e una mancante non mostra un segnaposto. È pensata per il pannello a nodo non selezionato della UI.

ts
return conConfigurazioneDellaPipeline(
  buildTestPostaPipeline(c.config, dipendenze(c)).getSpec(),
  c.config, // le `variabili` della config, risolte anche quando è d'esempio
);

conConfigurazioneDellaPipeline (src/connectors/shared/configurazione-istanze.ts) passa per extendPlan, che conserva le dichiarazioni delle sub-pipeline del piano: uno spread le perderebbe. Una pipeline senza manifest delle variabili resta senza campo. La configurazione di pipeline cambia il piano, quindi il suo snapshot: chiede un bump (Versioni di pipeline).

I parametri di lancio (PlanSpec.runParams) sono quelli che la pipeline dichiara con graph(nome, { runParams }): nome, tipo, obbligatorietà e descrizione. Il campo manca se la pipeline non ne dichiara, e l'app apre il dialog di lancio solo quando c'è. Nello snapshot entrano senza la descrizione.

L'ora di calcolo (PlanSpec.computedAt) non la mette getSpec(), ma il server dei grafi quando costruisce la cache all'avvio: uno snapshot con l'orologio dentro cambierebbe a ogni generazione (runbook del server).

Dipendenze di un'istanza di sub-pipeline ​

Una sub-pipeline non costruisce connettori: li riceve dalla root come dipendenze, e nel piano PlanInstance.deps porta il descriptor di ognuno. È il descriptor del connettore com'è, quindi segue lo stesso contratto dei nodi: env e origine per riga, riga credenziale senza segreti. La root costruisce i connettori delle dipendenze con la stessa configurazioneDelPiano dei suoi nodi, e la ricaduta resta per istanza di connettore: una dipendenza su un'istanza rotta è d'esempio da sola, con le variabili mancanti.

ts
const c = configurazioneDelPiano({
  notifiche: istanzaSmtpPerIlPiano("notifiche", process.env),
  archivio: istanzaSmtpPerIlPiano("archivio", process.env),
});
graph("…").pipeline("ordini", "avvisa-ordini", avvisaOrdini, {
  deps: {
    avviso: smtpAction(c.notifiche, { instanceId: "notifiche", … }),
    archivio: smtpAction(c.archivio, { instanceId: "archivio", … }),
  },
});

Non serve altro: il core copia il descriptor della dipendenza nel piano, e non lo riscrive. Lo provano src/connectors/shared/dipendenze-istanze.test.ts e il test anti-segreto sull'intero catalogo (etl catalog check). Una root con dipendenze la prova src/catalog/piano-senza-segreti.test.ts, con gli stessi ambienti di prova del comando, perché nessuna pipeline del catalogo ne istanzia una oggi. In noeva-etl-ui, dal pannello dell'istanza, la vista dell'istanza mostra le righe delle dipendenze con lo stesso componente dei nodi (ConnectorDetailList), badge di origine compresi.

Variabile e origine: dal manifest, mai a mano ​

I nomi delle variabili non si scrivono nei descriptor: comparirebbero due volte, nel loader e nel descriptor, e prima o poi divergerebbero. Si dichiarano una volta in un manifest (src/connectors/shared/manifest-variabili.ts) e si risolvono con risolviVariabili:

ts
const manifest: ManifestVariabili = [
  { suffisso: "HOST", obbligatoria: true },
  { suffisso: "SECURE", default: "true" },
  {
    suffisso: "PORT",
    default: (v) => (v("SECURE") === "true" ? "993" : "143"),
  },
  {
    unaFra: [
      { suffisso: "PASSWORD", segreta: true },
      { suffisso: "ACCESS_TOKEN", segreta: true },
    ],
    obbligatoria: true,
  },
];

const risoluzione = risolviVariabili(
  manifest,
  instanceEnvPrefix(PREFISSO_IMAP, id),
  env,
);
// risoluzione.variabili: { variabile, origine, segreta, valore? } per ogni variabile
// risoluzione.mancanti:  tutte le variabili obbligatorie assenti
richiediCompleta(risoluzione); // la run: ConfigError con `mancanti` strutturato
  • Segreti: di una variabile segreta la risoluzione restituisce solo l'origine, mai il valore — nemmeno al default calcolato di un'altra variabile.
  • Gruppi «una fra» (unaFra): credenziali alternative; ne va impostata una sola. Più di una è un problema (alternative multiple), nessuna è mancante se il gruppo è obbligatorio. Ogni alternativa porta l'elenco del gruppo in alternative.
  • Varianti (selettore + varianti): variabili scelte dal valore di un'altra, come HTTP_AUTH. Si risolvono solo quelle della variante scelta; un valore fuori elenco è un problema (variante sconosciuta). Con senzaSelettore si dichiarano le voci per il selettore assente: IMAP_AUTH è facoltativa, e senza vale una fra password e token.
  • Obbligatorietà calcolata: obbligatoria accetta una funzione dei valori non segreti precedenti, come il default. Le credenziali SMTP servono solo se SMTP_USER è impostato.
  • I prefissi base sono costanti di src/connectors/shared/prefissi.ts (PREFISSO_PG, PREFISSO_IMAP…), le stesse che usano registry e loader.

La funzione è pura: il piano mostra la risoluzione com'è, la run chiama richiediCompleta e si ferma con ConfigError come prima. Il loader mette risoluzione.variabili sulla config (ImapConfig.variabili), e il descriptor ne ricava le righe con dettaglioVariabile(label, valore, variabileDi(config.variabili, "HOST")): valore, variabile e origine. Le credenziali di posta hanno la loro riga, dettaglioCredenzialiPosta; Postgres, SQL Server e MySQL, che hanno la sola password, la mettono in fondo a dettagliConnessioneSql. ConfigError.mancanti elenca tutte le variabili mancanti, non solo la prima, ed è vuoto per gli errori che non riguardano variabili assenti.

Nodi dead-letter ​

toPlanSpec proietta il deadLetter di un nodo transform/join in un nodo sintetico stepType: "dead-letter" + edge kind: "dead-letter". Quel nodo riusa il descriptor del sink di scarto per dire dove finiscono gli errori (kind + destinazione), esattamente come un sink normale — nessuna regola nuova qui, solo un altro stepType che porta lo stesso descriptor?.

Sync con la UI ​

Il contratto è duplicato in noeva-etl-ui (submodule indipendente): tipo ConnectorDescriptor, schema Zod in plan-loader.ts, rendering in DatasetNode/DetailPanel. Modifiche al descriptor vanno propagate su entrambi i lati nello stesso PR-set. Lato UI: noeva-etl-ui/docs/dag-node-visualization.md.

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