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. Decisione e razionale: ADR-0020. Campo gemello dello stessoPlanNode: le colonne del dato — quali campi scorrono nel nodo — vivono incolumns, vedi node-columns.md (ADR-0026).
Il contratto
Ogni nodo source/sink/lookup può esporre un ConnectorDescriptor (src/core/connector.ts):
interface ConnectorDetail {
readonly label: string; // es. "host", "database", "table", "query"
readonly value: string;
readonly sensitive?: boolean; // hint di rendering: la UI mostra "••••"
}
interface ConnectorConnectionRef {
readonly id: string; // nome logico dell'istanza nel registry (ADR-0030), 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[];
}sensitive è solo un suggerimento per la UI, non una protezione: un detail marcato sensitive porta al più un valore già parziale calcolato dall'engine (es. ••••ABCD), mai il segreto intero. La garanzia resta la regola 2 qui sotto. Oggi nessun connettore lo usa.
Il campo è opzionale su Source/Sink (descriptor?). toPlanSpec (src/core/graph.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 e lookup. Il lookup prende il descriptor dal suo LookupResolver (mssqlLookup: kind sqlserver-lookup; postgresLookup: kind postgres-lookup; entrambi con la query). transform/join/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/port/database si leggono dalla config pubblica del pool, mai da campi credenziale. Se un valore non è derivabile (es. host senza pool live), 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/*/).
Campi esposti oggi, per connettore
| Connettore | kind | details (label) — solo non sensibili | Fonte |
|---|---|---|---|
| Postgres | postgres | host, port, database, query (source) / table (sink) | pool.options + query/table |
| SQL Server | sqlserver | host, port, database, query (source) / table (sink) | pool.config (runtime) + query/table |
| Noeva file | noeva-file | baseUrl, workspace, fileName/fileId/fuzzyQuery, folderId | getter pubblici del client + opzioni |
| File | file | storage, codec, path/prefix | nome storage/codec + opzioni |
| Noeva cartella | noeva-folder | baseUrl, workspace, folderId, includeSubfolders, skipIfMetadataKey, maxFiles | getter pubblici del client + opzioni |
| Noeva multi-file | noeva-files | baseUrl, workspace, mode, folderId | getter pubblici del client + opzioni |
| SQL Server lookup | sqlserver-lookup | host, port, database, query (+ connection dal registry) | pool.config (runtime) + query |
| Postgres lookup | postgres-lookup | host, port, database, query (+ connection dal registry) | pool.options + query |
| Array (mem.) | memory | — (nessuno) | arraySource/arraySink |
La query è mostrata troncata a 120 caratteri (queryDetail): è un "puntamento", non il payload. host/database restano esclusi quando non c'è un pool attivo (caso getSpec() statico, 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, langfuse-sessions-daily/run.ts). Il descriptor sta sullo stub, coi soli campi noti senza connessione (kind, query, tabella); host/database si omettono. 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 (ETL-5).
Nodi dead-letter
toPlanSpec proietta il deadLetter di un nodo transform/join in un nodo sintetico stepType: "dead-letter" + edge kind: "dead-letter" (ADR-0027). 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.

