diff --git a/package-lock.json b/package-lock.json index 6e69f35..ada9988 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@nestjs/schedule": "^6.1.3", "@nestjs/terminus": "^11.1.1", "@nestjs/throttler": "^6.5.0", + "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@promster/express": "^15.5.1", "@types/archiver": "^7.0.0", @@ -26,6 +27,7 @@ "class-validator": "^0.15.1", "date-fns": "^4.3.0", "nodemailer": "^8.0.8", + "pg": "^8.21.0", "prisma": "^7.8.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2" @@ -2687,6 +2689,18 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@prisma/adapter-pg": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz", + "integrity": "sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.8.0", + "@types/pg": "^8.16.0", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, "node_modules/@prisma/client": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz", @@ -2760,6 +2774,15 @@ "zeptomatch": "2.1.0" } }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz", + "integrity": "sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, "node_modules/@prisma/engines": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz", @@ -3366,6 +3389,17 @@ "@types/node": "*" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -9538,6 +9572,104 @@ "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", "license": "MIT" }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9677,6 +9809,45 @@ "url": "https://github.com/sponsors/porsager" } }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -10493,6 +10664,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -12018,6 +12198,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 8823c5c..b0e1c1f 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@nestjs/schedule": "^6.1.3", "@nestjs/terminus": "^11.1.1", "@nestjs/throttler": "^6.5.0", + "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@promster/express": "^15.5.1", "@types/archiver": "^7.0.0", @@ -66,6 +67,7 @@ "class-validator": "^0.15.1", "date-fns": "^4.3.0", "nodemailer": "^8.0.8", + "pg": "^8.21.0", "prisma": "^7.8.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2" diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts index dc2e716..b3a9879 100644 --- a/src/prisma/prisma.service.ts +++ b/src/prisma/prisma.service.ts @@ -4,7 +4,14 @@ import { OnModuleDestroy, OnModuleInit, } from '@nestjs/common'; +import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient } from '@prisma/client'; +import { Pool, type PoolConfig } from 'pg'; + +type PrismaClientOptions = ConstructorParameters[0]; + +const DATABASE_URL_FALLBACK = + 'postgresql://postgres:postgres@127.0.0.1:5432/lyttle_nginx?schema=public'; function normalizePossiblyQuotedEnvValue( value: string | undefined, @@ -29,47 +36,135 @@ function normalizePossiblyQuotedEnvValue( return trimmed; } +function parsePositiveInteger( + value: string | null | undefined, +): number | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} + +function resolvePgSslOption( + sslMode: string | null, +): PoolConfig['ssl'] | undefined { + if (!sslMode) { + return undefined; + } + + switch (sslMode.toLowerCase()) { + case 'disable': + case 'allow': + case 'prefer': + return false; + case 'no-verify': + case 'require': + return { rejectUnauthorized: false }; + case 'verify-ca': + case 'verify-full': + return { rejectUnauthorized: true }; + default: + return undefined; + } +} + +function createPrismaRuntimeConfiguration(): { + clientOptions: PrismaClientOptions; + pool?: Pool; +} { + const baseClientOptions: PrismaClientOptions = { + log: [ + { level: 'warn', emit: 'event' }, + { level: 'error', emit: 'event' }, + ], + }; + + const databaseUrl = + normalizePossiblyQuotedEnvValue(process.env['DATABASE_URL']) || + DATABASE_URL_FALLBACK; + + const prismaUrl = new URL(databaseUrl); + const configuredPoolSize = + parsePositiveInteger(prismaUrl.searchParams.get('connection_limit')) ?? + parsePositiveInteger(prismaUrl.searchParams.get('pool_size')) ?? + 1; + + if (!prismaUrl.searchParams.has('connection_limit')) { + prismaUrl.searchParams.set('connection_limit', String(configuredPoolSize)); + } + if (!prismaUrl.searchParams.has('pool_timeout')) { + prismaUrl.searchParams.set('pool_timeout', '10'); + } + if (!prismaUrl.searchParams.has('connect_timeout')) { + prismaUrl.searchParams.set('connect_timeout', '10'); + } + + process.env['DATABASE_URL'] = prismaUrl.toString(); + + const adapterUrl = new URL(prismaUrl.toString()); + const schema = adapterUrl.searchParams.get('schema') || 'public'; + const connectionTimeoutSeconds = parsePositiveInteger( + adapterUrl.searchParams.get('connect_timeout'), + ); + const ssl = resolvePgSslOption(adapterUrl.searchParams.get('sslmode')); + + for (const searchParam of [ + 'connection_limit', + 'pool_size', + 'pool_timeout', + 'schema', + 'sslmode', + ]) { + adapterUrl.searchParams.delete(searchParam); + } + + const poolConfig: PoolConfig = { + connectionString: adapterUrl.toString(), + max: configuredPoolSize, + }; + + if (connectionTimeoutSeconds) { + poolConfig.connectionTimeoutMillis = connectionTimeoutSeconds * 1000; + } + + if (ssl !== undefined) { + poolConfig.ssl = ssl; + } + + const pool = new Pool(poolConfig); + const adapter = new PrismaPg(pool, { + schema, + disposeExternalPool: false, + }); + + return { + pool, + clientOptions: { + ...baseClientOptions, + adapter, + }, + }; +} + @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(PrismaService.name); + private readonly pool?: Pool; constructor() { - // Configure connection pool to prevent connection leaks - // Parse DATABASE_URL and add connection pool parameters if not present - const databaseUrl = normalizePossiblyQuotedEnvValue(process.env.DATABASE_URL); - if (databaseUrl) { - const url = new URL(databaseUrl); - const configuredPoolSize = Number.parseInt( - url.searchParams.get('pool_size') || '', - 10, - ); - const resolvedConnectionLimit = - Number.isFinite(configuredPoolSize) && configuredPoolSize > 0 - ? String(configuredPoolSize) - : '1'; - - // Set connection pool limits if not already configured - if (!url.searchParams.has('connection_limit')) { - url.searchParams.set('connection_limit', resolvedConnectionLimit); - } - if (!url.searchParams.has('pool_timeout')) { - url.searchParams.set('pool_timeout', '10'); // 10 second timeout - } - if (!url.searchParams.has('connect_timeout')) { - url.searchParams.set('connect_timeout', '10'); // 10 second connect timeout - } - - process.env.DATABASE_URL = url.toString(); - } + const { clientOptions, pool } = createPrismaRuntimeConfiguration(); + + super(clientOptions); - super({ - log: [ - { level: 'warn', emit: 'event' }, - { level: 'error', emit: 'event' }, - ], + this.pool = pool; + + this.pool?.on('error', (error) => { + this.logger.error(`[Prisma][Pool] ${error.message}`, error.stack); }); // Log connection pool warnings @@ -93,6 +188,9 @@ export class PrismaService async onModuleDestroy() { this.logger.log('[Prisma] Disconnecting from database...'); await this.$disconnect(); + if (this.pool) { + await this.pool.end(); + } this.logger.log('[Prisma] Database connection closed'); } }