Guida al progetto — noeva-etl
Guida di orientamento pratica: come funziona il progetto e come svilupparci. Per la visione di prodotto vedi Technical Overview; per le regole vincolanti vedi ../CLAUDE.md.
1. Cos'è in breve
noeva-etl è un motore ETL code-first: le pipeline di integrazione dati (estrazione → trasformazione → caricamento) sono scritte in TypeScript, testate con vitest e documentate — non job grafici stile Talend, ma software vero.
Il servizio è stateless: non ha un proprio database, esegue una pipeline e restituisce un GraphRunReport (JSON). Si distribuisce come immagine Docker con entrypoint la CLI etl, che si avvia in tre modi:
etl serve— il default dell'immagine: nello stesso processo espone la topologia dei grafi via HTTP (pernoeva-etl-ui) e avvia i worker. Un solo comando fa girare la piattaforma, in dev come in Docker (ADR-0028, runbooks/graph-server.md).etl worker(src/worker/) — solo il daemon: resta in polling sulla tabellaprocessesdi Noeva e dispatcha le pipeline i cui job vengono creati dal sistema Noeva stesso (workspace/scheduled job), invece che da un cron esterno. Il polling passa sempre tramitenoeva-server-api, mai un client Supabase diretto (ADR-0019, che supera ADR-0017; runbooks/etl-worker.md). Mai insieme a unservenello stesso ambiente, o i job vengono presi due volte.etl run <pipeline>— batch: una esecuzione, report su stdout, poi termina.
In tutti e tre i casi il nucleo che esegue la pipeline è lo stesso: cambia solo chi la fa partire e dove finisce il report.
2. Le primitive del core
Tutto il framework (src/core/, senza logica di dominio) ruota attorno a quattro concetti base, mappati ai componenti Talend:
| Primitiva | File | Cosa fa |
|---|---|---|
Source<T> | src/core/connector.ts | estrae record da un sistema esterno come AsyncIterable<T> (streaming, mai tutto in memoria) |
Transform<I,O> | src/core/transform.ts | trasforma uno stream I in uno stream O; pura, senza side-effect esterni |
Sink<T> | src/core/connector.ts | carica i record su un sistema di destinazione; deve essere idempotente (upsert/MERGE) |
GraphPipeline | src/core/graph-builder.ts | il processo: un grafo di nodi costruito col DSL graph(name)…build() |
Il grafo è l'unico modo di costruire un processo: il vecchio DSL lineare (pipeline().pipe(), RunReport, run(ctx, { deadLetter })) è stato rimosso (ADR-0036). Anche una catena semplice è un grafo con un nodo per stadio:
graph("orders-daily")
.source("orders", source)
.transform("orders", "raw-contract", validate("raw-contract", rawSchema), {
deadLetter: rejects,
})
.transform("raw-contract", "to-fact", mapTransform("to-fact", toFact))
.transform("to-fact", "fact-contract", validate("fact-contract", factSchema))
.sink("fact-contract", sink)
.build();I sei tipi di nodo:
| Nodo | Metodo | Cosa fa |
|---|---|---|
source | .source(id, source) | punto d'ingresso di uno stream |
transform | .transform(from, id, t, opts) | trasformazione pura; accetta deadLetter |
lookup | .lookup(from, id, config, opts) | query SQL in sola lettura in mezzo al grafo, a batch (ADR-0041); accetta deadLetter |
join | .join(id, { build, probe, … }) | unico fan-in: join a chiave inner/left, lato build in memoria e probe in streaming; accetta deadLetter |
try-catch | .tryCatch(from, id, opts) | apre uno scope di gestione errori sui nodi a valle, con una porta catch |
sink | .sink(from, sink) | scrittura verso l'esterno; l'id del nodo è sink.name |
Il fan-out invece è libero: basta agganciare più nodi allo stesso from, e il runner duplica lo stream (tee). La forma del grafo è validata a runtime su .build() (validateGraph() in src/core/graph.ts): cicli, id sconosciuti o più di un ingresso su un nodo diverso da join esplodono come GraphValidationError alla costruzione — vedi ADR-0016 per il perché. Esempio di riferimento completo: src/pipelines/orders-enriched-daily/.
Altri moduli core:
context.ts—RunContext: env, params, logger, metriche,AbortSignal.observability.ts— logger JSON strutturato + contatori.errors.ts— gerarchiaEtlError(ValidationError,ExtractError,LoadError,ConfigError,GraphValidationError,JoinError,LookupError) per distinguere errori di dominio da bug; il contesto porta anchenodeId.transform.ts— gli helpermapTransform,filterTransform,validate(name, schema)(contratto Zod).graph-runner.ts— esegue il grafo e produce ilGraphRunReport.lineage.ts,progress.ts,schema-columns.ts— identità dei record, report di avanzamento per nodo, colonne dei nodi per la UI.
Il dettaglio operativo (porte, regole di validazione, registry, metadati per la UI) è nella skill etl-graph (.claude/skills/etl-graph/).
3. Come gira una pipeline
Il grafo è pull-based: la domanda parte dai sink e risale verso le source, quindi è il ritmo di scrittura a regolare quello di lettura (backpressure gratis). I record scorrono in streaming, mai tutti in memoria — tranne il lato build di un join.
Quando un record fallisce ci sono tre percorsi, dal più locale al più drastico:
- Dead-letter per nodo —
{ deadLetter: sink }su untransform, unlookupo unjoin. Il record che sollevaEtlErroresce come envelopeDeadLettersu quel sink e lo stream continua. Adatto alle trasformazioni stateless (map/filter/validate). - Nodo
try-catch— protegge tutti i nodi raggiungibili dalla sua portaout, sink compresi. Di default èper-record: ogni record che fallisce va sul ramocatch, che è fatto di nodi veri e può loggare, rimappare o instradare altrove. Incircuit-breakeral primo errore la sub-stream si ferma (ADR-0029, ADR-0031). - Fail-fast — il default, senza dead-letter né guard: l'errore aborta la run. È l'unica scelta sensata per le trasformazioni stateful (es.
groupByinsrc/transforms/group-by.ts): un'aggregazione spezzata per record darebbe un risultato falso.
Un errore che non è un EtlError (un TypeError, un bug) aborta sempre la run, anche dentro un guard: un bug non va scaricato in un sink di scarto, va visto. Qualunque sia la scelta, nessun record sparisce in silenzio: ogni scarto è contato nel report.
Ogni run() produce un GraphRunReport (src/core/graph-runner.ts):
- stato —
success,partial(arrivata in fondo con scarti o errori catturati) ofailed; - totali —
extracted(somma delle source),written(somma dei sink: su un fan-out lo stesso record scritto due volte conta due),rejected,rejectedReasons; - per nodo —
nodes[]ededges[]con la topologia reale e i contatori di ogni nodo,caughtsui guard, tempi di attraversamento; - metriche ed errore — mai segreti o payload.
È il "prodotto" del servizio: chi invoca la pipeline decide se archiviarlo, alertare, ecc. Schema completo in conventions/run-report.md.
4. Cosa c'è già pronto
Connettori (src/connectors/):
- PostgreSQL — source con cursor streaming, sink con upsert
ON CONFLICT. - SQL Server — source streaming, sink con
MERGE. - File (
src/connectors/file/) — architettura a due assi componibili:- storage: dove vivono i file (locale, S3, SFTP, FTP/FTPS);
- codec: come si (de)serializzano — CSV (anche grezzo,
csvRawCodec), JSON/NDJSON, XML, XLSX, Parquet. Storage e codec si combinano liberamente (es. CSV su S3, Parquet su SFTP).
- Noeva (
src/connectors/noeva/) — file dei workspace Noeva vianoeva-server-api: lettura di un file o di una cartella intera, scrittura di uno o più file inoverwriteonew-version(ADR-0014, ADR-0042).
Trasformazioni riusabili (src/transforms/): group-by.ts (aggregazione stateful generica init/reduce/finalize).
Pipeline di esempio:
src/pipelines/langfuse-sessions-daily/— legge le sessioni da Postgres, le aggrega per progetto/giorno congroupBy, carica i fatti su SQL Server. È l'esempio end-to-end cross-database, idempotente, da usare come riferimento per un grafo a catena (una source, un sink; schema, pipeline, run, test).src/pipelines/orders-enriched-daily/— dataset demo in-memory, esempio di riferimento per una pipeline a grafo: due sorgenti, join a chiave, fan-out su due sink, dead-letter isolato su due nodi indipendenti (vedi docs/pipelines/orders-enriched-daily.md).
Le pipeline disponibili si registrano in src/pipelines/registry.ts e sono eseguibili via CLI (src/cli.ts, entrypoint pnpm run etl).
Worker daemon (src/worker/) — poller sulla tabella processes:
config.ts—loadEtlWorkerConfiglegge il singolo workspace daNOEVA_WORKSPACE_ID,NOEVA_API_BASE_URL,NOEVA_API_KEY(obbligatorie),ETL_WORKER_TYPE(default"etl"),ETL_POLL_INTERVAL_MS(default2000); fail-fast se mancante/invalida.loadEtlWorkerConfigs(plurale) legge invece la listaNOEVA_WORKSPACE_IDS(workspace separati da virgola, stessaNOEVA_API_KEYper tutti) — così una singola istanza serve N workspace (ADR-0021) — con fallback al singoloNOEVA_*se la variabile non è impostata.run-workers.ts—startWorkers: crea unEtlProcessWorkerper config workspace e ne gestisce lo shutdown combinato; è ciò che rende l'istanza multi-workspace (un poller indipendente per workspace).processes-api-client.ts—ProcessesApiClient, client HTTP versonoeva-server-api(stessi headerAuthorization: Bearer/x-noeva-workspacedel connettore workspace files):claimNextEtlProcess(POST /api/processes/etl/claim, CAS+filtro perworker_type/workspace_idfatti server-side) ereportProgress(PATCH /api/processes/etl/:processId/progress). Il worker non ha mai credenziali Supabase dirette.dispatch.ts— risolveprocess.functioninregistry.ts(match esatto, fail-fast se sconosciuta), esegue la pipeline e mappa ilGraphRunReportsull'esito riportato viaProcessesApiClient.reportProgress(completed/failed).etl-process-worker.ts— il loopstart()/stop(), un job alla volta per poller, shutdown cooperativo suSIGTERM/SIGINT. Con più workspace, i poller girano in parallelo e sono isolati fra loro.
Dettagli operativi (come inserire un job di test, monitoraggio, recovery, config multi-workspace): runbooks/etl-worker.md. Decisioni architetturali: il polling passa da noeva-server-api invece di un client Supabase diretto nel worker (ADR-0019, supersede ADR-0017); una singola istanza serve N workspace via N poller (ADR-0021).
5. Setup e comandi
Requisiti: Node ≥ 20, pnpm 9. Package manager: solo pnpm, mai npm/yarn.
pnpm install
pnpm run check # gate completo: typecheck + lint + format + test:cov + build + sito doc, varianti pubblica e app (= CI)
pnpm run test:watch # TDD
pnpm run test:cov # coverage con soglie (gate: lines/statements/functions 80%, branches 75%)
pnpm run lint:fix # autofix lint
pnpm run format # autofix formattazione
pnpm run docs:dev # sito di documentazione in locale, variante pubblica (ADR-0043)
pnpm run docs:dev:app # variante dell'app desktop, con le schede pipeline (ADR-0044)
pnpm run etl list # elenca le pipeline registrate
pnpm run etl run langfuse-sessions-daily # esegue una pipeline (legge .env)
pnpm run etl:serve # grafi via HTTP + worker (vedi runbooks/graph-server.md)
pnpm run etl worker # solo il daemon su processes (vedi runbooks/etl-worker.md)
pnpm run etl runs list # storico locale delle run (ADR-0039)
./dev-desktop.sh # dev completo: etl serve + app desktop Tauri
pnpm run test:integration # test contro sistemi reali (DB, S3, SFTP), separati dalla suite rapidaConfig solo da variabili d'ambiente (.env, git-ignored): PG*, MSSQL_*, ETL_ENV, credenziali S3/SFTP, ecc. Ogni variabile va documentata in .env.example e nella scheda della pipeline che la usa. Per etl worker servono inoltre NOEVA_WORKSPACE_ID, NOEVA_API_BASE_URL, NOEVA_API_KEY (obbligatorie) e ETL_WORKER_TYPE/ETL_POLL_INTERVAL_MS (opzionali) — nessuna credenziale Supabase: il worker parla solo con noeva-server-api. Per servire più workspace con una sola istanza, in alternativa a NOEVA_WORKSPACE_ID si imposta NOEVA_WORKSPACE_IDS alla lista dei workspace (separati da virgola); la NOEVA_API_KEY è una sola e vale per tutti (vedi il runbook).
Distribuzione: docker build -t noeva-etl ., poi ./deploy-ecr.sh (build multi-arch + push su ECR) e ./run-ecr.sh <pipeline> per l'esecuzione da ECR in locale. L'immagine parte di default con serve. La UI non passa da ECR: è un'app desktop Tauri (ADR-0025). Dettagli: runbooks/deployment.md.
6. Come sviluppare — workflow consigliato
- Capire il requisito. Se ambiguo, chiedere prima di scrivere codice.
- Aprire un worktree per l'attività:
.claude/worktrees/<descrizione>/con un branchfeat/*ofix/*daorigin/dev. Ogni attività ha la sua cartella, così se ne portano avanti più d'una in parallelo e la cartella principale resta sudev. Comandi e insidie: conventions/git-workflow.md. - Cercare riuso prima di scrivere codice nuovo: un connettore/storage/codec esistente copre già il caso? Una libreria matura risolve il problema meglio di codice hand-rolled?
- Scaffold con i comandi dedicati (generano struttura + test + doc coerenti, vedi §7):
/new-pipeline <nome>— nuova pipeline/new-connector <nome> [source|sink]— nuovo sistema esterno/new-transform <nome>— nuova logica di trasformazione
- TDD: test prima (rosso) → implementazione minima (verde) → refactor. Per un bug fix: prima un test che lo riproduce, poi la correzione.
- Verificare:
pnpm run check. Per una review mirata ETL:/etl-reviewo l'agenteetl-reviewer(idempotenza, contratti, dead-letter, osservabilità). - Documentare nello stesso commit: scheda pipeline (
docs/pipelines/<nome>.md), ADR se la scelta è strutturale (nuova dipendenza, nuovo pattern), runbook se cambia un processo operativo. Se hai aggiunto, modificato o rimosso una funzionalità, a lavoro finito esegui il doc sync (/doc-synco l'agentedoc-syncer) e chiudi ogni deriva prima della PR. - Commit/PR: mai direttamente su
dev/staging/main— si committa nel worktree, sul suo branch, e si apre la PR versodev. Dopo il merge si rimuovono worktree e branch. Promozionedev → staging → mainsempre via PR, mai saltando un tier.
7. Aggiungere le cose più comuni
Una nuova pipeline
Una cartella src/pipelines/<nome>/ con: schema.ts (contratti Zod input/output), pipeline.ts (definizione con graph()...build(), sorgente/sink iniettati, non hardcoded), run.ts (wiring dei connettori concreti + lettura env, escluso dalla coverage perché è solo DI), pipeline.test.ts (end-to-end con arraySource/arraySink: record validi caricati, invalidi al dead-letter, metriche corrette). Registrarla in src/pipelines/registry.ts e creare docs/pipelines/<nome>.md.
Un nuovo connettore (Source o Sink)
Implementare l'interfaccia Source<T> o Sink<T> (src/core/connector.ts) verso il nuovo sistema (API, file, altro DB, coda). Grazie alla dependency injection, tutte le pipeline esistenti possono usarlo subito senza toccare il core. Per un nuovo storage/codec file, seguire il pattern a due assi già in src/connectors/file/ (storage e codec si combinano liberamente). Aggiungere un ADR se introduce una nuova libreria/dipendenza (vedi docs/architecture/adr/ per i precedenti: es. 0009 per S3/aws-sdk, 0011 per SFTP/ssh2, 0013 per Parquet). Documentare in docs/connectors/README.md.
Una nuova trasformazione
Funzione pura in src/transforms/, senza side-effect verso sistemi esterni (quelli stanno solo in Source/Sink), così è testabile in isolamento. Riusabile tra pipeline diverse. Se stateful (come group-by.ts), documentare chiaramente che va usata in fail-fast: niente dead-letter sul suo nodo (vedi §3).
8. Cosa NON fare (vincoli non negoziabili)
- Niente logica di dominio in
src/core/— il core resta agnostico e stabile. - Niente
anyinsrc/— dati esterni entrano comeunknown, si restringono con Zod. - Niente scarti silenziosi — ogni record invalido ha un percorso esplicito: dead-letter o fail-fast, mai un semplice skip.
- Sink non idempotenti — un retry/riesecuzione non deve mai duplicare dati (upsert su chiave naturale, MERGE, o write-then-swap).
- Niente segreti nel codice, nei log o nel report — solo variabili d'ambiente, documentate in
.env.example. - Codice senza test o senza doc aggiornata non è "done" — vedi la checklist completa in ../CLAUDE.md §5.
- Mai committare/pushare direttamente su
dev,staging,main.
9. Mappa della documentazione
- README.md — quick start e struttura.
- TECHNICAL_OVERVIEW.md — visione di prodotto (cosa è/non è, direzione).
- Sito di documentazione — gli stessi documenti con percorsi, ricerca e pagine interattive (
pnpm run docs:dev, odocs:dev:appper la variante dell'app, ADR-0044). - architecture/overview.md — architettura, layout repo, principi.
- architecture/adr/ — decisioni tracciate, con indice.
- conventions/testing-strategy.md — strategia di test.
- conventions/documentation.md — policy di documentazione.
- conventions/run-report.md — schema del report di run.
- connectors/README.md — connettori disponibili.
- pipelines/README.md — schede delle pipeline.
- runbooks/deployment.md — build e deploy su ECR/Docker, modalità batch
etl run. - runbooks/graph-server.md —
etl serve: grafi via HTTP + worker. - runbooks/etl-worker.md — poller
etl workersuprocesses(modalità daemon). - ../CLAUDE.md — regole operative vincolanti per l'agente e il team.
10. Strumenti dell'agente Claude Code
- Slash command:
/new-pipeline,/new-connector,/new-transform,/etl-review,/doc-sync. - Skill
etl-graph— come costruire, cablare e configurare un grafo: nodi e porte, gestione errori, registry, metadati per la UI, pattern dato (idempotenza, incrementale, dead-letter, backpressure). - Subagent read-only in
.claude/agents/:graph-auditor(struttura del grafo e cablaggio),etl-reviewer(correttezza dato),schema-contract-auditor(contratti Zod),doc-syncer,project-pulse. Scrivono file solograph-builder(scaffold di una pipeline già progettata) etest-author(solo test).

