node-listen
node-listen
In sintesi
Bridge tra il meccanismo nativo LISTEN/NOTIFY di PostgreSQL e il bus messaggi NATS, con un control plane HTTP per la gestione runtime dei mapping canale → topic. Il servizio (a) apre una connessione pg.Client dedicata, si sottoscrive ai canali Postgres attivi e ripubblica ogni NOTIFY come messaggio NATS, (b) gestisce il mapping canale postgres → topic NATS tramite la tabella nodelisten.listenchannelmapping e una API REST admin con hot-reload immediato (LISTEN/UNLISTEN sulla connessione attiva senza riavvio), (c) costruisce il subject finale come {NATS_NAMESPACE}.{toppicnats}, dove NATS_NAMESPACE resta sempre implicito e non è override-abile via API. L'ingress pubblico /listen-admin/* è autenticato da nginx con propagazione di X-User-Id ai fini dell'audit log.
Funzionalità principali
- Bridge Postgres LISTEN/NOTIFY → NATS publish: ogni
NOTIFYricevuto sui canali abilitati viene normalizzato in unNatsEnvelopee pubblicato sul subject NATS corrispondente - Tabella
nodelisten.listenchannelmappingcome source of truth dei mapping canale Postgres → topic NATS, con flagabilitato,descrizionee metadatadatacreazione/datamodifica - 6 endpoint REST CRUD + reload (
GET /channels,GET /channels/{id},POST /channels,PATCH /channels/{id},DELETE /channels/{id},POST /channels/reload) per gestione runtime, con validazione Zod e OpenAPI completo - Hot-reload runtime: la creazione di un mapping abilitato esegue
LISTEN <ch>immediato sulla connessione Postgres attiva; la disabilitazione o l'eliminazione esegueUNLISTEN <ch>immediato; il cambio ditoppicnatsinvalida la cache in-memory senza riconnessione {NATS_NAMESPACE}implicito non override-abile via API: il namespace applicativo è gestito esclusivamente dalla configurazione del deploy, lo schema Zod degli endpoint rifiuta payload che tentino di forzarlo- Seed automatico da
POSTGRES_LISTEN_CHANNELSal primo bootstrap: se la tabella è vuota, ilChannelMappingSeederpopola i mapping a partire dall'env (backwards compatibility con la configurazione legacy) - Auth nginx con audit log: tutte le mutation (
POST/PATCH/DELETE/reload) registranoactorUserIdricavato dall'headerX-User-Idpropagato da nginx tramiteauth_requestversonode-user-auth - Riconnessione Postgres con ri-LISTEN automatico: alla perdita della sessione l'adapter pianifica un retry con backoff; al ripristino tutti i canali
abilitato=truevengono ri-sottoscritti leggendo lo stato corrente della tabella
Architettura
Stack: Fastify v5 + @pzeta/fastify-utils v1.0.12 (plugin auth, healthcheck, openapi, security) · Inversify per la composition root · driver pg 8.x come client LISTEN/NOTIFY e come repository della tabella di mapping · client nats 2.x come publisher · Zod per la validazione di request/response e della configurazione · @pzeta/log per logging strutturato con correlation IDs.
Layout DDD (src/):
| Layer | Contenuto |
|---|---|
domain/ | Entità ChannelMapping e NatsEnvelope, value object ChannelName/NatsSubject/NotificationPayload/ConnectionStatus, errori di dominio (ChannelAlreadyMappedError, ChannelMappingNotFoundError, ecc.), monade Result<T> |
application/ | 6 use case CRUD in useCases/channelMapping/ (CreateChannelMapping, ListChannelMappings, GetChannelMapping, UpdateChannelMapping, DeleteChannelMapping, ReloadChannelMappings); HandleNotificationUseCase per il flusso NOTIFY → publish; ChannelRegistry (cache in-memory dei mapping attivi con pattern Observer per propagare le invalidazioni); ChannelMappingSeeder per il bootstrap idempotente da env; DTO ChannelMappingDto; porte IChannelMappingRepository, INatsPublisher, IPostgresListener, ILogger |
infrastructure/ | PostgresChannelMappingRepository, PostgresListenerAdapter (gestisce client, eventi notification/error/end, schedulazione reconnect, comandi LISTEN/UNLISTEN runtime), NatsPublisher, migration 001_initial_listenchannelmapping con pg_advisory_lock per evitare race su deploy paralleli, EnvironmentConfig (Zod), DIContainer, FastifyServer, plugin OpenAPI condizionato a NODE_ENV !== production salvo opt-in, AppBootstrapper, GracefulShutdown |
presentation/ | ChannelRoutes (CRUD + reload, schema Zod per request/response, OpenAPI auto-generato), HealthRoutes (liveness/readiness) |
Pattern adottati: Hexagonal/Ports & Adapters (porte applicative isolano dominio da pg e nats), Observer (il ChannelRegistry notifica il PostgresListenerAdapter su create/update/delete così da emettere LISTEN/UNLISTEN puntuali), Repository per la persistenza dei mapping, Factory per la composition root, Bootstrapper con sequenza deterministica (config → logger → DI → migration → seeder → server → orchestrator → shutdown hooks).
Flusso dati — propagazione eventi:
PostgreSQL NOTIFY <ch> → pg.Client.on('notification')
→ PostgresListenerAdapter → HandleNotificationUseCase
→ ChannelRegistry.resolveTopic(ch) // canale Postgres → toppicnats
→ NatsEnvelope (channel, payload, processId, timestamp, source)
→ NatsPublisher.publish({NATS_NAMESPACE}.{toppicnats}, envelope)
→ NATS
Flusso dati — control plane:
HTTP /listen-admin/channels → nginx auth_request → node-user-auth
→ Fastify (X-User-Id propagato)
→ ChannelRoutes (Zod validation, OpenAPI)
→ Use case CRUD (audit log con actorUserId)
→ PostgresChannelMappingRepository (tabella nodelisten.listenchannelmapping)
→ ChannelRegistry.invalidate(...) // Observer
→ PostgresListenerAdapter.applyDiff() // LISTEN/UNLISTEN runtime
Casi d'uso
- Onboarding live di un nuovo canale: il team DB aggiunge un trigger che emette
NOTIFY ordine_creato_v2, '<json>'; il team backend chiamaPOST /listen-admin/channelscon{ "channelpostgres": "ordine_creato_v2", "toppicnats": "orders.created", "descrizione": "Pubblicazione ordini v2" }; entro pochi secondinode-listenesegueLISTEN ordine_creato_v2sulla connessione attiva e inizia a pubblicare su{NATS_NAMESPACE}.orders.created. Zero downtime, zero rolling restart - Disabilitazione temporanea di un canale rumoroso:
PATCH /listen-admin/channels/{id}con{ "abilitato": false }esegueUNLISTENimmediato senza eliminare la riga, preservando descrizione e mapping per la riabilitazione futura - Rebrand del subject NATS senza modifiche SQL:
PATCH /listen-admin/channels/{id}con{ "toppicnats": "orders.v3.created" }invalida la cache del registry e ridiriga iNOTIFYfuturi sul nuovo subject; i nomi dei canali Postgres (e quindi i trigger SQL) restano invariati - Audit operativo da UI admin:
GET /listen-admin/channels?abilitato=truerestituisce la lista paginata dei mapping attivi condescrizione,datacreazione,datamodificaper dashboard di osservabilità o reportistica - Boot iniziale con configurazione legacy: alla prima accensione del cluster, se
nodelisten.listenchannelmappingè vuota, ilChannelMappingSeederleggePOSTGRES_LISTEN_CHANNELSdall'env e popola la tabella creando un mapping 1:1 (channelpostgres == toppicnats); le accensioni successive non riapplicano il seed, garantendo che le modifiche operative via API non vengano sovrascritte
Identità & esposizione
| Campo | Valore |
|---|---|
| Categoria | eventing |
| Versione cluster | 1.0.14 |
| Image | gitea.pzetatouch.it/pzeta_touch/node-listen:1.0.4 |
| URL pubblico | https://ditta.pzeta.it/listen-admin |
| Path regex ingress | `/listen-admin(/ |
| Rewrite a backend | /$2 |
| DNS interno | node-listen-ditta.ditta.svc.cluster.local:3010 |
| Auth nginx | auth_request → node-user-auth |
| Repository | node-listen |
| Endpoint REST | 16 (vedi sezione "API reference") |
Endpoint operazionali
Endpoint convenzionali esposti da tutti i microservizi PZeta basati su @pzeta/fastify-utils:
| Path pubblico | Scopo |
|---|---|
https://ditta.pzeta.it/listen-admin/health | liveness probe |
https://ditta.pzeta.it/listen-admin/ready | readiness probe |
https://ditta.pzeta.it/listen-admin/metrics | metriche Prometheus |
https://ditta.pzeta.it/listen-admin/api-docs.json | spec OpenAPI runtime (richiede OPENAPI_EXPOSE_IN_PRODUCTION=true) |
https://ditta.pzeta.it/listen-admin/api-docs | Swagger UI (solo in NODE_ENV !== production) |
Configurazione
Variabili d'ambiente rilevanti per chi integra il servizio (lista completa in .env.example del repo). Tutte le variabili sono validate all'avvio tramite schema Zod: configurazione invalida = fail-fast.
| Variabile | Ruolo |
|---|---|
HTTP_PORT | Porta del server Fastify (default 3010, esposto in cluster come node-listen-ditta.ditta.svc.cluster.local:3010 e a internet via /listen-admin) |
DATABASE_HOST / DATABASE_PORT / DATABASE_USER / DATABASE_PASSWORD / DATABASE_NAME | Connessione PostgreSQL; il servizio usa una sessione dedicata per LISTEN (non un pool) e un pool separato per la tabella nodelisten.listenchannelmapping |
POSTGRES_SSL_ENABLED / POSTGRES_SSL_REJECT_UNAUTHORIZED / POSTGRES_SSL_CA | Configurazione TLS verso PostgreSQL |
POSTGRES_LISTEN_CHANNELS | Solo seed iniziale. Elenco canali separati da virgola usato dal ChannelMappingSeeder per popolare nodelisten.listenchannelmapping se la tabella è vuota. Dopo il primo bootstrap il source of truth è la tabella DB |
NATS_URL | URL del cluster NATS di destinazione |
NATS_NAMESPACE | Prefisso applicativo dei subject pubblicati. Implicito e non override-abile via API: gli endpoint REST rifiutano payload che tentino di forzarlo |
NATS_USER / NATS_PASS / NATS_TOKEN | Credenziali opzionali per l'autenticazione al cluster NATS (mutuamente esclusive: user/pass oppure token) |
PG_RECONNECT_INTERVAL_MS / PG_RECONNECT_MAX_INTERVAL_MS | Backoff esponenziale per la riconnessione PostgreSQL del client LISTEN |
NATS_RECONNECT_INTERVAL_MS | Tempo fra tentativi di riconnessione NATS (passato come reconnectTimeWait) |
SHUTDOWN_TIMEOUT_MS | Tempo massimo concesso al graceful shutdown prima di forzare l'uscita |
OPENAPI_EXPOSE_IN_PRODUCTION | Se true, espone la spec OpenAPI anche in production (default: solo in NODE_ENV !== production) |
LOG_LEVEL / NODE_ENV | Logging operazionale |
L'utente PostgreSQL configurato necessita del privilegio CONNECT sul database, del permesso di eseguire LISTEN sui canali e dei diritti SELECT/INSERT/UPDATE/DELETE sullo schema nodelisten (creato dalla migration 001_initial_listenchannelmapping con pg_advisory_lock per evitare race su deploy paralleli). Nessun accesso ad altri schemi applicativi è richiesto.
Note eventing NATS
Sezione centrale del servizio: la trasformazione NOTIFY → subject è la responsabilità funzionale di node-listen, ortogonale al control plane HTTP.
Modello di comunicazione: node-listen è producer puro di subject NATS. Non si sottoscrive ad alcun subject e non implementa request/reply: il flusso eventi è strettamente unidirezionale (PostgreSQL → NATS). L'unico canale di "ingresso" del servizio è l'API REST /listen-admin/channels per la gestione dei mapping.
Naming dei subject: per ogni mapping (channelpostgres, toppicnats) con abilitato = true nella tabella nodelisten.listenchannelmapping, le notifiche ricevute sul canale Postgres vengono pubblicate su:
{NATS_NAMESPACE}.{toppicnats}
dove NATS_NAMESPACE è impostato dall'env del deploy ed è implicito (non viene mai esposto né accettato dagli endpoint REST). A differenza della versione legacy del servizio — in cui valeva la convenzione 1:1 {NATS_NAMESPACE}.{channelName} — il toppicnats è ora indipendente dal nome del canale Postgres: lo stesso canale può essere rinominato a livello NATS senza modificare i trigger SQL, e topic NATS gerarchici (es. orders.v2.created) sono utilizzabili anche se il nome del canale Postgres deve restare un identificatore semplice. Il subject finale è validato dal value object NatsSubject (no spazi, no segmenti vuoti, no leading/trailing dot).
Subject dinamici e scanner statico: poiché i subject derivano dalla tabella DB e non da costanti nel codice, lo scanner statico della documentazione non è in grado di rilevarli a build-time. È atteso che nats.json per questo servizio sia vuoto: la lista effettiva dei subject pubblicati nel deploy corrente è ottenibile via GET /listen-admin/channels?abilitato=true (campo toppicnats).
Schema del messaggio (NatsEnvelope): il payload originale del NOTIFY deve essere JSON valido; viene parsato in NotificationPayload e racchiuso in un envelope con metadata. La forma serializzata pubblicata su NATS è:
{
"channel": "ordine_creato_v2",
"payload": { "idordine": 1234, "stato": "nuovo", "...": "..." },
"processId": 42891,
"timestamp": "2026-05-14T18:54:06.706Z",
"source": "node-listen"
}
Il campo channel riporta il nome del canale Postgres di origine (utile ai consumer per tracciare la sorgente anche dopo un rebrand del topic NATS); processId è il PID PostgreSQL del backend che ha emesso il NOTIFY ed è utile per il tracing; timestamp viene generato dal servizio al momento della ricezione (non al momento della scrittura DB).
Resilienza e semantica di delivery: il publish è best-effort fire-and-forget. Il client NATS è configurato con maxReconnectAttempts: -1, quindi tenta indefinitamente la riconnessione, ma le notifiche ricevute mentre il bus è irraggiungibile vengono perse (l'errore viene loggato). Non è attivo JetStream e non è implementato un buffer locale: la garanzia di delivery dipende dall'idempotenza dei consumer e dalla disponibilità del bus. Per workflow critici, la sorgente del trigger PostgreSQL dovrebbe persistere lo stato in una tabella outbox prima del NOTIFY così da poter ricostruire l'evento.
Riconnessione PostgreSQL e stato della tabella: alla perdita della sessione LISTEN (error o end), l'adapter pianifica un retry con backoff esponenziale. Al ripristino della connessione, l'adapter ri-legge nodelisten.listenchannelmapping filtrando abilitato = true ed emette LISTEN per ciascun canale corrente: eventuali modifiche operative effettuate via API durante il downtime (es. nuovi mapping, disabilitazioni) vengono quindi applicate automaticamente al primo reconnect. I NOTIFY emessi mentre la sessione era down sono persi (semantica nativa di LISTEN/NOTIFY PostgreSQL).
Dipendenze e dipendenti
Dipende da (servizi che questo servizio chiama):
Consumato da (chi chiama questo servizio):
Infrastruttura (PostgreSQL, NATS, Redis, MinIO) non è elencata qui — vedi sezione Architettura del singolo servizio.