diff --git a/.env.example b/.env.example index 51d331e..095664b 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,12 @@ DB_PASSWORD=postgres DB_NAME=interchangabletrade DB_SYNCHRONIZE=true DB_LOGGING=false +# Connection pooling +DB_POOL_MAX=20 +DB_POOL_MIN=2 +DB_POOL_IDLE_TIMEOUT_MS=30000 +# Versioned migrations (see docs/data-persistence.md) +DB_MIGRATIONS_RUN=false # Redis REDIS_HOST=localhost diff --git a/docs/data-persistence.md b/docs/data-persistence.md new file mode 100644 index 0000000..0902cc9 --- /dev/null +++ b/docs/data-persistence.md @@ -0,0 +1,113 @@ +# Data Persistence & Database Module + +How InterChangableTrade-Core persists data: connection management, transactions, +repositories, migrations, auditing, and operational guarantees. + +## Components + +| Piece | Location | Purpose | +| --- | --- | --- | +| Connection & pooling | `src/config/database.config.ts` | PostgreSQL options built from validated env config (`DB_POOL_MAX`, `DB_POOL_MIN`, idle timeout). | +| ACID transaction helper | `libs/common/src/database/transaction.helper.ts` | `TransactionHelper.run(work, isolation?)` wraps `DataSource.transaction`; everything commits or rolls back together. | +| Base repository | `libs/common/src/database/base.repository.ts` | `BaseRepository.findPaginated()` returns the shared `PaginatedResultDto` envelope with clamped page/limit. | +| Versioned migrations | `src/database/migrations/` + `src/database/data-source.ts` | TypeORM CLI migrations; run via `npm run migration:run|revert|generate`. `DB_MIGRATIONS_RUN=true` applies pending migrations on boot. | +| Audit trail entity | `src/database/entities/audit-trail.entity.ts` | Append-only row-level change capture (`actorId`, `action`, `before`, `after`) with CHECK constraint on `action`. | +| Change-capture subscriber | `src/database/audit-trail.subscriber.ts` | Global TypeORM subscriber writing trail rows using the event's transaction manager; skips itself and swallows its own failures. | +| Actor binding | `src/database/audit-context.ts`, `src/database/actor-context.interceptor.ts` | `AsyncLocalStorage` carries the request user to the subscriber without signature changes. | + +The activity/event log in `src/modules/audit` (who called which endpoint) is +complementary to the row-level trail here (which row changed and how). + +## Entity relationship overview + +```mermaid +erDiagram + USER ||--o{ WALLET : owns + USER ||--o{ AUDIT_LOG : "acted in" + USER ||--o{ AUDIT_TRAIL : "changed rows" + AUDIT_TRAIL }o--|| ENTITY : "snapshots" + + USER { + uuid id PK + string email UK + string passwordHash + timestamptz createdAt + } + WALLET { + uuid id PK + uuid userId FK + numeric balance + string assetCode + timestamptz updatedAt + } + AUDIT_LOG { + uuid id PK + uuid userId + enum category + string action + jsonb metadata + timestamptz createdAt + } + AUDIT_TRAIL { + uuid id PK + string actorId + string action "CHECK insert|update|delete" + string entityType + string entityId + jsonb before + jsonb after + timestamptz createdAt + } +``` + +## Indexing strategy + +- **Composite indexes follow query patterns**: `(entityType, entityId, createdAt)` + serves "history of this row", `(actorId, createdAt)` serves "what did this user + change". Column order matches equality-first, range-last. +- **Hot filters get indexes** (`action`, `userId`) but every index slows writes; + audit tables are write-heavy, so avoid speculative single-column indexes on + low-cardinality columns. +- **JSONB snapshots are not indexed** by default; if diffing becomes a workload, + add GIN indexes on specific keys rather than whole documents. +- Verify with `EXPLAIN (ANALYZE, BUFFERS)` before adding; monitor + `pg_stat_user_indexes` for unused indexes. + +## Backup & disaster recovery + +Targets: **RPO < 5 minutes**, **RTO < 15 minutes**. + +1. **Continuous WAL archiving** (PostgreSQL `archive_command` or a managed + equivalent such as Cloud SQL PITR / RDS automated backups) gives a + point-in-time recovery window measured in seconds-to-minutes → satisfies RPO. +2. **Nightly base backups** (`pgBackRest` full/incremental) retained 30 days, + plus weekly restore drills into staging. +3. **Recovery runbook**: promote latest base backup + replay WAL to just before + failure (≈5–10 min for a database of our size class); DNS/connection-string + cutover to the restored instance (≈2 min); app pods reconnect via pooled + retry logic already present in TypeORM. Total stays under the 15-minute RTO. +4. **Audit data durability**: `audit_trails` is append-only and small per row; + it is included in the same backup chain. For stricter guarantees, stream + inserts to an append-only sink (object storage / second region) as well. +5. Quarterly game-day: failover to warm standby replica; replication lag alarm + at >60s protects the RPO budget. + +## Sharding strategy + +Current scale does not require sharding; the plan below is the agreed path when +write volume demands it: + +- **Tenant/region-first**: partition by `userId` hash at the application layer + (a thin datasource router keyed off the JWT claim). Most queries are + user-scoped (wallets, trades), so they hit exactly one shard. +- **Shard key choice**: `userId`, never auto-increment ids or timestamps — + avoids hot shards and keeps cross-shard joins rare. +- **Cross-shard operations**: escrow/trading flows that must touch two users' + rows use saga-style compensation instead of 2PC; each leg is a local ACID + transaction via `TransactionHelper`. +- **Audit trails**: shard by `entityId` hash; they are written where the change + happened and read per-entity, so co-location preserves the access pattern. +- **Migration path**: start with PostgreSQL declarative partitioning + (`PARTITION BY HASH (userId)`) on the largest tables (trades, ledger entries) + — it delivers most of the benefit with no application changes — before moving + to true multi-instance sharding. diff --git a/libs/common/src/database/base.repository.ts b/libs/common/src/database/base.repository.ts new file mode 100644 index 0000000..57d97a2 --- /dev/null +++ b/libs/common/src/database/base.repository.ts @@ -0,0 +1,34 @@ +import { FindOptionsOrder, FindOptionsWhere, Repository } from 'typeorm'; +import { BaseEntity } from '../entities/base.entity'; +import { PaginatedResultDto } from '../dto/paginated-result.dto'; +import { PaginationQueryDto } from '../dto/pagination-query.dto'; + +/** + * Base class for feature repositories. Adds opinionated pagination on top of + * the stock TypeORM repository so every list endpoint shares the same + * envelope ({@link PaginatedResultDto}) and the same 1-based page semantics + * as {@link PaginationQueryDto}. + */ +export class BaseRepository extends Repository { + /** + * Returns one page of rows plus pagination metadata. Page is 1-based; + * limit defaults to 20 and is clamped to [1, 100] to match the shared DTO. + */ + async findPaginated( + query: Partial> = {}, + where?: FindOptionsWhere | FindOptionsWhere[], + order?: FindOptionsOrder, + ): Promise> { + const page = Math.max(1, query.page ?? 1); + const limit = Math.min(Math.max(1, query.limit ?? 20), 100); + + const [items, total] = await this.findAndCount({ + skip: (page - 1) * limit, + take: limit, + where, + order: order ?? ({ createdAt: 'DESC' } as FindOptionsOrder), + }); + + return new PaginatedResultDto(items, total, page, limit); + } +} diff --git a/libs/common/src/database/transaction.helper.ts b/libs/common/src/database/transaction.helper.ts new file mode 100644 index 0000000..23dbab4 --- /dev/null +++ b/libs/common/src/database/transaction.helper.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { + DataSource, + EntityManager, +} from 'typeorm'; + +/** + * Isolation levels accepted by {@link DataSource.transaction} ("READ + * UNCOMMITTED", "READ COMMITTED", "REPEATABLE READ", "SERIALIZABLE"). + */ +export type TransactionIsolationLevel = Parameters< + DataSource['transaction'] +>[0]; + +/** + * Thin ACID transaction helper. Wraps {@link DataSource.transaction} so + * services get a scoped {@link EntityManager}: everything executed inside the + * callback commits together or rolls back together. + * + * Usage: + * ```ts + * await this.transactions.run(async (tx) => { + * const wallet = await tx.findOneBy(Wallet, { id }); + * wallet.balance -= amount; + * await tx.save(wallet); + * }, 'SERIALIZABLE'); + * ``` + * + * A thrown error propagates after TypeORM rolls back, so callers keep their + * normal error handling. To compose code that must join an existing + * transaction, pass its manager to {@link TransactionHelper.join}. + */ +@Injectable() +export class TransactionHelper { + constructor(private readonly dataSource: DataSource) {} + + async run( + work: (manager: EntityManager) => Promise, + isolationLevel?: TransactionIsolationLevel, + ): Promise { + return this.dataSource.transaction(isolationLevel as never, work); + } + + /** + * Runs work inside an existing transaction's manager. Useful for composing + * repository helpers that must join a caller's transaction instead of + * opening a nested one. + */ + static join( + manager: EntityManager, + work: (tx: EntityManager) => Promise, + ): Promise { + return work(manager); + } +} diff --git a/libs/common/src/index.ts b/libs/common/src/index.ts index e1c975b..cc037d1 100644 --- a/libs/common/src/index.ts +++ b/libs/common/src/index.ts @@ -8,3 +8,5 @@ export * from './dto/paginated-result.dto'; export * from './interceptors/transform.interceptor'; export * from './filters/http-exception.filter'; export * from './decorators/api-paginated-response.decorator'; +export * from './database/base.repository'; +export * from './database/transaction.helper'; diff --git a/package.json b/package.json index a3f884a..4dd2f7d 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,10 @@ "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", + "migration:run": "typeorm migration:run -d src/database/data-source.ts", + "migration:revert": "typeorm migration:revert -d src/database/data-source.ts", + "migration:generate": "typeorm migration:generate -d src/database/data-source.ts" }, "dependencies": { "@nestjs/bull": "^11.0.4", diff --git a/src/app.module.ts b/src/app.module.ts index 0526114..971fc89 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -26,6 +26,7 @@ import { AuditModule } from './modules/audit/audit.module'; import { EscrowModule } from './modules/escrow/escrow.module'; import { ComplianceModule } from './modules/compliance/compliance.module'; import { RateLimitingModule } from './modules/rate-limiting/rate-limiting.module'; +import { DatabaseModule } from './database/database.module'; import { PortfolioModule } from './modules/portfolio/portfolio.module'; import { WebhookModule } from './modules/webhooks/webhook.module'; @@ -33,6 +34,7 @@ import { WebhookModule } from './modules/webhooks/webhook.module'; imports: [ EventEmitterModule.forRoot(), ConfigModule, + DatabaseModule, TypeOrmModule.forRootAsync({ useClass: DatabaseConfig, }), diff --git a/src/config/configuration.ts b/src/config/configuration.ts index f351aa1..b183d74 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -14,6 +14,14 @@ export default () => ({ name: process.env.DB_NAME, synchronize: process.env.DB_SYNCHRONIZE === 'true', logging: process.env.DB_LOGGING === 'true', + + // Connection pooling. + poolMax: parseInt(process.env.DB_POOL_MAX ?? '20', 10), + poolMin: parseInt(process.env.DB_POOL_MIN ?? '2', 10), + poolIdleTimeoutMs: parseInt(process.env.DB_POOL_IDLE_TIMEOUT_MS ?? '30000', 10), + + // Run versioned migrations automatically on startup (non-production). + migrationsRun: process.env.DB_MIGRATIONS_RUN === 'true', }, redis: { diff --git a/src/config/database.config.ts b/src/config/database.config.ts index 58cb323..82884c6 100644 --- a/src/config/database.config.ts +++ b/src/config/database.config.ts @@ -1,15 +1,23 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm'; +import { AuditTrailSubscriber } from '../database/audit-trail.subscriber'; /** * Builds TypeORM connection options from validated configuration. Entities are * auto-loaded via the `autoLoadEntities` flag so feature modules only need to * register their entities with `TypeOrmModule.forFeature`. + * + * Pool sizing is environment-driven (`DB_POOL_MAX` / `DB_POOL_MIN`) — pg + * exposes these as poolSize/min. Versioned migrations are wired via + * `DB_MIGRATIONS_RUN`; prefer them over `synchronize` outside development. */ @Injectable() export class DatabaseConfig implements TypeOrmOptionsFactory { - constructor(private readonly configService: ConfigService) {} + constructor( + private readonly configService: ConfigService, + private readonly auditTrailSubscriber: AuditTrailSubscriber, + ) {} createTypeOrmOptions(): TypeOrmModuleOptions { const db = this.configService.get('database'); @@ -23,6 +31,21 @@ export class DatabaseConfig implements TypeOrmOptionsFactory { autoLoadEntities: true, synchronize: db.synchronize, logging: db.logging, + + // Connection pooling. + poolSize: db.poolMax, + extra: { + min: db.poolMin, + max: db.poolMax, + // Recycle connections so they do not outlive DB/network idle timeouts. + idleTimeoutMillis: db.poolIdleTimeoutMs, + }, + + // Row-level change capture (see src/database/audit-trail.subscriber.ts). + subscribers: [this.auditTrailSubscriber], + + // Versioned migrations infrastructure (see src/database/migrations/). + migrationsRun: db.migrationsRun, }; } } diff --git a/src/database/actor-context.interceptor.spec.ts b/src/database/actor-context.interceptor.spec.ts new file mode 100644 index 0000000..5b11182 --- /dev/null +++ b/src/database/actor-context.interceptor.spec.ts @@ -0,0 +1,49 @@ +import { ExecutionContext } from '@nestjs/common'; +import { firstValueFrom, Observable } from 'rxjs'; +import { ActorContextInterceptor } from './actor-context.interceptor'; +import { AuditContextService } from './audit-context'; + +/** + * Verifies the interceptor binds the request user so code running downstream + * (including TypeORM subscribers) resolves the right audit actor. + */ +describe('ActorContextInterceptor', () => { + const buildContext = (request: unknown) => + ({ + switchToHttp: () => ({ getRequest: () => request }), + }) as unknown as ExecutionContext; + + it('binds the authenticated user id', async () => { + let seen: string | undefined; + const interceptor = new ActorContextInterceptor(); + + const result$ = interceptor.intercept(buildContext({ user: { id: 'u42' } }), { + handle: () => + new Observable((subscriber) => { + seen = AuditContextService.getActor().id; + subscriber.next('ok'); + subscriber.complete(); + }), + } as never); + + await firstValueFrom(result$ as Observable); + expect(seen).toBe('u42'); + }); + + it('falls back to the system actor for unauthenticated requests', async () => { + let seen: string | undefined; + const interceptor = new ActorContextInterceptor(); + + const result$ = interceptor.intercept(buildContext({}), { + handle: () => + new Observable((subscriber) => { + seen = AuditContextService.getActor().id; + subscriber.next('ok'); + subscriber.complete(); + }), + } as never); + + await firstValueFrom(result$ as Observable); + expect(seen).toBe('system'); + }); +}); diff --git a/src/database/actor-context.interceptor.ts b/src/database/actor-context.interceptor.ts new file mode 100644 index 0000000..c30e129 --- /dev/null +++ b/src/database/actor-context.interceptor.ts @@ -0,0 +1,31 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { AuditContextService } from './audit-context'; + +/** + * Binds the authenticated request user to {@link AuditContextService} for the + * duration of the handler, so the {@link AuditTrailSubscriber} can attribute + * row changes to an actor without any service-layer plumbing. + */ +@Injectable() +export class ActorContextInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const user = request?.user as { id?: string } | undefined; + + // Subscribe *inside* run() so every async continuation of the handler + // inherits the actor context. + return new Observable((subscriber) => + AuditContextService.run( + user ? { id: String(user.id) } : undefined, + () => next.handle().subscribe(subscriber), + ), + ); + } +} diff --git a/src/database/audit-context.ts b/src/database/audit-context.ts new file mode 100644 index 0000000..41375e2 --- /dev/null +++ b/src/database/audit-context.ts @@ -0,0 +1,27 @@ +import { AsyncLocalStorage } from 'async_hooks'; + +export interface AuditActor { + id: string; +} + +/** + * Request-scoped holder of the current audit actor. The + * {@link ActorContextInterceptor} seeds it from `request.user` and the + * {@link AuditTrailSubscriber} reads it when persisting trail records. + * + * `AsyncLocalStorage` keeps the value available across async boundaries + * without threading it through every service signature. + */ +export class AuditContextService { + private static readonly storage = new AsyncLocalStorage(); + + /** Runs `fn` with `actor` bound to everything it schedules. */ + static run(actor: AuditActor | undefined, fn: () => T): T { + return this.storage.run(actor ?? { id: 'system' }, fn); + } + + /** Current actor, or `system` when called outside a request context. */ + static getActor(): AuditActor { + return this.storage.getStore() ?? { id: 'system' }; + } +} diff --git a/src/database/audit-trail.subscriber.spec.ts b/src/database/audit-trail.subscriber.spec.ts new file mode 100644 index 0000000..652341d --- /dev/null +++ b/src/database/audit-trail.subscriber.spec.ts @@ -0,0 +1,124 @@ +import { + InsertEvent, + RemoveEvent, + UpdateEvent, +} from 'typeorm'; +import { AuditContextService } from './audit-context'; +import { AuditTrailSubscriber } from './audit-trail.subscriber'; + +type TrailRepo = { create: jest.Mock; insert: jest.Mock }; + +const buildCtx = () => { + const trailRepo: TrailRepo = { + create: jest.fn((v) => v), + insert: jest.fn().mockResolvedValue(undefined), + }; + return { + trailRepo, + manager: { getRepository: jest.fn(() => trailRepo) }, + }; +}; + +const buildEvent = (targetName: string, extra: Record) => + ({ metadata: { targetName }, ...extra }) as unknown as T; + +/** + * Exercises the real subscriber against a stubbed repository: rows must + * record actor/action/before/after, skip the trail table itself, and never + * break the operation being observed. + */ +describe('AuditTrailSubscriber', () => { + let subscriber: AuditTrailSubscriber; + let ctx: ReturnType; + + beforeEach(() => { + ctx = buildCtx(); + subscriber = new AuditTrailSubscriber({} as never); + }); + + it('records inserts with the system actor by default', async () => { + await subscriber.afterInsert( + buildEvent>('Wallet', { + entity: { id: 'w1', balance: 10 }, + manager: ctx.manager, + }), + ); + + expect(ctx.trailRepo.insert).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'system', + action: 'insert', + entityType: 'Wallet', + entityId: 'w1', + before: null, + after: { id: 'w1', balance: 10 }, + }), + ); + }); + + it('attributes updates to the request actor and captures before/after', async () => { + await AuditContextService.run({ id: 'user-9' }, async () => { + await subscriber.afterUpdate( + buildEvent>('Wallet', { + databaseEntity: { id: 'w1', balance: 10 }, + entity: { id: 'w1', balance: 25 }, + manager: ctx.manager, + }), + ); + }); + + expect(ctx.trailRepo.insert).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'user-9', + action: 'update', + before: { id: 'w1', balance: 10 }, + after: { id: 'w1', balance: 25 }, + }), + ); + }); + + it('captures only the prior state for deletes', async () => { + await subscriber.afterRemove( + buildEvent>('Wallet', { + databaseEntity: { id: 'w2', balance: 0 }, + entity: undefined, + manager: ctx.manager, + }), + ); + + expect(ctx.trailRepo.insert).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'delete', + entityType: 'Wallet', + entityId: 'w2', + before: { id: 'w2', balance: 0 }, + after: null, + }), + ); + }); + + it('never audits the audit trail itself (recursion guard)', async () => { + await subscriber.afterInsert( + buildEvent>('AuditTrail', { + entity: { id: 'a1' }, + manager: ctx.manager, + }), + ); + + expect(ctx.trailRepo.insert).not.toHaveBeenCalled(); + }); + + it('swallows persistence failures so business flow is unaffected', async () => { + ctx.trailRepo.insert.mockRejectedValueOnce(new Error('audit write failed')); + + await expect( + subscriber.afterInsert( + buildEvent>('Wallet', { + entity: { id: 'w3' }, + manager: ctx.manager, + }), + ), + ).resolves.toBeUndefined(); + expect(ctx.trailRepo.insert).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/database/audit-trail.subscriber.ts b/src/database/audit-trail.subscriber.ts new file mode 100644 index 0000000..5d85982 --- /dev/null +++ b/src/database/audit-trail.subscriber.ts @@ -0,0 +1,113 @@ +import { + DataSource, + EntitySubscriberInterface, + EventSubscriber, + InsertEvent, + RemoveEvent, + UpdateEvent, +} from 'typeorm'; +import { AuditTrail, AuditTrailAction } from './entities/audit-trail.entity'; +import { AuditContextService } from './audit-context'; + +/** + * Global TypeORM subscriber performing row-level change capture. + * + * For every entity except {@link AuditTrail} itself (to avoid recursion) it + * writes an `audit_trails` row with: + * - `actorId`: the authenticated user bound by `ActorContextInterceptor` + * (falls back to `system`), + * - `action`: insert / update / delete, + * - `before`: the row as loaded from the database (updates and deletes), + * - `after`: the persisted row (inserts and updates). + * + * Trail failures are logged and swallowed: auditing must never break the + * business operation it observes. + */ +@EventSubscriber() +export class AuditTrailSubscriber implements EntitySubscriberInterface { + constructor(private readonly dataSource: DataSource) {} + + /** + * Receives all entities; {@link shouldCapture} filters out the trail table. + */ + listenTo() { + return Object; + } + + async afterInsert(event: InsertEvent): Promise { + await this.capture( + event.manager, + event.metadata.targetName, + this.extractId(event.entity), + AuditTrailAction.INSERT, + null, + event.entity as Record, + ); + } + + async afterUpdate(event: UpdateEvent): Promise { + await this.capture( + event.manager, + event.metadata.targetName, + this.extractId(event.entity ?? event.databaseEntity), + AuditTrailAction.UPDATE, + (event.databaseEntity ?? null) as Record | null, + (event.entity ?? null) as Record | null, + ); + } + + async afterRemove(event: RemoveEvent): Promise { + await this.capture( + event.manager, + event.metadata.targetName, + this.extractId(event.entity ?? event.databaseEntity), + AuditTrailAction.DELETE, + (event.databaseEntity ?? null) as Record | null, + null, + ); + } + + /** Never audit the audit trail — it would recurse forever. */ + private shouldCapture(targetName?: string): boolean { + return targetName !== 'AuditTrail' && !!targetName; + } + + private extractId(entity: unknown): string { + const id = (entity as { id?: unknown })?.id; + return id === undefined || id === null ? 'unknown' : String(id); + } + + private async capture( + manager: Pick, + entityType: string | undefined, + entityId: string, + action: AuditTrailAction, + before: Record | null, + after: Record | null, + ): Promise { + if (!this.shouldCapture(entityType)) return; + + try { + const trail = manager.getRepository(AuditTrail).create({ + actorId: AuditContextService.getActor().id, + action, + entityType, + entityId, + before: this.snapshot(before), + after: this.snapshot(after), + }); + await manager + .getRepository(AuditTrail) + // jsonb snapshots don't fit TypeORM's strict insert partial type. + .insert(trail as never); + } catch (error) { + // Auditing is observability, not business logic — never rethrow. + console.error(`audit-trail: failed to record ${action} on ${entityType}`, error); + } + } + + /** Strips non-serializable noise (functions, class methods) from rows. */ + private snapshot(row: Record | null) { + return row ? (JSON.parse(JSON.stringify(row)) as Record) : null; + } +} diff --git a/src/database/base.repository.spec.ts b/src/database/base.repository.spec.ts new file mode 100644 index 0000000..929fc2f --- /dev/null +++ b/src/database/base.repository.spec.ts @@ -0,0 +1,62 @@ +import { BaseRepository } from '@app/common'; +import { BaseEntity } from '@app/common'; + +class Widget extends BaseEntity { + name: string; +} + +type FindAndCountMock = jest.Mock; + +/** + * The repository is exercised through a stubbed `findAndCount` so the + * pagination math and envelope construction can be verified without a + * database. + */ +describe('BaseRepository.findPaginated', () => { + const buildRepo = (total: number, rows: Widget[]) => { + const repo = Object.create(BaseRepository.prototype) as BaseRepository; + repo.findAndCount = jest.fn().mockResolvedValue([rows, total]) as FindAndCountMock; + return repo as BaseRepository & { findAndCount: FindAndCountMock }; + }; + + it('computes skip/take from the requested page', async () => { + const repo = buildRepo(55, []); + await repo.findPaginated({ page: 3, limit: 20 }); + + expect(repo.findAndCount).toHaveBeenCalledWith({ + skip: 40, + take: 20, + where: undefined, + order: { createdAt: 'DESC' }, + }); + }); + + it('returns the shared pagination envelope', async () => { + const rows = [{ id: 'w1' } as Widget]; + const result = await buildRepo(55, rows).findPaginated({ page: 2, limit: 20 }); + + expect(result.data).toEqual(rows); + expect(result.meta).toEqual({ total: 55, page: 2, limit: 20, totalPages: 3 }); + }); + + it('clamps page below one and limit above one hundred', async () => { + const repo = buildRepo(0, []); + await repo.findPaginated({ page: -5, limit: 5000 }); + + expect(repo.findAndCount).toHaveBeenCalledWith({ + skip: 0, + take: 100, + where: undefined, + order: { createdAt: 'DESC' }, + }); + }); + + it('applies defaults (page 1, limit 20) when omitted', async () => { + const repo = buildRepo(3, []); + await repo.findPaginated(); + + expect(repo.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ skip: 0, take: 20 }), + ); + }); +}); diff --git a/src/database/data-source.ts b/src/database/data-source.ts new file mode 100644 index 0000000..78d9e77 --- /dev/null +++ b/src/database/data-source.ts @@ -0,0 +1,20 @@ +import 'reflect-metadata'; +import { DataSource } from 'typeorm'; +import { CreateAuditTrailTable1724000000000 } from './migrations/1724000000000-CreateAuditTrailTable'; + +/** + * Standalone TypeORM data source used by the migration CLI + * (`npm run migration:run` etc.). It mirrors the runtime options from + * `src/config/database.config.ts` but always runs against PostgreSQL with + * explicit migrations instead of `synchronize`. + */ +export default new DataSource({ + 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, + migrations: [CreateAuditTrailTable1724000000000], + logging: process.env.DB_LOGGING === 'true', +}); diff --git a/src/database/database.module.ts b/src/database/database.module.ts new file mode 100644 index 0000000..ab63219 --- /dev/null +++ b/src/database/database.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { AuditTrailSubscriber } from './audit-trail.subscriber'; + +/** + * Persistence infrastructure shared by every feature module: + * - registers {@link AuditTrailSubscriber} so row changes are captured + * automatically (the TypeORM `DataSource` it needs comes from the global + * `TypeOrmModule.forRootAsync` registration in `AppModule`). + * + * Connection/pool configuration itself lives in `src/config/database.config.ts`. + */ +@Module({ + providers: [AuditTrailSubscriber], + exports: [AuditTrailSubscriber], +}) +export class DatabaseModule {} diff --git a/src/database/entities/audit-trail.entity.ts b/src/database/entities/audit-trail.entity.ts new file mode 100644 index 0000000..9de00db --- /dev/null +++ b/src/database/entities/audit-trail.entity.ts @@ -0,0 +1,74 @@ +import { + Check, + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +/** + * The mutation that produced this trail record. Constrained at the database + * level by a CHECK constraint (see the example migration) so only these + * values can ever be persisted. + */ +export enum AuditTrailAction { + INSERT = 'insert', + UPDATE = 'update', + DELETE = 'delete', +} + +/** + * Append-only row-level change capture for entities of interest. + * + * Unlike `modules/audit` (which records request/activity events), the audit + * *trail* is written automatically by {@link AuditTrailSubscriber} whenever a + * subscribed entity is inserted/updated/deleted, capturing who did it + * (`actorId`) and JSON snapshots of the row before and after the change. + * + * Immutability: rows are never updated or deleted from application code; in a + * deployed environment `UPDATE`/`DELETE` grants are revoked from the app role. + */ +@Entity('audit_trails') +@Index(['entityType', 'entityId', 'createdAt']) +@Index(['actorId', 'createdAt']) +// Example constraint: only known mutation kinds may be persisted. +@Check( + 'CHK_audit_trails_action', + '"action" IN (\'insert\', \'update\', \'delete\')', +) +export class AuditTrail { + @PrimaryGeneratedColumn('uuid') + id: string; + + /** Authenticated actor responsible for the change ('system' when none). */ + @Column({ type: 'varchar', length: 64, default: 'system' }) + actorId: string; + + @Column({ + type: 'varchar', + length: 16, + default: AuditTrailAction.INSERT, + }) + action: AuditTrailAction | string; + + /** Entity target name, e.g. `Wallet`, `User`. */ + @Column({ type: 'varchar', length: 64 }) + entityType: string; + + /** Primary key of the affected row (kept as text to support any PK type). */ + @Column({ type: 'varchar', length: 128 }) + entityId: string; + + /** Row snapshot before the change (null for inserts). */ + @Column({ type: 'jsonb', nullable: true }) + before: Record | null; + + /** Row snapshot after the change (null for deletes). */ + @Column({ type: 'jsonb', nullable: true }) + after: Record | null; + + /** Wall-clock time the change was captured. */ + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/database/migrations/1724000000000-CreateAuditTrailTable.ts b/src/database/migrations/1724000000000-CreateAuditTrailTable.ts new file mode 100644 index 0000000..ab87174 --- /dev/null +++ b/src/database/migrations/1724000000000-CreateAuditTrailTable.ts @@ -0,0 +1,78 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableCheck, + TableIndex, +} from 'typeorm'; + +/** + * Example versioned migration: creates the `audit_trails` table backing + * {@link AuditTrail}, including its indexes and CHECK constraint. + */ +export class CreateAuditTrailTable1724000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'audit_trails', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'gen_random_uuid()', + }, + { + name: 'actorId', + type: 'varchar', + length: '64', + default: "'system'", + }, + { + name: 'action', + type: 'varchar', + length: '16', + default: "'insert'", + }, + { name: 'entityType', type: 'varchar', length: '64' }, + { name: 'entityId', type: 'varchar', length: '128' }, + { name: 'before', type: 'jsonb', isNullable: true }, + { name: 'after', type: 'jsonb', isNullable: true }, + { + name: 'createdAt', + type: 'timestamptz', + default: 'now()', + }, + ], + checks: [ + new TableCheck({ + name: 'CHK_audit_trails_action', + expression: + `"action" IN ('insert', 'update', 'delete')`, + }), + ], + }), + true, + ); + + await queryRunner.createIndex( + 'audit_trails', + new TableIndex({ + name: 'IDX_audit_trails_entity_time', + columnNames: ['entityType', 'entityId', 'createdAt'], + }), + ); + + await queryRunner.createIndex( + 'audit_trails', + new TableIndex({ + name: 'IDX_audit_trails_actor_time', + columnNames: ['actorId', 'createdAt'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('audit_trails', true); + } +} diff --git a/src/database/transaction.helper.spec.ts b/src/database/transaction.helper.spec.ts new file mode 100644 index 0000000..9b6a9ce --- /dev/null +++ b/src/database/transaction.helper.spec.ts @@ -0,0 +1,54 @@ +import { DataSource, EntityManager } from 'typeorm'; +import { TransactionHelper } from '@app/common'; + +/** + * Verifies the helper delegates to `DataSource.transaction` with the supplied + * isolation level and that failures propagate (after TypeORM's rollback). + */ +describe('TransactionHelper', () => { + let dataSource: { transaction: jest.Mock }; + + beforeEach(() => { + dataSource = { transaction: jest.fn() }; + }); + + it('runs work inside a transaction with the requested isolation level', async () => { + dataSource.transaction.mockImplementation( + async ( + isolation: string | undefined, + work: (m: EntityManager) => Promise, + ) => work({} as EntityManager), + ); + + const helper = new TransactionHelper(dataSource as unknown as DataSource); + const outcome = await helper.run(async (tx) => `done:${!!tx}`, 'SERIALIZABLE'); + + expect(dataSource.transaction).toHaveBeenCalledWith('SERIALIZABLE', expect.any(Function)); + expect(outcome).toBe('done:true'); + }); + + it('propagates errors thrown by the work unit', async () => { + dataSource.transaction.mockImplementation( + async (_isolation: string | undefined, work: () => Promise) => + work(), + ); + + const helper = new TransactionHelper(dataSource as unknown as DataSource); + await expect( + helper.run(async () => { + throw new Error('constraint violated'); + }), + ).rejects.toThrow('constraint violated'); + }); + + it('joins an existing manager without opening a nested transaction', async () => { + const manager = {} as EntityManager; + const probe = jest.fn().mockResolvedValue(42); + + const result = await TransactionHelper.join(manager, probe); + + expect(result).toBe(42); + expect(probe).toHaveBeenCalledWith(manager); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); +});