From 53213601202103174c72919bb8b064194488cc3a Mon Sep 17 00:00:00 2001 From: softmind <198604584+sheyman546@users.noreply.github.com> Date: Fri, 19 Jun 2026 07:03:09 +0000 Subject: [PATCH] infra: implement shared connection pool management (#139) - Add api/src/database/database.module.ts as a @Global NestJS module that provides a single pg.Pool instance under the PG_POOL token - Remove individual new Pool() calls from StreamsDbRepository, TagsDbRepository, UsersRepository, AuditService, PasswordResetService, and DatabaseHealthIndicator - Inject PG_POOL via @Inject(PG_POOL) in all six consumers - Import DatabaseModule in AppModule; @Global() propagates the token to all feature modules automatically - Add database.module.spec.ts verifying singleton Pool provision --- api/src/app.module.ts | 2 ++ api/src/audit/audit.service.ts | 5 ++-- api/src/auth/password-reset.service.ts | 5 ++-- api/src/auth/users.repository.ts | 5 ++-- api/src/database/database.module.spec.ts | 29 +++++++++++++++++++ api/src/database/database.module.ts | 17 +++++++++++ api/src/health/database.health-indicator.ts | 13 ++++----- .../repository/streams-db.repository.ts | 12 ++------ api/src/tags/repository/tags-db.repository.ts | 12 ++------ 9 files changed, 68 insertions(+), 32 deletions(-) create mode 100644 api/src/database/database.module.spec.ts create mode 100644 api/src/database/database.module.ts diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 819cddb..c551601 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -4,6 +4,7 @@ import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler" import { AdminModule } from "./admin/admin.module" import { AuditModule } from "./audit/audit.module" import { AuthModule } from "./auth/auth.module" +import { DatabaseModule } from "./database/database.module" import { GatewaysModule } from "./gateways/gateways.module" import { HealthModule } from "./health/health.module" import { MetricsModule } from "./metrics/metrics.module" @@ -19,6 +20,7 @@ import { TagsModule } from "./tags/tags.module" limit: parseInt(process.env.THROTTLE_LIMIT ?? "100"), }, ]), + DatabaseModule, AdminModule, AuditModule, AuthModule, diff --git a/api/src/audit/audit.service.ts b/api/src/audit/audit.service.ts index 1c48afd..acdf49c 100644 --- a/api/src/audit/audit.service.ts +++ b/api/src/audit/audit.service.ts @@ -1,9 +1,10 @@ -import { Injectable } from "@nestjs/common" +import { Inject, Injectable } from "@nestjs/common" import { Pool } from "pg" +import { PG_POOL } from "../database/database.module" @Injectable() export class AuditService { - private pool = new Pool({ connectionString: process.env.DATABASE_URL }) + constructor(@Inject(PG_POOL) private readonly pool: Pool) {} async log(userId: number | null, action: string, ip: string) { await this.pool.query( diff --git a/api/src/auth/password-reset.service.ts b/api/src/auth/password-reset.service.ts index 49d0dcc..c13428d 100644 --- a/api/src/auth/password-reset.service.ts +++ b/api/src/auth/password-reset.service.ts @@ -4,6 +4,7 @@ import { Cache } from "cache-manager" import { Pool } from "pg" import * as bcrypt from "bcrypt" import * as crypto from "crypto" +import { PG_POOL } from "../database/database.module" import { UsersRepository } from "./users.repository" const PASSWORD_RESET_TOKEN_BYTES = 32 @@ -14,14 +15,12 @@ const BCRYPT_ROUNDS = 12 @Injectable() export class PasswordResetService { - private readonly pool = new Pool({ - connectionString: process.env.DATABASE_URL, - }) private readonly logger = new Logger(PasswordResetService.name) constructor( private readonly usersRepository: UsersRepository, @Inject(CACHE_MANAGER) private readonly cache: Cache, + @Inject(PG_POOL) private readonly pool: Pool, ) {} async sendResetToken(email: string): Promise { diff --git a/api/src/auth/users.repository.ts b/api/src/auth/users.repository.ts index 62b79f5..c5da8aa 100644 --- a/api/src/auth/users.repository.ts +++ b/api/src/auth/users.repository.ts @@ -1,5 +1,6 @@ -import { Injectable } from "@nestjs/common" +import { Inject, Injectable } from "@nestjs/common" import { Pool } from "pg" +import { PG_POOL } from "../database/database.module" export interface User { id: number @@ -18,7 +19,7 @@ export interface User { */ @Injectable() export class UsersRepository { - private pool = new Pool({ connectionString: process.env.DATABASE_URL }) + constructor(@Inject(PG_POOL) private readonly pool: Pool) {} async findByEmail(email: string): Promise { const { rows } = await this.pool.query( diff --git a/api/src/database/database.module.spec.ts b/api/src/database/database.module.spec.ts new file mode 100644 index 0000000..5e223b3 --- /dev/null +++ b/api/src/database/database.module.spec.ts @@ -0,0 +1,29 @@ +import { Test, TestingModule } from "@nestjs/testing" +import { DatabaseModule, PG_POOL } from "./database.module" +import { Pool } from "pg" + +describe("DatabaseModule", () => { + let module: TestingModule + + beforeEach(async () => { + module = await Test.createTestingModule({ + imports: [DatabaseModule], + }).compile() + }) + + afterEach(async () => { + await module.close() + }) + + it("provides PG_POOL token as a Pool instance", () => { + const pool = module.get(PG_POOL) + expect(pool).toBeDefined() + expect(pool).toBeInstanceOf(Pool) + }) + + it("provides the same Pool instance on repeated resolution (singleton)", () => { + const pool1 = module.get(PG_POOL) + const pool2 = module.get(PG_POOL) + expect(pool1).toBe(pool2) + }) +}) diff --git a/api/src/database/database.module.ts b/api/src/database/database.module.ts new file mode 100644 index 0000000..34810b9 --- /dev/null +++ b/api/src/database/database.module.ts @@ -0,0 +1,17 @@ +import { Global, Module } from "@nestjs/common" +import { Pool } from "pg" +import { env } from "../config/env" + +export const PG_POOL = "PG_POOL" + +@Global() +@Module({ + providers: [ + { + provide: PG_POOL, + useFactory: (): Pool => new Pool({ connectionString: env.DATABASE_URL }), + }, + ], + exports: [PG_POOL], +}) +export class DatabaseModule {} diff --git a/api/src/health/database.health-indicator.ts b/api/src/health/database.health-indicator.ts index a56c08d..c906c42 100644 --- a/api/src/health/database.health-indicator.ts +++ b/api/src/health/database.health-indicator.ts @@ -1,10 +1,13 @@ import { HealthCheckError, HealthIndicator, HealthIndicatorResult } from "@nestjs/terminus" -import { Injectable, OnModuleDestroy } from "@nestjs/common" +import { Inject, Injectable } from "@nestjs/common" import { Pool } from "pg" +import { PG_POOL } from "../database/database.module" @Injectable() -export class DatabaseHealthIndicator extends HealthIndicator implements OnModuleDestroy { - private readonly pool = new Pool({ connectionString: process.env.DATABASE_URL }) +export class DatabaseHealthIndicator extends HealthIndicator { + constructor(@Inject(PG_POOL) private readonly pool: Pool) { + super() + } async isHealthy(key: string): Promise { try { @@ -14,8 +17,4 @@ export class DatabaseHealthIndicator extends HealthIndicator implements OnModule throw new HealthCheckError("database", error) } } - - async onModuleDestroy(): Promise { - await this.pool.end() - } } diff --git a/api/src/streams/repository/streams-db.repository.ts b/api/src/streams/repository/streams-db.repository.ts index 76ec9c8..d429b2a 100644 --- a/api/src/streams/repository/streams-db.repository.ts +++ b/api/src/streams/repository/streams-db.repository.ts @@ -1,11 +1,12 @@ import { + Inject, Injectable, InternalServerErrorException, Logger, ServiceUnavailableException, } from "@nestjs/common" import { Pool } from "pg" -import { env } from "../../config/env" +import { PG_POOL } from "../../database/database.module" import { Stream } from "../stream.entity" /** @@ -19,16 +20,9 @@ import { Stream } from "../stream.entity" */ @Injectable() export class StreamsDbRepository { - private readonly pool: Pool private readonly logger = new Logger(StreamsDbRepository.name) - constructor() { - this.pool = new Pool({ connectionString: env.DATABASE_URL }) - - this.pool.on("error", (err) => { - this.logger.error("Unexpected PostgreSQL pool error", err.stack) - }) - } + constructor(@Inject(PG_POOL) private readonly pool: Pool) {} /** Map a raw DB row to the Stream entity shape. */ private rowToStream(row: Record): Stream { diff --git a/api/src/tags/repository/tags-db.repository.ts b/api/src/tags/repository/tags-db.repository.ts index a4b49e5..411f724 100644 --- a/api/src/tags/repository/tags-db.repository.ts +++ b/api/src/tags/repository/tags-db.repository.ts @@ -1,11 +1,12 @@ import { + Inject, Injectable, InternalServerErrorException, Logger, ServiceUnavailableException, } from "@nestjs/common" import { Pool } from "pg" -import { env } from "../../config/env" +import { PG_POOL } from "../../database/database.module" import { StreamTag, Tag } from "../tag.entity" /** @@ -19,16 +20,9 @@ import { StreamTag, Tag } from "../tag.entity" */ @Injectable() export class TagsDbRepository { - private readonly pool: Pool private readonly logger = new Logger(TagsDbRepository.name) - constructor() { - this.pool = new Pool({ connectionString: env.DATABASE_URL }) - - this.pool.on("error", (err) => { - this.logger.error("Unexpected PostgreSQL pool error", err.stack) - }) - } + constructor(@Inject(PG_POOL) private readonly pool: Pool) {} /** Map a raw DB row to the Tag entity shape. */ private rowToTag(row: Record): Tag {