diff --git a/.env.example b/.env.example index f22b4da..ff482e4 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,12 @@ DB_NAME=interchangabletrade DB_SYNCHRONIZE=true DB_LOGGING=false +# Connection pool (node-postgres `pg.Pool`). See docs/database.md. +DB_POOL_MAX=20 +DB_POOL_MIN=5 +DB_POOL_IDLE_TIMEOUT_MS=30000 +DB_POOL_CONNECTION_TIMEOUT_MS=5000 + # Redis REDIS_HOST=localhost REDIS_PORT=6379 diff --git a/README.md b/README.md index ddfc7ec..9caffe3 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,9 @@ Write invocations and contract deployments require a funded source account via - [Roadmap](ROADMAP.md) — direction and upcoming milestones - [Changelog](CHANGELOG.md) — released changes +- [Data Persistence & Database](docs/database.md) — pooling, transactions, + migrations, constraints +- [Audit Logging](docs/audit-logging.md) — compliance and audit trail - API reference — Swagger UI at `/api/docs` when the app is running ## Related Repositories diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..1f3e3fa --- /dev/null +++ b/docs/database.md @@ -0,0 +1,207 @@ +# Data Persistence & Database Module + +Reference for how this service persists data: connection pooling, transaction +support, migrations, indexing/constraints, and the audit trail. Tracks +[#12](https://github.com/Chulilee/InterChangableTrade-Core/issues/12). + +## Stack + +PostgreSQL, accessed through TypeORM (`@nestjs/typeorm`), configured in +`src/config/database.config.ts` from validated env vars +(`src/config/configuration.ts`, `src/config/env.validation.ts`). + +## Schema & entities + +Every persisted table is a TypeORM entity under `src/modules/*/entities/` (45 +entities across the modules listed in the top-level README's "Project +Structure" section) plus the shared base columns in +`libs/common/src/entities/base.entity.ts` (`id` UUID PK, +`createdAt`/`updatedAt` timestamptz). There is no separate ER diagram to keep +in sync — the entities *are* the schema, and TypeORM's `autoLoadEntities` +picks up every module's entities automatically. + +Most entities already declare `@Index` on their filter/sort columns; see any +file under `src/modules/*/entities/*.entity.ts` for the pattern, and +`src/modules/audit/entities/audit-log.entity.ts` for a heavily-indexed +example (composite indexes on `(userId, createdAt)`, `(action, createdAt)`, +`(resourceType, createdAt)`, plus single-column indexes for point lookups). + +## Connection pooling + +`DatabaseConfig.createTypeOrmOptions()` forwards pool sizing to the +underlying `pg.Pool` via TypeORM's `extra` option: + +| Env var | Default | Meaning | +| -------------------------------- | ------- | ---------------------------------------------------- | +| `DB_POOL_MAX` | `20` | Max concurrent connections held open | +| `DB_POOL_MIN` | `5` | Connections kept warm even when idle | +| `DB_POOL_IDLE_TIMEOUT_MS` | `30000` | How long an idle connection stays open before closing | +| `DB_POOL_CONNECTION_TIMEOUT_MS` | `5000` | How long to wait for a connection before failing | + +Tune `DB_POOL_MAX` against Postgres' own `max_connections` and however many +app instances run concurrently — the sum of every instance's `DB_POOL_MAX` +must stay under Postgres' ceiling (with headroom for migrations, admin +tooling, and read replicas' replication connections). + +We have not run a load test in this environment to produce a before/after +latency number for pooled vs. unpooled connections (that requires a live +Postgres instance and a load-generation harness this sandbox doesn't have); +`test/` has no such benchmark today. Establishing a baseline and a +regression-tested performance budget is a good follow-up once the app is +deployed somewhere that can carry the load test. + +## Transaction support + +`@app/common` exports `@Transactional()` (a method decorator for controller +handlers) backed by `TransactionInterceptor` +(`libs/common/src/interceptors/transaction.interceptor.ts`): + +```ts +import { Transactional, TransactionManager } from '@app/common'; +import { EntityManager } from 'typeorm'; + +@Transactional() +@Post() +async create( + @TransactionManager() manager: EntityManager, + @Body() dto: CreateWidgetDto, +) { + const widget = await manager.getRepository(Widget).save(dto); + await manager.getRepository(WidgetHistory).save({ widgetId: widget.id }); + return widget; // both writes commit together, or both roll back +} +``` + +The interceptor opens a `QueryRunner` transaction before the handler runs, +attaches it to the request, commits on success, and rolls back on any thrown +error — giving multi-write handlers ACID guarantees instead of each +repository call committing independently. `@TransactionManager()` pulls the +transactional `EntityManager` back out for the handler (and anything it calls +that accepts a manager) to use; using it without `@Transactional()` throws +rather than silently falling back to a non-transactional connection. + +This existed in the codebase but was unexported, untested and unused; this +change fixes it to use TypeORM 0.3's `DataSource`/`InjectDataSource` (the +previous `Connection`/`InjectConnection` pair is the deprecated 0.2 API), +exports it from `@app/common`, and adds unit-test coverage +(`libs/common/src/interceptors/transaction.interceptor.spec.ts`, +`libs/common/src/decorators/transaction-manager.decorator.spec.ts`). + +For a single-repository write, TypeORM's own `repository.save()` / +`manager.transaction()` already wrap each call in a transaction — reach for +`@Transactional()` specifically when a handler needs to make **multiple** +related writes atomically. + +## Migrations + +`synchronize` (auto-DDL from entity metadata) is fine for local development — +`DB_SYNCHRONIZE=true` in `.env.example` — but it must never run against a +database that matters: it can silently drop columns/tables to reconcile the +schema, and it isn't reviewable the way a migration file is. Version-controlled +migrations now exist for that: + +```bash +npm run migration:generate -- src/database/migrations/DescriptiveName # diff entities vs. DB, write a migration +npm run migration:create -- src/database/migrations/DescriptiveName # blank migration file +npm run migration:run # apply pending migrations +npm run migration:revert # roll back the most recent migration +npm run migration:show # list applied / pending migrations +``` + +These use a standalone `DataSource` (`src/database/data-source.ts`) since the +TypeORM CLI runs outside of Nest's dependency injection and can't use +`DatabaseConfig`. `DatabaseConfig` itself sets `migrationsRun: false` — CI/CD +should run `npm run migration:run` as an explicit deploy step, so a broken +migration fails the deploy rather than crash-looping the app on boot. + +### ⚠️ No baseline migration yet + +This repository's schema has been managed by `synchronize` since its first +commit, so there is no migration that creates the 45 existing tables — only +the one new migration added by this PR +(`1787847457730-AddTransactionIntegrityConstraints.ts`), which alters an +already-existing `transactions` table. **Do not run `migration:run` against +a database that doesn't already have the full schema** (e.g. from +`synchronize`); it will fail on the first `ALTER TABLE`. + +Before any environment can rely on migrations instead of `synchronize`, +someone with a live Postgres instance needs to either: + +1. Run `npm run migration:generate` against a fully-synchronized database to + snapshot the current schema as a baseline migration, or +2. Use `typeorm migration:create` to hand-write one and verify it against a + real database. + +Both need a running Postgres to produce and verify — this environment has +Docker but no running daemon (`docker ps` failed: "error during connect ... +the system cannot find the file specified", i.e. Docker Desktop isn't +started), so it isn't done here. Recommended next step: whoever picks this up +next runs `docker compose up -d postgres`, `npm run start:dev` once +(so `synchronize` builds the full schema), then `npm run migration:generate` +against it, and commits the result as the first migration, before this new +migration. + +### Example migration + +`AddTransactionIntegrityConstraints1787847457730` demonstrates the pattern +end to end — a `CHECK` constraint (`transactions.amount > 0`, matching the +`@Check` now declared on the `Transaction` entity so `synchronize` and +migrations agree) and a composite index for the transaction-history query +(`userId` + `status`, ordered by `createdAt`). It has not been run against a +live database in this environment for the reason above; its SQL was reviewed +by hand rather than executed. Treat it as a reference implementation to +validate (`migration:run` then `migration:revert`) against a real database +before relying on it in a deployed environment. + +## Data validation & constraints + +Two layers, deliberately overlapping: + +- **Application layer:** `class-validator` DTOs on every controller input. +- **Database layer:** `NOT NULL`/`unique` column options and now `@Check` + constraints on the entities, enforced regardless of which code path writes + the row. The `transactions.amount > 0` check above is the first explicit + example; extending the same pattern (e.g. non-negative balances, valid + enum-backed status transitions) to other entities is straightforward + follow-up work once there's a baseline migration to build on. + +## Audit trail + +Already implemented — see [`docs/audit-logging.md`](audit-logging.md) and +`src/modules/audit/`. A global interceptor records every request +(actor, action, outcome, before/after state for data changes) to an +append-only `audit_logs` table with a 7-year minimum retention +(`AUDIT_RETENTION_YEARS`), plus GDPR export and compliance-report endpoints. +No changes needed for this issue. + +## Out of scope for this change + +Issue #12's acceptance criteria include several items that are infrastructure +and operations decisions, not application code, and that this PR does not +attempt — implementing them here would mean guessing choices (backup target, +sharding architecture) the codebase gives no basis for: + +- **Automated, verified backups.** Needs a chosen backup target (managed + Postgres provider snapshot? `pg_dump` to object storage on a cron?) and a + restore-verification job. No such target is configured anywhere in this + repo or its Docker/CI setup. +- **Disaster recovery (RPO < 5 min, RTO < 15 min).** An RPO under 5 minutes + effectively requires continuous WAL streaming/replication, which is an + infrastructure/hosting decision (e.g. managed Postgres with PITR, or a + standby replica) — not something expressed in application code. +- **Sharding strategy for 10x growth.** No target approach is indicated + anywhere in the codebase (single `DataSource`, no tenant/shard key on any + entity, no read-replica routing). Picking one (e.g. Citus, application-level + sharding by a tenant/user key, or simply read replicas + partitioning) is a + significant architectural decision that should be made deliberately, with + the team, against real growth data — not guessed at in a persistence-module + PR. +- **A measured >40% latency reduction from pooling.** Pooling is now + configured (above), but proving a percentage improvement needs a load-test + harness and a before/after run against a live database, which this sandbox + environment doesn't have. +- **Load tests validating performance targets.** Same constraint — no live + database available here to generate a load-test baseline against. + +These are called out explicitly rather than left implicit so the next person +picking up #12 knows exactly what decisions are still needed. diff --git a/libs/common/src/decorators/transaction-manager.decorator.spec.ts b/libs/common/src/decorators/transaction-manager.decorator.spec.ts new file mode 100644 index 0000000..16c29fa --- /dev/null +++ b/libs/common/src/decorators/transaction-manager.decorator.spec.ts @@ -0,0 +1,25 @@ +import { ExecutionContext } from '@nestjs/common'; +import { transactionManagerFactory } from './transaction-manager.decorator'; + +function buildContext(req: Record): ExecutionContext { + return { + switchToHttp: () => ({ getRequest: () => req }), + } as unknown as ExecutionContext; +} + +describe('TransactionManager decorator', () => { + it('returns the EntityManager attached by TransactionInterceptor', () => { + const manager = { save: jest.fn() }; + const ctx = buildContext({ queryRunner: { manager } }); + + expect(transactionManagerFactory(undefined, ctx)).toBe(manager); + }); + + it('throws when no transaction is active on the request', () => { + const ctx = buildContext({}); + + expect(() => transactionManagerFactory(undefined, ctx)).toThrow( + /without @Transactional\(\)/, + ); + }); +}); diff --git a/libs/common/src/decorators/transaction-manager.decorator.ts b/libs/common/src/decorators/transaction-manager.decorator.ts new file mode 100644 index 0000000..809576c --- /dev/null +++ b/libs/common/src/decorators/transaction-manager.decorator.ts @@ -0,0 +1,42 @@ +import { + createParamDecorator, + ExecutionContext, + InternalServerErrorException, +} from '@nestjs/common'; +import { EntityManager } from 'typeorm'; + +/** + * Extracts the transactional `EntityManager` attached to the request by + * `TransactionInterceptor`. Use it in a handler guarded by `@Transactional()` + * to run repository calls inside the request's transaction instead of the + * ambient (non-transactional) connection: + * + * ```ts + * @Transactional() + * @Post() + * create(@TransactionManager() manager: EntityManager, @Body() dto: CreateDto) { + * return manager.getRepository(Widget).save(dto); + * } + * ``` + * + * Throws if used outside a `@Transactional()`-wrapped handler, since that + * indicates a missing decorator rather than a state a caller should silently + * work around. + */ +export const transactionManagerFactory = ( + _data: unknown, + ctx: ExecutionContext, +): EntityManager => { + const request = ctx.switchToHttp().getRequest(); + const manager = request.queryRunner?.manager; + if (!manager) { + throw new InternalServerErrorException( + '@TransactionManager() used without @Transactional() — no active transaction on this request.', + ); + } + return manager; +}; + +export const TransactionManager = createParamDecorator( + transactionManagerFactory, +); diff --git a/libs/common/src/index.ts b/libs/common/src/index.ts index e1c975b..f628644 100644 --- a/libs/common/src/index.ts +++ b/libs/common/src/index.ts @@ -6,5 +6,8 @@ export * from './entities/base.entity'; export * from './dto/pagination-query.dto'; export * from './dto/paginated-result.dto'; export * from './interceptors/transform.interceptor'; +export * from './interceptors/transaction.interceptor'; export * from './filters/http-exception.filter'; export * from './decorators/api-paginated-response.decorator'; +export * from './decorators/transactional.decorator'; +export * from './decorators/transaction-manager.decorator'; diff --git a/libs/common/src/interceptors/transaction.interceptor.spec.ts b/libs/common/src/interceptors/transaction.interceptor.spec.ts new file mode 100644 index 0000000..2404459 --- /dev/null +++ b/libs/common/src/interceptors/transaction.interceptor.spec.ts @@ -0,0 +1,111 @@ +import { CallHandler, ExecutionContext } from '@nestjs/common'; +import { lastValueFrom, of, throwError } from 'rxjs'; +import { DataSource } from 'typeorm'; +import { TransactionInterceptor } from './transaction.interceptor'; + +function buildContext(req: Record): ExecutionContext { + return { + getType: () => 'http', + switchToHttp: () => ({ + getRequest: () => req, + getResponse: () => ({}), + }), + } as unknown as ExecutionContext; +} + +/** `intercept()` is async (it awaits `queryRunner.connect()`), so it resolves + * to an `Observable` rather than returning one synchronously — await it + * before handing it to `lastValueFrom`. */ +async function run( + interceptor: TransactionInterceptor, + ctx: ExecutionContext, + next: CallHandler, +): Promise { + return lastValueFrom(await interceptor.intercept(ctx, next)); +} + +describe('TransactionInterceptor', () => { + let interceptor: TransactionInterceptor; + let queryRunner: { + connect: jest.Mock; + startTransaction: jest.Mock; + commitTransaction: jest.Mock; + rollbackTransaction: jest.Mock; + release: jest.Mock; + }; + let dataSource: { createQueryRunner: jest.Mock }; + + beforeEach(() => { + queryRunner = { + connect: jest.fn().mockResolvedValue(undefined), + startTransaction: jest.fn().mockResolvedValue(undefined), + commitTransaction: jest.fn().mockResolvedValue(undefined), + rollbackTransaction: jest.fn().mockResolvedValue(undefined), + release: jest.fn().mockResolvedValue(undefined), + }; + dataSource = { createQueryRunner: jest.fn().mockReturnValue(queryRunner) }; + interceptor = new TransactionInterceptor( + dataSource as unknown as DataSource, + ); + }); + + it('starts a transaction, attaches the queryRunner to the request, and commits on success', async () => { + const req: Record = {}; + const ctx = buildContext(req); + const next: CallHandler = { handle: () => of({ ok: true }) }; + + const result = await run(interceptor, ctx, next); + + expect(dataSource.createQueryRunner).toHaveBeenCalledTimes(1); + expect(queryRunner.connect).toHaveBeenCalledTimes(1); + expect(queryRunner.startTransaction).toHaveBeenCalledTimes(1); + expect(req.queryRunner).toBe(queryRunner); + expect(result).toEqual({ ok: true }); + + // commit happens asynchronously inside `tap`; flush microtasks + await Promise.resolve(); + await Promise.resolve(); + expect(queryRunner.commitTransaction).toHaveBeenCalledTimes(1); + expect(queryRunner.release).toHaveBeenCalledTimes(1); + expect(queryRunner.rollbackTransaction).not.toHaveBeenCalled(); + }); + + it('rolls back and releases the connection when the handler throws', async () => { + const req: Record = {}; + const ctx = buildContext(req); + const error = new Error('boom'); + const next: CallHandler = { handle: () => throwError(() => error) }; + + await expect(run(interceptor, ctx, next)).rejects.toThrow('boom'); + + expect(queryRunner.rollbackTransaction).toHaveBeenCalledTimes(1); + expect(queryRunner.release).toHaveBeenCalledTimes(1); + expect(queryRunner.commitTransaction).not.toHaveBeenCalled(); + }); + + it('still releases the connection when rollback itself fails', async () => { + const req: Record = {}; + const ctx = buildContext(req); + const handlerError = new Error('handler failed'); + queryRunner.rollbackTransaction.mockRejectedValue( + new Error('rollback failed'), + ); + const next: CallHandler = { + handle: () => throwError(() => handlerError), + }; + + await expect(run(interceptor, ctx, next)).rejects.toThrow('handler failed'); + + expect(queryRunner.release).toHaveBeenCalledTimes(1); + }); + + it('passes through non-http contexts without opening a transaction', async () => { + const ctx = { getType: () => 'ws' } as unknown as ExecutionContext; + const next: CallHandler = { handle: () => of('passthrough') }; + + const result = await run(interceptor, ctx, next); + + expect(result).toBe('passthrough'); + expect(dataSource.createQueryRunner).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/common/src/interceptors/transaction.interceptor.ts b/libs/common/src/interceptors/transaction.interceptor.ts index f1ca0f0..967dff7 100644 --- a/libs/common/src/interceptors/transaction.interceptor.ts +++ b/libs/common/src/interceptors/transaction.interceptor.ts @@ -2,26 +2,44 @@ import { CallHandler, ExecutionContext, Injectable, + Logger, NestInterceptor, } from '@nestjs/common'; -import { InjectConnection } from '@nestjs/typeorm'; -import { Connection } from 'typeorm'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { Observable } from 'rxjs'; import { catchError, tap } from 'rxjs/operators'; +/** + * Wraps a request handler in a single database transaction, giving + * `@Transactional()`-annotated handlers ACID guarantees across every + * repository call they make: the transaction commits when the handler + * completes successfully and rolls back — atomically undoing every write + * made through it — when the handler throws. + * + * The transactional `EntityManager` is attached to the request so handlers + * and the services they call can read from it via the `@TransactionManager()` + * param decorator (see `transaction-manager.decorator.ts`) instead of the + * module-wide (non-transactional) repository. + */ @Injectable() export class TransactionInterceptor implements NestInterceptor { - constructor(@InjectConnection() private readonly connection: Connection) {} + private readonly logger = new Logger(TransactionInterceptor.name); + + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} async intercept( context: ExecutionContext, next: CallHandler, - ): Promise> { - const queryRunner = this.connection.createQueryRunner(); + ): Promise> { + if (context.getType() !== 'http') { + return next.handle(); + } + + const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); - // Attach the queryRunner to the request object const request = context.switchToHttp().getRequest(); request.queryRunner = queryRunner; @@ -31,8 +49,17 @@ export class TransactionInterceptor implements NestInterceptor { await queryRunner.release(); }), catchError(async (err) => { - await queryRunner.rollbackTransaction(); - await queryRunner.release(); + try { + await queryRunner.rollbackTransaction(); + } catch (rollbackErr) { + this.logger.error( + `Failed to roll back transaction: ${ + (rollbackErr as Error).message + }`, + ); + } finally { + await queryRunner.release(); + } throw err; }), ); diff --git a/package-lock.json b/package-lock.json index 8e9106d..5c83d6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "discord.js": "^14.27.0", + "dotenv": "^16.6.1", "helmet": "^7.1.0", "ioredis": "^5.4.1", "joi": "^17.13.4", @@ -265,6 +266,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1068,7 +1070,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", @@ -1080,7 +1081,6 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -1107,8 +1107,7 @@ "url": "https://opencollective.com/fastify" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@fastify/fast-json-stringify-compiler": { "version": "5.1.0", @@ -1125,7 +1124,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "fast-json-stringify": "^7.0.0" } @@ -1144,8 +1142,7 @@ "url": "https://opencollective.com/fastify" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@fastify/merge-json-schemas": { "version": "0.2.1", @@ -1162,7 +1159,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -1182,7 +1178,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" @@ -1193,7 +1188,6 @@ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 10" } @@ -2184,6 +2178,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.22.tgz", "integrity": "sha512-fxJ4v85nDHaqT1PmfNCQ37b/jcv2OojtXTaK1P2uAXhzLf9qq6WNUOFvxBrV4fhQek1EQoT1o9oj5xAZmv3NRw==", "license": "MIT", + "peer": true, "dependencies": { "file-type": "20.4.1", "iterare": "1.2.1", @@ -2224,12 +2219,25 @@ "rxjs": "^7.1.0" } }, + "node_modules/@nestjs/config/node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/@nestjs/core": { "version": "10.4.22", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.22.tgz", "integrity": "sha512-6IX9+VwjiKtCjx+mXVPncpkQ5ZjKfmssOZPFexmT+6T9H9wZ3svpYACAo7+9e7Nr9DZSoRZw3pffkJP7Z0UjaA==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", @@ -2323,6 +2331,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.22.tgz", "integrity": "sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==", "license": "MIT", + "peer": true, "dependencies": { "body-parser": "1.20.4", "cors": "2.8.5", @@ -2499,6 +2508,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.22.tgz", "integrity": "sha512-OLd4i0Faq7vgdtB5vVUrJ54hWEtcXy9poJ6n7kbbh/5ms+KffUl+wwGsbe7uSXLrkoyI8xXU6fZPkFArI+XiRg==", "license": "MIT", + "peer": true, "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", @@ -2610,8 +2620,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", @@ -3102,6 +3111,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3332,6 +3342,7 @@ "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.64.0", "@typescript-eslint/types": "8.64.0", @@ -3726,8 +3737,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/accepts": { "version": "1.3.8", @@ -3748,6 +3758,7 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3761,7 +3772,6 @@ "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.13.0" }, @@ -3809,6 +3819,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -4009,7 +4020,6 @@ "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -4044,7 +4054,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" @@ -4418,6 +4427,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", @@ -4497,6 +4507,7 @@ "resolved": "https://registry.npmjs.org/bull/-/bull-4.16.5.tgz", "integrity": "sha512-lDsx2BzkKe7gkCYiT5Acj02DpTwDznl/VNN7Psn7M3USPG7Vs/BaClZJJTAG+ufAR9++N1/NiUTdaFBWDIl5TQ==", "license": "MIT", + "peer": true, "dependencies": { "cron-parser": "^4.9.0", "get-port": "^5.1.1", @@ -4723,13 +4734,15 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/class-validator": { "version": "0.14.4", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", "license": "MIT", + "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -5219,7 +5232,6 @@ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -5335,9 +5347,9 @@ } }, "node_modules/dotenv": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", - "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -5502,8 +5514,7 @@ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/es-object-atoms": { "version": "1.1.2", @@ -5567,6 +5578,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5623,6 +5635,7 @@ "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -5943,6 +5956,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -6024,8 +6038,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-deep-equal": { "version": "3.1.3", @@ -6062,7 +6075,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", @@ -6077,7 +6089,6 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -6104,8 +6115,7 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/fast-levenshtein": { "version": "2.0.6", @@ -6119,7 +6129,6 @@ "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", "license": "MIT", - "peer": true, "dependencies": { "fast-decode-uri-component": "^1.0.1" } @@ -6144,8 +6153,7 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/fastify": { "version": "5.10.0", @@ -6332,7 +6340,6 @@ "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", @@ -7495,6 +7502,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8286,7 +8294,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -8445,7 +8452,6 @@ } ], "license": "BSD-3-Clause", - "peer": true, "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", @@ -8457,7 +8463,6 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -8480,8 +8485,7 @@ "url": "https://opencollective.com/fastify" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lines-and-columns": { "version": "1.2.4", @@ -8833,7 +8837,6 @@ "integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", @@ -8895,7 +8898,6 @@ "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -8911,7 +8913,6 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -8932,7 +8933,6 @@ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -9185,7 +9185,6 @@ "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", "license": "MIT", - "peer": true, "engines": { "node": ">=14.0.0" } @@ -9374,6 +9373,7 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", + "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -9489,6 +9489,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", @@ -9598,7 +9599,6 @@ "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", "license": "MIT", - "peer": true, "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", @@ -9621,7 +9621,6 @@ "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", "license": "MIT", - "peer": true, "dependencies": { "split2": "^4.0.0" } @@ -9630,8 +9629,7 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/pirates": { "version": "4.0.7", @@ -9901,6 +9899,7 @@ "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9966,8 +9965,7 @@ "url": "https://opencollective.com/fastify" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/prompts": { "version": "2.4.2", @@ -10071,8 +10069,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/randombytes": { "version": "2.1.0", @@ -10159,7 +10156,6 @@ "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 12.13.0" } @@ -10323,7 +10319,6 @@ "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" } @@ -10342,8 +10337,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/rimraf": { "version": "6.1.3", @@ -10488,7 +10482,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "ret": "~0.5.0" }, @@ -10501,7 +10494,6 @@ "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" } @@ -10537,6 +10529,7 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -10579,8 +10572,7 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/semver": { "version": "7.8.5", @@ -10652,8 +10644,7 @@ "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/set-function-length": { "version": "1.2.2", @@ -10876,7 +10867,6 @@ "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", "license": "MIT", - "peer": true, "dependencies": { "atomic-sleep": "^1.0.0" } @@ -11464,7 +11454,6 @@ "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", "license": "MIT", - "peer": true, "dependencies": { "real-require": "^1.0.0" }, @@ -11476,8 +11465,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/through": { "version": "2.3.8", @@ -11568,7 +11556,6 @@ "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", "license": "MIT", - "peer": true, "engines": { "node": ">=20" } @@ -11738,6 +11725,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11903,6 +11891,7 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.31.tgz", "integrity": "sha512-6u9EFtdLBgHjnPm78NStVeM+I/1MolTzKykDDcydzKUkh6E++YS6XViU/fePJbvDvEGU4Xq34KOM/CLeer9I2A==", "license": "MIT", + "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.3.1", @@ -12039,18 +12028,6 @@ "ieee754": "^1.2.1" } }, - "node_modules/typeorm/node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/typeorm/node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -12106,6 +12083,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12413,7 +12391,6 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -12428,7 +12405,6 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -12439,7 +12415,6 @@ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -12450,7 +12425,6 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", diff --git a/package.json b/package.json index a3f884a..1592a24 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,13 @@ "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts", + "migration:create": "typeorm-ts-node-commonjs migration:create", + "migration:generate": "npm run typeorm -- migration:generate", + "migration:run": "npm run typeorm -- migration:run", + "migration:revert": "npm run typeorm -- migration:revert", + "migration:show": "npm run typeorm -- migration:show" }, "dependencies": { "@nestjs/bull": "^11.0.4", @@ -38,6 +44,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "discord.js": "^14.27.0", + "dotenv": "^16.6.1", "helmet": "^7.1.0", "ioredis": "^5.4.1", "joi": "^17.13.4", @@ -91,6 +98,10 @@ "ts" ], "rootDir": "src", + "roots": [ + "", + "/../libs" + ], "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 5c84afc..59e29cc 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -14,6 +14,22 @@ export default () => ({ name: process.env.DB_NAME, synchronize: process.env.DB_SYNCHRONIZE === 'true', logging: process.env.DB_LOGGING === 'true', + // Connection pool sizing (node-postgres `pg.Pool` options, passed through + // TypeORM's `extra`). Tuned pooling avoids per-request connection setup + // cost and caps concurrent connections so the app can't exhaust Postgres' + // `max_connections` under load. See docs/database.md. + pool: { + max: parseInt(process.env.DB_POOL_MAX ?? '20', 10), + min: parseInt(process.env.DB_POOL_MIN ?? '5', 10), + idleTimeoutMs: parseInt( + process.env.DB_POOL_IDLE_TIMEOUT_MS ?? '30000', + 10, + ), + connectionTimeoutMs: parseInt( + process.env.DB_POOL_CONNECTION_TIMEOUT_MS ?? '5000', + 10, + ), + }, }, redis: { diff --git a/src/config/database.config.spec.ts b/src/config/database.config.spec.ts new file mode 100644 index 0000000..95ac64d --- /dev/null +++ b/src/config/database.config.spec.ts @@ -0,0 +1,61 @@ +import { ConfigService } from '@nestjs/config'; +import { DatabaseConfig } from './database.config'; + +describe('DatabaseConfig', () => { + const database = { + host: 'db.internal', + port: 5432, + username: 'app', + password: 'secret', + name: 'app_db', + synchronize: false, + logging: true, + pool: { + max: 20, + min: 5, + idleTimeoutMs: 30000, + connectionTimeoutMs: 5000, + }, + }; + + function build(): ReturnType { + const configService = { + get: jest.fn().mockReturnValue(database), + } as unknown as ConfigService; + return new DatabaseConfig(configService).createTypeOrmOptions(); + } + + it('maps validated config to postgres connection options', () => { + const options = build() as Record; + + expect(options.type).toBe('postgres'); + expect(options.host).toBe('db.internal'); + expect(options.port).toBe(5432); + expect(options.username).toBe('app'); + expect(options.password).toBe('secret'); + expect(options.database).toBe('app_db'); + expect(options.autoLoadEntities).toBe(true); + expect(options.synchronize).toBe(false); + }); + + it('forwards pool sizing to the pg driver via `extra`', () => { + const options = build() as Record; + + expect(options.extra).toEqual({ + max: 20, + min: 5, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, + }); + }); + + it('points at the version-controlled migrations directory and never auto-runs them', () => { + const options = build() as Record; + + expect(options.migrationsRun).toBe(false); + expect(Array.isArray(options.migrations)).toBe(true); + expect(options.migrations[0]).toEqual( + expect.stringContaining('database/migrations'), + ); + }); +}); diff --git a/src/config/database.config.ts b/src/config/database.config.ts index 58cb323..7d2ddc5 100644 --- a/src/config/database.config.ts +++ b/src/config/database.config.ts @@ -23,6 +23,21 @@ export class DatabaseConfig implements TypeOrmOptionsFactory { autoLoadEntities: true, synchronize: db.synchronize, logging: db.logging, + // Version-controlled migrations (see src/database). Never run + // automatically on boot — CI/CD applies them explicitly with + // `npm run migration:run` before the new app version starts serving + // traffic, so a bad migration fails the deploy rather than the app. + migrations: [__dirname + '/../database/migrations/*{.ts,.js}'], + migrationsRun: false, + // Connection pool (forwarded to node-postgres' `pg.Pool`). Reusing + // warm connections instead of opening one per request is the single + // biggest lever on request latency under load. + extra: { + max: db.pool.max, + min: db.pool.min, + idleTimeoutMillis: db.pool.idleTimeoutMs, + connectionTimeoutMillis: db.pool.connectionTimeoutMs, + }, }; } } diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index c15ffc2..c337f63 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -17,6 +17,10 @@ export const envValidationSchema = Joi.object({ DB_NAME: Joi.string().required(), DB_SYNCHRONIZE: Joi.boolean().default(false), DB_LOGGING: Joi.boolean().default(false), + DB_POOL_MAX: Joi.number().integer().min(1).default(20), + DB_POOL_MIN: Joi.number().integer().min(0).default(5), + DB_POOL_IDLE_TIMEOUT_MS: Joi.number().integer().min(0).default(30000), + DB_POOL_CONNECTION_TIMEOUT_MS: Joi.number().integer().min(0).default(5000), REDIS_HOST: Joi.string().default('localhost'), REDIS_PORT: Joi.number().default(6379), diff --git a/src/database/data-source.ts b/src/database/data-source.ts new file mode 100644 index 0000000..eabfeb2 --- /dev/null +++ b/src/database/data-source.ts @@ -0,0 +1,50 @@ +import 'reflect-metadata'; +import * as dotenv from 'dotenv'; +import { DataSource, DataSourceOptions } from 'typeorm'; + +// The Nest app itself gets its config through `@nestjs/config` (see +// `src/config`), which is wired into dependency injection and isn't +// available to a plain CLI script. The TypeORM CLI (`typeorm-ts-node-commonjs`, +// invoked via the `migration:*` npm scripts) needs a standalone +// `DataSource`, so this file loads `.env` itself and duplicates only the +// connection settings — not the full validated config — that migrations +// need. +dotenv.config(); + +/** + * Pool sizing mirrors `src/config/database.config.ts` so a migration run + * behaves like the app it is migrating for; see docs/database.md. + */ +export const dataSourceOptions: DataSourceOptions = { + type: 'postgres', + host: process.env.DB_HOST ?? 'localhost', + port: parseInt(process.env.DB_PORT ?? '5432', 10), + username: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + // Never use `synchronize` on this DataSource: it exists to generate and + // run migrations, and synchronize + migrations together is how schemas + // drift silently. + synchronize: false, + entities: [ + __dirname + '/../modules/**/*.entity{.ts,.js}', + __dirname + '/../../libs/common/src/**/*.entity{.ts,.js}', + ], + migrations: [__dirname + '/migrations/*{.ts,.js}'], + extra: { + max: parseInt(process.env.DB_POOL_MAX ?? '20', 10), + min: parseInt(process.env.DB_POOL_MIN ?? '5', 10), + idleTimeoutMillis: parseInt( + process.env.DB_POOL_IDLE_TIMEOUT_MS ?? '30000', + 10, + ), + connectionTimeoutMillis: parseInt( + process.env.DB_POOL_CONNECTION_TIMEOUT_MS ?? '5000', + 10, + ), + }, +}; + +const AppDataSource = new DataSource(dataSourceOptions); + +export default AppDataSource; diff --git a/src/database/migrations/1787847457730-AddTransactionIntegrityConstraints.ts b/src/database/migrations/1787847457730-AddTransactionIntegrityConstraints.ts new file mode 100644 index 0000000..24f4a89 --- /dev/null +++ b/src/database/migrations/1787847457730-AddTransactionIntegrityConstraints.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds a data-validation constraint and a query-pattern index to + * `transactions`. + * + * This is the first migration in the project (see docs/database.md for why + * there is no baseline migration yet): it assumes the `transactions` table + * already exists, created either by `synchronize` in a dev environment or by + * a future baseline migration. Run it only after that precondition holds. + */ +export class AddTransactionIntegrityConstraints1787847457730 implements MigrationInterface { + name = 'AddTransactionIntegrityConstraints1787847457730'; + + public async up(queryRunner: QueryRunner): Promise { + // Data validation: a transaction moving zero or negative value is an + // invalid state that should never reach the database, regardless of + // which application-layer check might have been skipped. + await queryRunner.query(` + ALTER TABLE "transactions" + ADD CONSTRAINT "CHK_transactions_amount_positive" CHECK ("amount" > 0) + `); + + // Query performance: the transaction-history endpoints page through a + // single user's transactions filtered by status, most-recent first. The + // existing single-column indexes on `userId` and `status` each narrow + // the search independently; this composite index lets Postgres satisfy + // that exact filter+sort in one index scan instead of intersecting two + // bitmap scans and then sorting. + await queryRunner.query(` + CREATE INDEX "IDX_transactions_user_status_created" + ON "transactions" ("userId", "status", "createdAt") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS "IDX_transactions_user_status_created" + `); + await queryRunner.query(` + ALTER TABLE "transactions" + DROP CONSTRAINT IF EXISTS "CHK_transactions_amount_positive" + `); + } +} diff --git a/src/modules/transactions/entities/transaction.entity.ts b/src/modules/transactions/entities/transaction.entity.ts index 187cc2c..9fc8453 100644 --- a/src/modules/transactions/entities/transaction.entity.ts +++ b/src/modules/transactions/entities/transaction.entity.ts @@ -1,4 +1,4 @@ -import { Column, Entity, Index } from 'typeorm'; +import { Check, Column, Entity, Index } from 'typeorm'; import { BaseEntity } from '@app/common'; export enum TransactionType { @@ -20,6 +20,12 @@ export enum TransactionStatus { * indexer can dedupe replays. */ @Entity('transactions') +@Check('CHK_transactions_amount_positive', '"amount" > 0') +@Index('IDX_transactions_user_status_created', [ + 'userId', + 'status', + 'createdAt', +]) export class Transaction extends BaseEntity { @Index({ unique: true, where: '"stellarTxHash" IS NOT NULL' }) @Column({ type: 'varchar', nullable: true })