Tema
Descriptor dei nodi per la UI del DAG
Come "dettagliare" un nodo del DAG affinché
noeva-etl-uimostri che connettore è e dove punta, senza mai esporre segreti. Campo gemello dello stessoPlanNode: le colonne del dato — quali campi scorrono nel nodo — vivono incolumns, 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
- Whitelist esplicita, mai un dump.
detailselenca campi scelti a mano. Non serializzare l'oggetto opzioni: conterrebbepassword/apiKey/pool. - 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. - Un test anti-segreto per connettore. Ogni connettore ha un
descriptor.test.tsche verificakind, i campi attesi e che nessunvaluecontenga il segreto della fixture (vedi esempi insrc/connectors/*/). Sopra c'èetl catalog check(regolapiano-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 lanciapnpm run check, e.env.exampledel core porta le variabili di tutte le pipeline del suo catalogo.
Campi esposti oggi, per connettore
| Connettore | kind | details (label) — solo non sensibili | Fonte |
|---|---|---|---|
| Postgres | postgres | host, porta, database, query (source) / table (sink) | pool.options + query/table |
| SQL Server | sqlserver | host, porta, database, query (source) / table (sink) | pool.config (runtime) + query/table |
| MySQL, MariaDB | mysql | host, porta, database, query (source) / table (sink) | |
| HTTP lookup | http-lookup | base, richiesta, modalità, credenziale (la variante di HTTP_AUTH). Mai token, chiave, utente né nome dell'header | config + opzioni del lookup |
| HTTP action | http-action | base, richiesta, su errore, scrittura, credenziale. Mai token, chiave, utente né nome dell'header | config + opzioni dell'action |
| Jira source | jira-source | sito, 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é header | config + opzioni di jiraIssues |
| Jira lookup | jira-lookup | sito, 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é chiavi | config + opzioni di jiraIssueLookup, jiraChangelogLookup, jiraCommentsLookup, jiraWorklogsLookup |
| Jira action | jira-action | sito, 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 campi | config + opzioni dell'action Jira |
| Zoho source | zoho-source | api (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 token | config + opzioni di zohoCrmRecords |
| Odoo source | odoo-source | istanza 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 campi | config + opzioni di odooRecords / odooConteggi |
| Noeva file | noeva-file | baseUrl, workspace, fileName/fileId/fuzzyQuery (source) / fileName, mode, folderId (sink) | getter pubblici del client + opzioni |
| File | file | storage, codec, path/prefix | nome storage/codec + opzioni |
| Excel | excel | storage, 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 task | excel-task | storage, path, una voce per operazione, destructive (true se c'è deleteSheet o deleteColumns) | |
| Noeva cartella | noeva-folder | baseUrl, workspace, folderId, includeSubfolders, skipIfMetadataKey, maxFiles, skipVersion (se attivo) | getter pubblici del client + opzioni |
| Noeva multi-file | noeva-files | baseUrl, workspace, mode, folderId | getter pubblici del client + opzioni |
| Noeva upload | noeva-upload | baseUrl, workspace, se già presente, elaborazione AI, folderId, categoria | getter pubblici del client + opzioni |
| Noeva elaborazione | noeva-elaborazione | baseUrl, workspace, pronto (la definizione usata) | getter pubblici del client |
| Noeva testo | noeva-contenuto | baseUrl, workspace | getter pubblici del client |
| Noeva estrazione AI | noeva-ai-extract | baseUrl, workspace, modello, campi, temperature, maxTokens (mai prompt né testi) | getter pubblici del client + schema |
| Cartella locale | local-folder | cartella (come configurata, non risolta), estensioni, età minima, maxFiles | opzioni di localFolderSource |
| IMAP | imap | host, porta; cartella, filtro, maxMessages (sorgente); aggiunge/toglie (flag); destinazione (spostamento); credenziale. Mai utente né segreti | config + opzioni di sorgente e action |
| SMTP | smtp | host, porta, mittente (SMTP_FROM), credenziale. Mai utente, segreti né destinatari | config |
| SendGrid | sendgrid | mittente (SENDGRID_FROM), base, sandbox, credenziale (solo lo stato). Mai la chiave né i destinatari | config |
| Solo log | log | esito | sink che registrano e non scrivono |
| SQL Server lookup | sqlserver-lookup | host, porta, database, query (+ connection dal registry) | pool.config (runtime) + query |
| MySQL lookup | mysql-lookup | host, porta, database, query (+ connection dal registry) | |
| Postgres lookup | postgres-lookup | host, porta, database, query (+ connection dal registry) | pool.options + query |
| Postgres task | postgres-task | host, porta, database, statement o statement 1…N (letterali mascherati), params (soli nomi), destructive (+ connection) | pool.options + opzioni del task |
| SQL Server task | sqlserver-task | host, porta, database, statement o statement 1…N (letterali mascherati), params (soli nomi), destructive (+ connection) | pool.config (runtime) + opzioni |
| MySQL task | mysql-task | host, porta, database, statement o statement 1…N (letterali mascherati), params (soli nomi), destructive (+ connection) | |
| Array (mem.) | memory | — (nessuno) | arraySource/arraySink |
| Processo esterno | processo-esterno | eseguibile, 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
Nel factory del
Source/Sink, costruisci il descriptor con i soli campi non sensibili noti alla costruzione:tsreturn { name, descriptor: { kind: "mio-sistema", details: [ { label: "endpoint", value: options.endpoint }, // non sensibile // NON: { label: "token", value: options.token } ← mai ], }, async *extract(ctx) { /* ... */ }, };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).Scrivi il test anti-segreto colocato (
descriptor.test.ts).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
segretala 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 èmancantese il gruppo è obbligatorio. Ogni alternativa porta l'elenco del gruppo inalternative. - Varianti (
selettore+varianti): variabili scelte dal valore di un'altra, comeHTTP_AUTH. Si risolvono solo quelle della variante scelta; un valore fuori elenco è un problema (variante sconosciuta). ConsenzaSelettoresi dichiarano le voci per il selettore assente:IMAP_AUTHè facoltativa, e senza vale una fra password e token. - Obbligatorietà calcolata:
obbligatoriaaccetta una funzione dei valori non segreti precedenti, come il default. Le credenziali SMTP servono solo seSMTP_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.

