From debfb645e07864f65229bb636b5daf31b180e0ad Mon Sep 17 00:00:00 2001 From: Anns Shahbaz Date: Mon, 8 Dec 2025 15:24:41 +0500 Subject: [PATCH 01/37] 1234567890 --- TRANSACTION_MANAGEMENT.md | 1777 +++++++++++++++++ apps/api/DEFAULT_TRANSACTIONS_GUIDE.md | 599 ++++++ apps/api/PRISMA_TRANSACTION_SETUP.md | 324 +++ apps/api/package.json | 5 +- apps/api/src/app.module.ts | 24 + .../src/common/base-transactional.service.ts | 23 + .../auto-transactional.decorator.ts | 147 ++ .../common/guards/transaction-check.guard.ts | 75 + .../common/testing/transaction-test.helper.ts | 99 + .../src/middleware/transaction.middleware.ts | 21 + apps/api/src/modules/crud/crud.router.ts | 2 + apps/api/src/modules/crud/crud.service.ts | 13 +- .../crud/repositories/crud.repository.ts | 16 +- apps/api/src/modules/prisma/index.ts | 4 + .../prisma/prisma-transaction.types.ts | 8 + pnpm-lock.yaml | 60 + 16 files changed, 3188 insertions(+), 9 deletions(-) create mode 100644 TRANSACTION_MANAGEMENT.md create mode 100644 apps/api/DEFAULT_TRANSACTIONS_GUIDE.md create mode 100644 apps/api/PRISMA_TRANSACTION_SETUP.md create mode 100644 apps/api/src/common/base-transactional.service.ts create mode 100644 apps/api/src/common/decorators/auto-transactional.decorator.ts create mode 100644 apps/api/src/common/guards/transaction-check.guard.ts create mode 100644 apps/api/src/common/testing/transaction-test.helper.ts create mode 100644 apps/api/src/middleware/transaction.middleware.ts create mode 100644 apps/api/src/modules/prisma/index.ts create mode 100644 apps/api/src/modules/prisma/prisma-transaction.types.ts diff --git a/TRANSACTION_MANAGEMENT.md b/TRANSACTION_MANAGEMENT.md new file mode 100644 index 0000000..531c055 --- /dev/null +++ b/TRANSACTION_MANAGEMENT.md @@ -0,0 +1,1777 @@ +# Database-Agnostic Transaction Management Guide + +## Overview + +This guide explains how to implement **declarative, database-agnostic transaction management** in this NestJS codebase. The solution allows you to automatically wrap entire request handlers in transactions using a simple decorator pattern, ensuring all database operations commit together or roll back on error—without manually writing transaction logic. + +## Table of Contents + +1. [Current State](#current-state) +2. [Solution Architecture](#solution-architecture) +3. [Implementation Steps](#implementation-steps) +4. [Usage Examples](#usage-examples) +5. [Advanced Patterns](#advanced-patterns) +6. [Testing Transactions](#testing-transactions) +7. [Troubleshooting](#troubleshooting) + +--- + +## Current State + +### Current Architecture + +``` +Controller → Service A → Repository A → Database (auto-commit) + → Service B → Repository B → Database (auto-commit) + → Service C → Repository C → Database (auto-commit) +``` + +**Problem:** Each database operation commits immediately. If Service C fails, Services A & B already committed, leaving the database in an inconsistent state. + +### Current Code Example + +```typescript +// apps/api/src/modules/crud/crud.service.ts +async createCrud(data: CreateCrudDto): Promise { + return this.crudRepository.create(data); // ✅ Commits immediately +} + +// If you call multiple services, there's no transaction: +async complexOperation(data: ComplexDto) { + await this.userService.create(data.user); // ✅ Commits + await this.profileService.create(data.profile); // ❌ Fails - user already saved! +} +``` + +--- + +## Solution Architecture + +### Desired Architecture + +``` +Controller (with @Transactional) + ↓ +Transaction Interceptor (starts transaction) + ↓ +Service A → Service B → Service C (all use same transaction) + ↓ +Transaction Interceptor (commits or rolls back) +``` + +### Technology Stack + +We'll use **`nestjs-cls`** (Continuation Local Storage) which provides: + +- ✅ **Database-agnostic** transaction management +- ✅ **Declarative** `@Transactional()` decorator +- ✅ **Automatic** commit/rollback +- ✅ **Context propagation** across service boundaries +- ✅ **Transaction isolation levels** (Prisma only) +- ✅ **Nested transaction** support with propagation strategies + +**Supported Adapters:** +- `@nestjs-cls/transactional-adapter-prisma` - For PostgreSQL (via Prisma) +- `@nestjs-cls/transactional-adapter-mongoose` - For MongoDB (via Mongoose) + +--- + +## Implementation Steps + +### Step 1: Install Dependencies + +```bash +pnpm add nestjs-cls @nestjs-cls/transactional @nestjs-cls/transactional-adapter-prisma + +# If using Mongoose transactions (requires replica set): +# pnpm add @nestjs-cls/transactional-adapter-mongoose +``` + +### Step 2: Configure ClsModule in AppModule + +Update `apps/api/src/app.module.ts`: + +```typescript +import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { ClsModule } from 'nestjs-cls'; +import { ClsPluginTransactional } from '@nestjs-cls/transactional'; +import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma'; +import { PrismaService } from './modules/prisma/prisma.service'; +// ... other imports + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: '.env', + }), + + // Add ClsModule with Transactional Plugin + ClsModule.forRoot({ + global: true, + middleware: { + mount: true, // Mount CLS middleware globally + generateId: true, // Generate request ID + idGenerator: (req: Request) => req.headers['x-request-id'] ?? crypto.randomUUID(), + }, + plugins: [ + new ClsPluginTransactional({ + imports: [PrismaModule], + adapter: new TransactionalAdapterPrisma({ + prismaInjectionToken: PrismaService, + }), + enableTransactionProxy: true, // Allows direct service injection + }), + ], + }), + + // ... rest of your imports + TRPCModule.forRoot({ + autoSchemaFile: '../../packages/trpc/src/server', + context: AppContext, + errorFormatter: trpcErrorFormatter, + }), + PrismaModule, + MongooseModule.forRoot(process.env.DATABASE_URL_MONGODB!), + AuthModule, + // ... other modules + ], + controllers: [], + providers: [AppContext], +}) +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(LoggerMiddleware).forRoutes('*'); + } +} +``` + +### Step 3: Update PrismaService (Optional Enhancement) + +Update `apps/api/src/modules/prisma/prisma.service.ts`: + +```typescript +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from '@repo/prisma-db'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + constructor() { + super({ + // Add logging for debugging transactions + log: [ + { emit: 'event', level: 'query' }, + { emit: 'event', level: 'error' }, + ], + // Global transaction defaults + transactionOptions: { + maxWait: 5000, // 5 seconds + timeout: 10000, // 10 seconds + }, + }); + + // Optional: Log queries in development + if (process.env.NODE_ENV === 'development') { + this.$on('query', (e) => { + console.log('Query: ' + e.query); + console.log('Duration: ' + e.duration + 'ms'); + }); + } + } + + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} +``` + +### Step 4: (Optional) Configure Mongoose for Transactions + +**Important:** MongoDB transactions require a **replica set**. For local development: + +```bash +# Update docker-compose.mongo.yml to use replica set +# See MongoDB replica set configuration below +``` + +Add to `apps/api/src/app.module.ts`: + +```typescript +import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter-mongoose'; +import { getConnectionToken } from '@nestjs/mongoose'; + +// In ClsModule plugins array, add: +plugins: [ + // Prisma adapter (primary) + new ClsPluginTransactional({ + imports: [PrismaModule], + adapter: new TransactionalAdapterPrisma({ + prismaInjectionToken: PrismaService, + }), + }), + // Mongoose adapter (secondary - if using MongoDB transactions) + new ClsPluginTransactional({ + imports: [MongooseModule], + adapter: new TransactionalAdapterMongoose({ + mongooseConnectionToken: getConnectionToken(), + }), + }), +], +``` + +--- + +## Usage Examples + +### Example 1: Simple Transactional Service Method + +```typescript +// apps/api/src/modules/crud/crud.service.ts +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Transactional } from '@nestjs-cls/transactional'; +import { CrudRepository } from './repositories/crud.repository'; + +@Injectable() +export class CrudService { + constructor(private readonly crudRepository: CrudRepository) {} + + // Simple operation - no transaction needed + async findAll(): Promise { + return this.crudRepository.find(); + } + + // Complex operation - wrap in transaction + @Transactional() + async createWithRelations(data: ComplexCrudDto): Promise { + // All these operations use the SAME transaction + const crud = await this.crudRepository.create(data.crud); + + // If this fails, crud.create will be rolled back automatically + const relation = await this.relationRepository.create({ + crudId: crud.id, + ...data.relation, + }); + + // If this fails, both operations above roll back + await this.auditRepository.log({ + action: 'CRUD_CREATED', + entityId: crud.id, + }); + + return crud; + } +} +``` + +**What happens:** +1. `@Transactional()` starts a transaction before the method executes +2. All repository calls use the same transaction context +3. If any operation throws an error, entire transaction rolls back +4. If all succeed, transaction commits automatically + +### Example 2: Cross-Service Transaction + +```typescript +// apps/api/src/modules/user/user.service.ts +import { Injectable } from '@nestjs/common'; +import { Transactional } from '@nestjs-cls/transactional'; +import { ProfileService } from '../profile/profile.service'; +import { EmailService } from '../email/email.service'; + +@Injectable() +export class UserService { + constructor( + private readonly userRepository: UserRepository, + private readonly profileService: ProfileService, + private readonly emailService: EmailService, + ) {} + + @Transactional() + async registerUser(data: RegisterDto): Promise { + // Step 1: Create user + const user = await this.userRepository.create({ + email: data.email, + name: data.name, + }); + + // Step 2: Create profile (different service, same transaction!) + const profile = await this.profileService.create({ + userId: user.id, + bio: data.bio, + avatar: data.avatar, + }); + + // Step 3: Send welcome email (non-transactional, runs outside transaction) + // Use @Transactional({ propagation: Propagation.NOT_SUPPORTED }) + // on emailService.sendWelcome() if it should run outside transaction + await this.emailService.sendWelcome(user.email); + + // If email fails, user & profile still roll back + // If you don't want email to affect transaction, handle separately + + return user; + } +} +``` + +### Example 3: Controller-Level Transaction (tRPC Router) + +```typescript +// apps/api/src/modules/crud/crud.router.ts +import { Injectable } from '@nestjs/common'; +import { Mutation, Router, UseMiddlewares, Input } from 'nestjs-trpc'; +import { Transactional } from '@nestjs-cls/transactional'; +import { AuthMiddleware } from '../auth/auth.middleware'; +import { CrudService } from './crud.service'; + +@Injectable() +@Router() +export class CrudRouter { + constructor(private readonly crudService: CrudService) {} + + @UseMiddlewares(AuthMiddleware) + @Mutation({ + input: ZComplexCrudRequest, + output: ZComplexCrudResponse, + }) + @Transactional() // Transaction starts here, covers entire request + async createComplexCrud( + @Input() req: ComplexCrudRequest, + ): Promise { + // Everything in this handler is transactional + const crud = await this.crudService.createCrud(req.crud); + const metadata = await this.crudService.createMetadata(req.metadata); + const tags = await this.crudService.addTags(crud.id, req.tags); + + // If ANY of these fail, ALL roll back + return { + success: true, + crud, + metadata, + tags, + }; + } +} +``` + +### Example 4: Transaction with Isolation Level + +```typescript +@Transactional({ + isolationLevel: 'Serializable', // Strictest isolation +}) +async transferBalance(fromId: string, toId: string, amount: number) { + const fromUser = await this.userRepository.findOne(fromId); + const toUser = await this.userRepository.findOne(toId); + + if (fromUser.balance < amount) { + throw new BadRequestException('Insufficient balance'); + } + + // Deduct from sender + await this.userRepository.update(fromId, { + balance: fromUser.balance - amount, + }); + + // Add to receiver + await this.userRepository.update(toId, { + balance: toUser.balance + amount, + }); +} +``` + +**Available Isolation Levels:** +- `ReadUncommitted` - Lowest isolation (fast, dirty reads possible) +- `ReadCommitted` - Default (good balance) +- `RepeatableRead` - Prevents non-repeatable reads +- `Serializable` - Highest isolation (slowest, prevents all anomalies) + +### Example 5: Manual Transaction Control (Advanced) + +```typescript +import { TransactionHost } from '@nestjs-cls/transactional'; +import { PrismaClient } from '@repo/prisma-db'; + +@Injectable() +export class AdvancedService { + constructor( + private readonly txHost: TransactionHost, + ) {} + + async manualTransaction() { + // Get the current transaction or regular client + const tx = this.txHost.tx as PrismaClient; + + // Use it directly + const user = await tx.user.create({ data: {...} }); + const profile = await tx.profile.create({ data: {...} }); + + return { user, profile }; + } +} +``` + +--- + +## Advanced Patterns + +### Transaction Propagation + +Control how nested `@Transactional()` methods behave: + +```typescript +import { Propagation } from '@nestjs-cls/transactional'; + +@Injectable() +export class OrderService { + constructor( + private readonly inventoryService: InventoryService, + private readonly paymentService: PaymentService, + ) {} + + @Transactional() + async createOrder(data: OrderDto) { + const order = await this.orderRepository.create(data); + + // Uses parent transaction (default) + await this.inventoryService.reserveItems(order.items); + + // Runs in separate transaction (independent) + await this.paymentService.processPayment(order.total); + + return order; + } +} + +@Injectable() +export class PaymentService { + // This runs in its OWN transaction, separate from parent + @Transactional({ propagation: Propagation.REQUIRES_NEW }) + async processPayment(amount: number) { + // Even if parent transaction rolls back, this commits independently + return this.paymentRepository.create({ amount }); + } +} +``` + +**Propagation Options:** + +| Value | Behavior | +|-------|----------| +| `REQUIRED` (default) | Use parent transaction, or create new if none exists | +| `REQUIRES_NEW` | Always create new transaction, suspend parent | +| `SUPPORTS` | Use parent transaction if exists, otherwise run without transaction | +| `NOT_SUPPORTED` | Run without transaction, suspend parent if exists | +| `MANDATORY` | Require parent transaction, throw error if none exists | +| `NEVER` | Run without transaction, throw error if parent exists | + +### Conditional Transactions + +```typescript +async conditionalCreate(data: CreateDto, useTransaction = true) { + if (useTransaction) { + return this.createWithTransaction(data); + } + return this.createWithoutTransaction(data); +} + +@Transactional() +private async createWithTransaction(data: CreateDto) { + // Transactional logic +} + +private async createWithoutTransaction(data: CreateDto) { + // Non-transactional logic +} +``` + +### Transaction Timeout + +```typescript +@Transactional({ + timeout: 5000, // 5 seconds max +}) +async longRunningOperation() { + // If this takes more than 5s, transaction rolls back with timeout error +} +``` + +### Read-Only Transactions (Optimization) + +```typescript +// PostgreSQL-specific optimization +@Transactional({ + isolationLevel: 'ReadCommitted', + // Add custom options via PrismaClient config +}) +async generateReport() { + // Multiple reads, no writes + // PostgreSQL can optimize read-only transactions +} +``` + +--- + +## Testing Transactions + +### Unit Tests + +```typescript +// crud.service.spec.ts +import { Test } from '@nestjs/testing'; +import { ClsModule } from 'nestjs-cls'; +import { ClsPluginTransactional } from '@nestjs-cls/transactional'; + +describe('CrudService', () => { + let service: CrudService; + let prisma: PrismaService; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + imports: [ + ClsModule.forRoot({ + plugins: [ + new ClsPluginTransactional({ + imports: [PrismaModule], + adapter: new TransactionalAdapterPrisma({ + prismaInjectionToken: PrismaService, + }), + }), + ], + }), + PrismaModule, + ], + providers: [CrudService, CrudRepository], + }).compile(); + + service = module.get(CrudService); + prisma = module.get(PrismaService); + }); + + it('should rollback on error', async () => { + const spy = jest.spyOn(prisma.crud, 'create'); + + await expect( + service.createWithInvalidData(invalidData) + ).rejects.toThrow(); + + // Verify transaction was rolled back + const count = await prisma.crud.count(); + expect(count).toBe(0); + }); +}); +``` + +### Integration Tests with Transaction Rollback + +```typescript +// Automatically rollback after each test +beforeEach(async () => { + await prisma.$transaction(async (tx) => { + // Run test inside transaction + // After test, transaction automatically rolls back + }); +}); +``` + +--- + +## Troubleshooting + +### Issue 1: "No transaction found in context" + +**Cause:** CLS middleware not mounted or service called outside request context. + +**Solution:** +```typescript +// Ensure ClsModule middleware is mounted +ClsModule.forRoot({ + middleware: { mount: true }, // ← Must be true + plugins: [...] +}) +``` + +### Issue 2: MongoDB transactions fail + +**Cause:** MongoDB requires replica set for transactions. + +**Solution:** Update `docker-compose.mongo.yml`: + +```yaml +services: + mongodb: + image: mongo:latest + command: ["--replSet", "rs0", "--bind_ip_all"] + environment: + MONGO_INITDB_ROOT_USERNAME: admin + MONGO_INITDB_ROOT_PASSWORD: admin123 + ports: + - "27017:27017" + healthcheck: + test: | + mongosh --eval " + try { + rs.status(); + } catch(e) { + rs.initiate({ + _id: 'rs0', + members: [{ _id: 0, host: 'localhost:27017' }] + }); + } + " + interval: 10s + timeout: 5s + retries: 5 +``` + +### Issue 3: Better Auth creating separate PrismaClient + +**Problem:** `apps/api/src/modules/auth/auth.ts` creates separate `new PrismaClient()` instead of using injected `PrismaService`. + +**Solution:** Refactor Better Auth to accept PrismaService: + +```typescript +// auth.module.ts +import { PrismaService } from '../prisma/prisma.service'; + +@Module({ + imports: [ + BetterAuthModule.forRootAsync({ + imports: [EmailModule, PrismaModule], + inject: [EmailService, BetterAuthLogger, PrismaService], + useFactory: ( + emailService: EmailService, + logger: BetterAuthLogger, + prisma: PrismaService, // ← Inject PrismaService + ) => ({ + auth: createBetterAuth(emailService, logger, prisma), + }), + }), + ], +}) +export class AuthModule {} + +// auth.ts +export const createBetterAuth = ( + emailService: EmailService, + logger: BetterAuthLogger, + prisma: PrismaService, // ← Accept as parameter +) => { + return betterAuth({ + database: prismaAdapter(prisma, { provider: 'postgresql' }), + // ... rest of config + }); +}; +``` + +### Issue 4: Transaction timeout + +**Cause:** Long-running operations exceed default 5-second timeout. + +**Solution:** +```typescript +@Transactional({ + timeout: 30000, // 30 seconds +}) +async importLargeDataset(data: any[]) { + // Long operation +} +``` + +### Issue 5: Nested transactions not working + +**Cause:** Using `Propagation.REQUIRES_NEW` without proper adapter support. + +**Solution:** Ensure adapter supports nested transactions: +- ✅ Prisma: Supports `REQUIRES_NEW` with separate `$transaction()` calls +- ⚠️ Mongoose: Limited nested transaction support + +--- + +## Performance Considerations + +### 1. Connection Pool Sizing + +```env +# .env +DATABASE_URL="postgresql://user:pass@host:5432/db?connection_limit=20&pool_timeout=10" +``` + +**Rule of thumb:** `connection_limit = (num_cpus * 2) + effective_spindle_count` + +For most apps: **10-20 connections** + +### 2. Transaction Duration + +**Best practices:** +- ✅ Keep transactions SHORT (< 1 second ideal) +- ✅ Fetch data BEFORE transaction +- ✅ Perform external API calls OUTSIDE transactions +- ❌ Don't do heavy computation inside transactions +- ❌ Don't call external services inside transactions + +**Example:** +```typescript +@Transactional() +async createOrder(data: OrderDto) { + // ❌ BAD: External API call inside transaction + const paymentResult = await stripe.charges.create(...); + await this.orderRepository.create({ ...data, paymentId: paymentResult.id }); +} + +// ✅ GOOD: External call outside transaction +async createOrder(data: OrderDto) { + const paymentResult = await stripe.charges.create(...); + + return this.saveOrder(data, paymentResult.id); +} + +@Transactional() +private async saveOrder(data: OrderDto, paymentId: string) { + return this.orderRepository.create({ ...data, paymentId }); +} +``` + +### 3. Isolation Level Tradeoffs + +| Level | Performance | Safety | Use Case | +|-------|-------------|--------|----------| +| Read Uncommitted | ⚡⚡⚡ Fastest | ⚠️ Lowest | Analytics, approximations | +| Read Committed | ⚡⚡ Fast | ✅ Good | General use (default) | +| Repeatable Read | ⚡ Medium | ✅✅ Better | Financial operations | +| Serializable | 🐌 Slowest | ✅✅✅ Best | Critical operations | + +--- + +## Migration Strategy + +### Phase 1: Identify Critical Operations + +Audit your codebase for operations that need transactions: + +```bash +# Find multi-step operations +grep -r "await.*Repository\." apps/api/src --include="*.service.ts" | grep -A 5 -B 5 "async" +``` + +**Candidates for transactions:** +- User registration (user + profile + verification) +- Order creation (order + items + inventory update) +- Payment processing (payment + order status + invoice) +- Data imports (multiple creates) + +### Phase 2: Gradual Rollout + +1. **Week 1:** Install dependencies, configure ClsModule +2. **Week 2:** Add `@Transactional()` to 1-2 critical endpoints +3. **Week 3:** Monitor logs, adjust timeouts if needed +4. **Week 4:** Expand to all multi-step operations + +### Phase 3: Validation + +```typescript +// Add logging to verify transactions work +@Transactional() +async createUser(data: CreateUserDto) { + console.log('Transaction started'); + try { + const user = await this.userRepository.create(data); + console.log('User created:', user.id); + + const profile = await this.profileRepository.create({...}); + console.log('Profile created:', profile.id); + + console.log('Transaction will commit'); + return user; + } catch (error) { + console.log('Transaction will rollback'); + throw error; + } +} +``` + +--- + +## Summary + +### Benefits of This Approach + +✅ **Declarative:** Just add `@Transactional()`, no boilerplate +✅ **Database-agnostic:** Works with Prisma, Mongoose, TypeORM +✅ **Automatic:** Commit/rollback handled for you +✅ **Context-aware:** Transaction propagates across services +✅ **Configurable:** Timeouts, isolation levels, propagation strategies +✅ **Testable:** Easy to mock and test + +### When to Use Transactions + +| Scenario | Use Transaction? | +|----------|------------------| +| Single read operation | ❌ No | +| Single create/update/delete | ❌ No (usually) | +| Multiple related writes | ✅ Yes | +| Cross-service operations | ✅ Yes | +| Financial operations | ✅ Yes | +| Data imports | ✅ Yes | +| Read-only operations | ❌ No (unless you need consistent snapshot) | + +### Next Steps + +1. **Install packages** (see Step 1) +2. **Configure ClsModule** in `app.module.ts` (see Step 2) +3. **Add `@Transactional()` to complex operations** (see Usage Examples) +4. **Test rollback behavior** (see Testing section) +5. **Monitor performance** and adjust timeouts + +--- + +## End-to-End Transaction Examples + +This section provides **complete, production-ready examples** showing the entire flow from tRPC router to repository with transactions, based on this codebase's architecture. + +### Example 1: Simple CRUD with Transaction (Single Service) + +This example shows how to add transactions to the existing CRUD module. + +#### Step 1: Update Repository (No changes needed) + +```typescript +// apps/api/src/modules/crud/repositories/crud.repository.ts +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +@Injectable() +export class CrudRepository { + constructor(private readonly prisma: PrismaService) {} + + async create(data: CreateCrudDto): Promise { + // This automatically uses the transaction from CLS context if available + return this.prisma.crud.create({ data }); + } + + async update(id: string, data: UpdateCrudDto): Promise { + return this.prisma.crud.update({ where: { id }, data }); + } + + async delete(id: string): Promise { + return this.prisma.crud.delete({ where: { id } }); + } +} +``` + +**Key Point:** Repository code doesn't change! `PrismaService` automatically uses transaction context when available. + +#### Step 2: Add @Transactional to Service (Optional) + +```typescript +// apps/api/src/modules/crud/crud.service.ts +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Transactional } from '@nestjs-cls/transactional'; +import { CrudRepository } from './repositories/crud.repository'; + +@Injectable() +export class CrudService { + constructor(private readonly crudRepository: CrudRepository) {} + + // Simple operation - no transaction decorator needed + async createCrud(data: CreateCrudDto): Promise { + return this.crudRepository.create(data); + } + + // Complex operation - add @Transactional + @Transactional() + async createCrudWithAudit(data: CreateCrudDto, userId: string): Promise { + // Step 1: Create the CRUD item + const crud = await this.crudRepository.create(data); + + // Step 2: Log audit trail (same transaction) + await this.auditRepository.log({ + action: 'CREATE', + entityType: 'CRUD', + entityId: crud.id, + userId, + timestamp: new Date(), + }); + + // If audit log fails, crud creation is also rolled back + return crud; + } +} +``` + +#### Step 3: Add @Transactional to tRPC Router + +```typescript +// apps/api/src/modules/crud/crud.router.ts +import { Input, Mutation, Router, UseMiddlewares } from 'nestjs-trpc'; +import { Transactional } from '@nestjs-cls/transactional'; +import { CrudService } from './crud.service'; +import { AuthMiddleware } from '../auth/auth.middleware'; +import * as CrudSchema from './schemas/crud.schema'; + +@Router({ alias: 'crud' }) +export class CrudRouter { + constructor(private readonly crudService: CrudService) {} + + // Option A: Transaction at router level (covers entire request) + @UseMiddlewares(AuthMiddleware) + @Mutation({ + input: ZCrudCreateRequest, + output: ZCrudCreateResponse, + }) + @Transactional() // ← Add this decorator + async createCrud( + @Input() req: CrudSchema.TCrudCreateRequest, + ): Promise { + const created = await this.crudService.createCrud(req); + return { + success: created != null, + id: created?.id, + message: created ? 'Item created successfully' : 'Failed to create item', + }; + } + + // Option B: Let service handle transaction (if service has @Transactional) + @UseMiddlewares(AuthMiddleware) + @Mutation({ + input: ZCrudUpdateRequest, + output: ZCrudUpdateResponse, + }) + async updateCrud( + @Input() req: CrudSchema.TCrudUpdateRequest, + ): Promise { + // No @Transactional here, service method handles it + const updated = await this.crudService.update(req.id, req.data); + return { + success: updated != null, + data: updated ?? undefined, + message: updated ? 'Item updated successfully' : 'Failed to update item', + }; + } +} +``` + +**Transaction Flow:** +``` +Client Request + ↓ +tRPC Router (@Transactional) + ↓ +[Transaction Starts] ← ClsModule intercepts + ↓ +CrudService.createCrud() + ↓ +CrudRepository.create() ← Uses transaction from CLS context + ↓ +PrismaService.crud.create() ← Executes in transaction + ↓ +[Transaction Commits] ← Automatic on success + ↓ +Response to Client +``` + +--- + +### Example 2: Multi-Service Transaction (User Registration) + +This example shows a complete user registration flow with profile creation across multiple services. + +#### Step 1: Create Schema/DTOs + +```typescript +// apps/api/src/modules/user/schemas/user.schema.ts +import { z } from 'zod'; + +export const ZRegisterUserRequest = z.object({ + email: z.string().email(), + name: z.string().min(2), + password: z.string().min(8), + profile: z.object({ + bio: z.string().optional(), + avatar: z.string().url().optional(), + phone: z.string().optional(), + }), +}); + +export type TRegisterUserRequest = z.infer; + +export const ZRegisterUserResponse = z.object({ + success: z.boolean(), + userId: z.string().optional(), + message: z.string(), +}); + +export type TRegisterUserResponse = z.infer; +``` + +#### Step 2: Create Repositories + +```typescript +// apps/api/src/modules/user/repositories/user.repository.ts +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +@Injectable() +export class UserRepository { + constructor(private readonly prisma: PrismaService) {} + + async create(data: CreateUserDto): Promise { + return this.prisma.user.create({ + data: { + email: data.email, + name: data.name, + // Note: In real app, hash password before storing + password: data.password, + }, + }); + } + + async findByEmail(email: string): Promise { + return this.prisma.user.findUnique({ where: { email } }); + } +} + +// apps/api/src/modules/profile/repositories/profile.repository.ts +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +@Injectable() +export class ProfileRepository { + constructor(private readonly prisma: PrismaService) {} + + async create(data: CreateProfileDto): Promise { + return this.prisma.profile.create({ + data: { + userId: data.userId, + bio: data.bio, + avatar: data.avatar, + phone: data.phone, + }, + }); + } +} +``` + +#### Step 3: Create Services + +```typescript +// apps/api/src/modules/user/user.service.ts +import { Injectable, BadRequestException } from '@nestjs/common'; +import { Transactional } from '@nestjs-cls/transactional'; +import { UserRepository } from './repositories/user.repository'; +import { ProfileService } from '../profile/profile.service'; +import * as bcrypt from 'bcrypt'; + +@Injectable() +export class UserService { + constructor( + private readonly userRepository: UserRepository, + private readonly profileService: ProfileService, + ) {} + + // This method orchestrates the entire registration with transaction + @Transactional() + async registerUser(data: RegisterUserDto): Promise { + // Step 1: Check if user already exists + const existingUser = await this.userRepository.findByEmail(data.email); + if (existingUser) { + throw new BadRequestException('User with this email already exists'); + } + + // Step 2: Hash password (outside DB operation, but inside transaction scope) + const hashedPassword = await bcrypt.hash(data.password, 10); + + // Step 3: Create user + const user = await this.userRepository.create({ + email: data.email, + name: data.name, + password: hashedPassword, + }); + + // Step 4: Create profile (different service, same transaction!) + await this.profileService.create({ + userId: user.id, + bio: data.profile.bio, + avatar: data.profile.avatar, + phone: data.profile.phone, + }); + + // If ANY of the above steps fail, EVERYTHING rolls back + return user; + } +} + +// apps/api/src/modules/profile/profile.service.ts +import { Injectable } from '@nestjs/common'; +import { ProfileRepository } from './repositories/profile.repository'; + +@Injectable() +export class ProfileService { + constructor(private readonly profileRepository: ProfileRepository) {} + + // No @Transactional needed here - it uses parent transaction + async create(data: CreateProfileDto): Promise { + return this.profileRepository.create(data); + } +} +``` + +#### Step 4: Create tRPC Router + +```typescript +// apps/api/src/modules/user/user.router.ts +import { Input, Mutation, Router } from 'nestjs-trpc'; +import { Transactional } from '@nestjs-cls/transactional'; +import { UserService } from './user.service'; +import { + ZRegisterUserRequest, + ZRegisterUserResponse, + TRegisterUserRequest, + TRegisterUserResponse, +} from './schemas/user.schema'; + +@Router({ alias: 'user' }) +export class UserRouter { + constructor(private readonly userService: UserService) {} + + @Mutation({ + input: ZRegisterUserRequest, + output: ZRegisterUserResponse, + }) + // Option A: Add @Transactional here (router level) + @Transactional() + async register( + @Input() req: TRegisterUserRequest, + ): Promise { + try { + const user = await this.userService.registerUser(req); + + return { + success: true, + userId: user.id, + message: 'User registered successfully', + }; + } catch (error) { + // Transaction automatically rolls back on error + return { + success: false, + message: error.message, + }; + } + } + + // Option B: Let service handle transaction (remove @Transactional from router) + // If UserService.registerUser has @Transactional, it will handle the transaction +} +``` + +**Complete Transaction Flow:** +``` +Client calls: trpc.user.register.mutate({ email, name, password, profile }) + ↓ +UserRouter.register() [@Transactional starts here] + ↓ +[TRANSACTION STARTS] ← CLS context created + ↓ +UserService.registerUser() + ├─→ UserRepository.findByEmail() ← Read in transaction + ├─→ bcrypt.hash() ← CPU work (outside DB) + ├─→ UserRepository.create() ← Write in transaction + └─→ ProfileService.create() + └─→ ProfileRepository.create() ← Write in SAME transaction + ↓ +[TRANSACTION COMMITS] ← All succeeded + ↓ +Return success response to client + +// If ProfileRepository.create() fails: + ↓ +[TRANSACTION ROLLS BACK] ← User creation also undone + ↓ +Return error response to client +``` + +--- + +### Example 3: Complex E-commerce Order Creation + +This demonstrates a real-world scenario with multiple related entities and validation. + +#### Complete File Structure: + +``` +apps/api/src/modules/ +├── order/ +│ ├── order.module.ts +│ ├── order.router.ts ← tRPC router with @Transactional +│ ├── order.service.ts ← Business logic +│ ├── repositories/ +│ │ ├── order.repository.ts +│ │ └── order-item.repository.ts +│ └── schemas/ +│ └── order.schema.ts +├── inventory/ +│ ├── inventory.service.ts ← Called by order service +│ └── repositories/ +│ └── inventory.repository.ts +└── notification/ + └── notification.service.ts ← Runs OUTSIDE transaction +``` + +#### Implementation: + +```typescript +// apps/api/src/modules/order/schemas/order.schema.ts +import { z } from 'zod'; + +export const ZCreateOrderRequest = z.object({ + customerId: z.string(), + items: z.array( + z.object({ + productId: z.string(), + quantity: z.number().min(1), + price: z.number().positive(), + }) + ).min(1), + shippingAddress: z.object({ + street: z.string(), + city: z.string(), + zipCode: z.string(), + country: z.string(), + }), +}); + +export type TCreateOrderRequest = z.infer; + +export const ZCreateOrderResponse = z.object({ + success: z.boolean(), + orderId: z.string().optional(), + orderNumber: z.string().optional(), + total: z.number().optional(), + message: z.string(), +}); + +export type TCreateOrderResponse = z.infer; +``` + +```typescript +// apps/api/src/modules/order/repositories/order.repository.ts +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +@Injectable() +export class OrderRepository { + constructor(private readonly prisma: PrismaService) {} + + async create(data: CreateOrderDto): Promise { + return this.prisma.order.create({ + data: { + customerId: data.customerId, + orderNumber: data.orderNumber, + total: data.total, + status: 'PENDING', + shippingAddress: data.shippingAddress, + }, + include: { + customer: true, + items: true, + }, + }); + } +} + +// apps/api/src/modules/order/repositories/order-item.repository.ts +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +@Injectable() +export class OrderItemRepository { + constructor(private readonly prisma: PrismaService) {} + + async createMany(orderId: string, items: CreateOrderItemDto[]): Promise { + const data = items.map((item) => ({ + orderId, + productId: item.productId, + quantity: item.quantity, + price: item.price, + })); + + await this.prisma.orderItem.createMany({ data }); + return this.prisma.orderItem.findMany({ where: { orderId } }); + } +} +``` + +```typescript +// apps/api/src/modules/inventory/inventory.service.ts +import { Injectable, BadRequestException } from '@nestjs/common'; +import { InventoryRepository } from './repositories/inventory.repository'; + +@Injectable() +export class InventoryService { + constructor(private readonly inventoryRepository: InventoryRepository) {} + + // This runs in parent transaction if called from @Transactional context + async reserveItems(items: { productId: string; quantity: number }[]): Promise { + for (const item of items) { + const inventory = await this.inventoryRepository.findByProductId(item.productId); + + if (!inventory || inventory.quantity < item.quantity) { + throw new BadRequestException( + `Insufficient stock for product ${item.productId}` + ); + } + + // Deduct inventory + await this.inventoryRepository.update(item.productId, { + quantity: inventory.quantity - item.quantity, + }); + } + } +} +``` + +```typescript +// apps/api/src/modules/notification/notification.service.ts +import { Injectable } from '@nestjs/common'; +import { Transactional, Propagation } from '@nestjs-cls/transactional'; + +@Injectable() +export class NotificationService { + // This runs OUTSIDE transaction (won't cause rollback if fails) + @Transactional({ propagation: Propagation.NOT_SUPPORTED }) + async sendOrderConfirmation(orderId: string, email: string): Promise { + // Send email via external service (Resend, SendGrid, etc.) + // Even if this fails, order is already committed + console.log(`Sending order confirmation for ${orderId} to ${email}`); + } +} +``` + +```typescript +// apps/api/src/modules/order/order.service.ts +import { Injectable } from '@nestjs/common'; +import { Transactional } from '@nestjs-cls/transactional'; +import { OrderRepository } from './repositories/order.repository'; +import { OrderItemRepository } from './repositories/order-item.repository'; +import { InventoryService } from '../inventory/inventory.service'; +import { NotificationService } from '../notification/notification.service'; + +@Injectable() +export class OrderService { + constructor( + private readonly orderRepository: OrderRepository, + private readonly orderItemRepository: OrderItemRepository, + private readonly inventoryService: InventoryService, + private readonly notificationService: NotificationService, + ) {} + + @Transactional() + async createOrder(data: CreateOrderDto): Promise { + // Step 1: Reserve inventory (validates stock availability) + // If any item is out of stock, this throws and transaction rolls back + await this.inventoryService.reserveItems(data.items); + + // Step 2: Calculate total + const total = data.items.reduce( + (sum, item) => sum + item.price * item.quantity, + 0 + ); + + // Step 3: Generate order number + const orderNumber = `ORD-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + // Step 4: Create order + const order = await this.orderRepository.create({ + customerId: data.customerId, + orderNumber, + total, + shippingAddress: JSON.stringify(data.shippingAddress), + }); + + // Step 5: Create order items + await this.orderItemRepository.createMany(order.id, data.items); + + // Step 6: Send notification (runs outside transaction) + // This won't cause rollback if it fails + await this.notificationService.sendOrderConfirmation( + order.id, + data.customerEmail + ); + + return order; + } +} +``` + +```typescript +// apps/api/src/modules/order/order.router.ts +import { Input, Mutation, Router, UseMiddlewares } from 'nestjs-trpc'; +import { Transactional } from '@nestjs-cls/transactional'; +import { OrderService } from './order.service'; +import { AuthMiddleware } from '../auth/auth.middleware'; +import { + ZCreateOrderRequest, + ZCreateOrderResponse, + TCreateOrderRequest, + TCreateOrderResponse, +} from './schemas/order.schema'; + +@Router({ alias: 'order' }) +export class OrderRouter { + constructor(private readonly orderService: OrderService) {} + + @UseMiddlewares(AuthMiddleware) + @Mutation({ + input: ZCreateOrderRequest, + output: ZCreateOrderResponse, + }) + @Transactional({ + timeout: 10000, // 10 seconds for complex operation + isolationLevel: 'ReadCommitted', + }) + async createOrder( + @Input() req: TCreateOrderRequest, + ): Promise { + try { + const order = await this.orderService.createOrder(req); + + return { + success: true, + orderId: order.id, + orderNumber: order.orderNumber, + total: order.total, + message: 'Order created successfully', + }; + } catch (error) { + return { + success: false, + message: error.message || 'Failed to create order', + }; + } + } +} +``` + +**Complete Transaction Flow with Rollback Scenarios:** + +``` +Client: trpc.order.createOrder.mutate({ customerId, items, shippingAddress }) + ↓ +OrderRouter.createOrder() [@Transactional starts] + ↓ +[TRANSACTION STARTS - timeout: 10s, isolation: ReadCommitted] + ↓ +OrderService.createOrder() + ├─→ InventoryService.reserveItems() + │ ├─→ Check product 1 stock ← READ in transaction + │ ├─→ Deduct product 1 stock ← WRITE in transaction + │ ├─→ Check product 2 stock ← READ in transaction + │ └─→ Deduct product 2 stock ← WRITE in transaction + │ └─→ ❌ Out of stock! BadRequestException thrown + │ ↓ + │ [TRANSACTION ROLLS BACK] ← All inventory changes undone + │ ↓ + │ Return error: "Insufficient stock for product XYZ" + + // If inventory check passes: + ├─→ Calculate total (CPU work) + ├─→ Generate order number (CPU work) + ├─→ OrderRepository.create() ← WRITE in transaction + ├─→ OrderItemRepository.createMany() ← WRITE in transaction + │ └─→ ❌ Database constraint violation (e.g., invalid productId) + │ ↓ + │ [TRANSACTION ROLLS BACK] ← Order creation AND inventory deductions undone + │ ↓ + │ Return error: "Failed to create order items" + + // If all DB operations succeed: + └─→ NotificationService.sendOrderConfirmation() ← OUTSIDE transaction + └─→ ❌ Email service fails + ↓ + [TRANSACTION STILL COMMITS] ← Order is saved, email failed separately + ↓ + Log error, but return success (order created, notification failed) + ↓ +[TRANSACTION COMMITS] ← All succeeded + ↓ +Return: { success: true, orderId: "...", orderNumber: "ORD-...", total: 299.99 } +``` + +--- + +### Example 4: Accessing Transaction Directly in Repositories + +Sometimes you need direct access to the transaction client (e.g., for raw queries or complex operations). + +```typescript +// apps/api/src/modules/analytics/repositories/analytics.repository.ts +import { Injectable } from '@nestjs/common'; +import { TransactionHost } from '@nestjs-cls/transactional'; +import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma'; +import { PrismaClient } from '@repo/prisma-db'; + +@Injectable() +export class AnalyticsRepository { + constructor( + private readonly txHost: TransactionHost, + ) {} + + async generateReportWithRawSQL(startDate: Date, endDate: Date): Promise { + // Get transaction client or fallback to regular client + const prisma = (this.txHost.tx ?? this.txHost.prisma) as PrismaClient; + + // Execute raw SQL within transaction context + return prisma.$queryRaw` + SELECT + DATE(created_at) as date, + COUNT(*) as order_count, + SUM(total) as revenue + FROM orders + WHERE created_at BETWEEN ${startDate} AND ${endDate} + GROUP BY DATE(created_at) + ORDER BY date DESC + `; + } +} +``` + +--- + +### Example 5: Testing Transactions with Rollback + +```typescript +// apps/api/src/modules/order/order.service.spec.ts +import { Test } from '@nestjs/testing'; +import { ClsModule } from 'nestjs-cls'; +import { ClsPluginTransactional } from '@nestjs-cls/transactional'; +import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma'; +import { PrismaService } from '../prisma/prisma.service'; +import { OrderService } from './order.service'; + +describe('OrderService - Transaction Tests', () => { + let service: OrderService; + let prisma: PrismaService; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + imports: [ + ClsModule.forRoot({ + global: true, + middleware: { mount: true }, + plugins: [ + new ClsPluginTransactional({ + imports: [PrismaModule], + adapter: new TransactionalAdapterPrisma({ + prismaInjectionToken: PrismaService, + }), + }), + ], + }), + PrismaModule, + OrderModule, + InventoryModule, + ], + providers: [OrderService, /* other providers */], + }).compile(); + + service = module.get(OrderService); + prisma = module.get(PrismaService); + }); + + it('should rollback entire order when inventory is insufficient', async () => { + // Setup: Create product with limited stock + const product = await prisma.product.create({ + data: { name: 'Test Product', price: 100 }, + }); + await prisma.inventory.create({ + data: { productId: product.id, quantity: 5 }, + }); + + // Test: Try to order more than available + await expect( + service.createOrder({ + customerId: 'test-customer', + items: [{ productId: product.id, quantity: 10, price: 100 }], + shippingAddress: { /* ... */ }, + }) + ).rejects.toThrow('Insufficient stock'); + + // Verify: No order created + const orderCount = await prisma.order.count(); + expect(orderCount).toBe(0); + + // Verify: Inventory unchanged + const inventory = await prisma.inventory.findUnique({ + where: { productId: product.id }, + }); + expect(inventory.quantity).toBe(5); // Still 5, not deducted + }); + + it('should commit order and deduct inventory on success', async () => { + const product = await prisma.product.create({ + data: { name: 'Test Product', price: 100 }, + }); + await prisma.inventory.create({ + data: { productId: product.id, quantity: 10 }, + }); + + const order = await service.createOrder({ + customerId: 'test-customer', + items: [{ productId: product.id, quantity: 3, price: 100 }], + shippingAddress: { /* ... */ }, + }); + + // Verify: Order created + expect(order.id).toBeDefined(); + expect(order.total).toBe(300); + + // Verify: Inventory deducted + const inventory = await prisma.inventory.findUnique({ + where: { productId: product.id }, + }); + expect(inventory.quantity).toBe(7); // 10 - 3 = 7 + }); +}); +``` + +--- + +### Where to Place @Transactional + +| Layer | When to Use | Example | +|-------|-------------|---------| +| **tRPC Router** | When you want to wrap the entire request handler | `@Transactional()` on `createOrder()` | +| **Service** | When the service orchestrates multiple operations | `@Transactional()` on `OrderService.createOrder()` | +| **Repository** | ❌ Never | Repositories use transaction from context automatically | + +**Best Practice:** Put `@Transactional()` at the **highest level** that needs atomicity: + +```typescript +// ✅ GOOD: Transaction at router level (covers entire request) +@Router() +export class OrderRouter { + @Mutation() + @Transactional() + async createOrder() { + await this.orderService.create(); // Uses transaction + await this.emailService.sendEmail(); // Uses transaction + } +} + +// ⚠️ OKAY: Transaction at service level (if router doesn't need transaction) +export class OrderService { + @Transactional() + async create() { + await this.orderRepo.create(); + await this.itemRepo.createMany(); + } +} + +// ❌ BAD: Multiple transactions (defeats the purpose) +export class OrderService { + @Transactional() + async createOrder() { /* ... */ } + + @Transactional() + async createItems() { /* ... */ } // Separate transaction! +} +``` + +--- + +### Common Pitfalls and Solutions + +#### Pitfall 1: External API Calls Inside Transactions + +```typescript +// ❌ BAD: Payment API inside transaction +@Transactional() +async createOrder(data: OrderDto) { + const order = await this.orderRepo.create(data); + const payment = await stripe.charges.create(...); // External API! + await this.orderRepo.update(order.id, { paymentId: payment.id }); +} + +// ✅ GOOD: External API outside transaction +async createOrder(data: OrderDto) { + // Step 1: Process payment first (outside transaction) + const payment = await stripe.charges.create(...); + + // Step 2: Save to DB (inside transaction) + return this.saveOrder(data, payment.id); +} + +@Transactional() +private async saveOrder(data: OrderDto, paymentId: string) { + return this.orderRepo.create({ ...data, paymentId }); +} +``` + +#### Pitfall 2: Forgetting to Throw Errors + +```typescript +// ❌ BAD: Swallowing errors prevents rollback +@Transactional() +async createOrder(data: OrderDto) { + try { + await this.inventoryService.reserve(data.items); + } catch (error) { + console.log('Inventory failed'); // Transaction won't rollback! + return null; + } +} + +// ✅ GOOD: Let errors propagate +@Transactional() +async createOrder(data: OrderDto) { + // Just let it throw - transaction will rollback automatically + await this.inventoryService.reserve(data.items); + return this.orderRepo.create(data); +} +``` + +#### Pitfall 3: Using Wrong Propagation + +```typescript +// ❌ BAD: Child service creates separate transaction +export class OrderService { + @Transactional() + async createOrder() { + await this.itemService.createItems(); // Separate transaction! + } +} + +export class ItemService { + @Transactional({ propagation: Propagation.REQUIRES_NEW }) // Wrong! + async createItems() { /* ... */ } +} + +// ✅ GOOD: Child uses parent transaction (default) +export class ItemService { + async createItems() { // No decorator needed + // Automatically uses parent transaction + } +} +``` + +--- + +## References + +- [nestjs-cls Documentation](https://papooch.github.io/nestjs-cls/) +- [Transactional Plugin](https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional) +- [Prisma Transactions](https://www.prisma.io/docs/orm/prisma-client/queries/transactions) +- [MongoDB Transactions](https://www.mongodb.com/docs/manual/core/transactions/) +- [NestJS Interceptors](https://docs.nestjs.com/interceptors) + +--- + +**Created:** 2025-12-08 +**Last Updated:** 2025-12-08 +**Maintainer:** Binary Exploits LLC diff --git a/apps/api/DEFAULT_TRANSACTIONS_GUIDE.md b/apps/api/DEFAULT_TRANSACTIONS_GUIDE.md new file mode 100644 index 0000000..ecb82bc --- /dev/null +++ b/apps/api/DEFAULT_TRANSACTIONS_GUIDE.md @@ -0,0 +1,599 @@ +# Making Transactions Default in NestJS + tRPC + +## ⚠️ Important Discovery + +**tRPC Router Middleware with `@Transactional()` DOES NOT WORK** because tRPC's middleware execution doesn't properly propagate the CLS (AsyncLocalStorage) context where transactions are stored. + +## ✅ Correct Approach + +**Put `@Transactional()` on SERVICE METHODS**, not on routers or middleware. + +--- + +## Table of Contents + +1. [Correct Approach: Service-Level Transactions](#correct-approach-service-level-transactions) +2. [Why Router Middleware Doesn't Work](#why-router-middleware-doesnt-work) +3. [Optional: Base Service Class](#optional-base-service-class) +4. [Best Practices](#best-practices) + +--- + +## Correct Approach: Service-Level Transactions + +### Implementation (Current & Working) + +Put `@Transactional()` directly on service methods that need transactions: + +```typescript +// crud.service.ts +import { Injectable } from '@nestjs/common'; +import { Transactional } from '@nestjs-cls/transactional'; + +@Injectable() +export class CrudService { + constructor(private readonly crudRepository: CrudRepository) {} + + // ✅ Add @Transactional to each write method + @Transactional() + async createCrud(data: CreateCrudDto): Promise { + const created = await this.crudRepository.create(data); + + // If any operation fails, entire transaction rolls back + await this.auditRepository.log({ action: 'CREATED', id: created.id }); + + return created; + } + + // ✅ Transactions on updates + @Transactional() + async update(id: string, data: UpdateCrudDto): Promise { + const updated = await this.crudRepository.update(id, data); + if (!updated) throw new NotFoundException(`Crud with id ${id} not found`); + return updated; + } + + // ✅ Transactions on deletes + @Transactional() + async delete(id: string): Promise { + const deleted = await this.crudRepository.delete(id); + if (!deleted) throw new NotFoundException(`Crud with id ${id} not found`); + return deleted; + } + + // ❌ No transaction needed for reads + async findAll(): Promise { + return this.crudRepository.find(); + } + + async findOne(id: string): Promise { + const crud = await this.crudRepository.findOne(id); + if (!crud) throw new NotFoundException(`Crud with id ${id} not found`); + return crud; + } +} +``` + +### Transaction Flow + +``` +Client Request + ↓ +tRPC Router Method + ↓ +Service Method [@Transactional starts here] + ↓ +[TRANSACTION STARTS] + ↓ +Repository Method (uses txHost.tx) + ↓ +Database Operations (in transaction) + ↓ +[TRANSACTION COMMITS or ROLLS BACK] + ↓ +Response to Client +``` + +--- + +## Why Router Middleware Doesn't Work + +### What We Tried (Didn't Work) + +Apply `TransactionMiddleware` at the **Router class level** to make all procedures in that router transactional by default. + +### Implementation + +#### 1. Transaction Middleware (`middleware/transaction.middleware.ts`) + +```typescript +import { Injectable } from '@nestjs/common'; +import { Transactional } from '@nestjs-cls/transactional'; +import { MiddlewareOptions, TRPCMiddleware } from 'nestjs-trpc'; + +/** + * Global transaction middleware for tRPC + * Automatically wraps all mutations in transactions + */ +@Injectable() +export class TransactionMiddleware implements TRPCMiddleware { + @Transactional() + async use(opts: MiddlewareOptions): Promise { + const { next } = opts; + + // The @Transactional decorator handles everything + return next(); + } +} +``` + +#### 2. Apply to Router (`crud.router.ts`) + +```typescript +import { TransactionMiddleware } from '../../middleware/transaction.middleware'; + +// Apply to ALL procedures in this router +@UseMiddlewares(TransactionMiddleware) +@Router({ alias: 'crud' }) +export class CrudRouter { + constructor(private readonly crudService: CrudService) {} + + // ✅ Automatically transactional (no decorator needed) + @Mutation({ input: ZCreateRequest, output: ZCreateResponse }) + async createCrud(@Input() req: CreateRequest) { + return this.crudService.createCrud(req); + } + + // ✅ Automatically transactional + @Mutation({ input: ZUpdateRequest, output: ZUpdateResponse }) + async updateCrud(@Input() req: UpdateRequest) { + return this.crudService.update(req.id, req.data); + } + + // ✅ Also transactional (but reads don't need transactions usually) + @Query({ input: ZFindAllRequest, output: ZFindAllResponse }) + async findAll(@Input() req: FindAllRequest) { + return this.crudService.findAll(); + } +} +``` + +#### 3. Register Middleware in Module (`crud.module.ts`) + +```typescript +@Module({ + imports: [PrismaModule], + providers: [ + CrudRepository, + CrudService, + CrudRouter, + TransactionMiddleware // ← Register here + ], + exports: [CrudRepository, CrudService], +}) +export class CrudModule {} +``` + +#### 4. Service (No Decorators Needed) + +```typescript +@Injectable() +export class CrudService { + constructor(private readonly crudRepository: CrudRepository) {} + + // ✅ Automatically transactional (router middleware handles it) + async createCrud(data: CreateCrudDto): Promise { + const created = await this.crudRepository.create(data); + + // If this fails, transaction rolls back automatically + await this.auditRepository.log({ action: 'CREATED', id: created.id }); + + return created; + } + + // ✅ Also transactional + async update(id: string, data: UpdateCrudDto): Promise { + return this.crudRepository.update(id, data); + } + + // Reads don't need transactions, but they're harmless + async findAll(): Promise { + return this.crudRepository.find(); + } +} +``` + +### Execution Flow + +``` +Client Request + ↓ +tRPC Router → TransactionMiddleware [@Transactional] + ↓ +[TRANSACTION STARTS] + ↓ +Router Method (createCrud) + ↓ +Service Method (no decorator needed) + ↓ +Repository Method (uses txHost.tx) + ↓ +Database Operations (in transaction) + ↓ +[TRANSACTION COMMITS or ROLLS BACK] + ↓ +Response to Client +``` + +### Pros & Cons + +✅ **Pros:** +- No decorators needed on services or methods +- Clean, DRY code +- Easy to apply to entire router at once +- Middleware executes BEFORE AuthMiddleware (if both are applied) + +❌ **Cons:** +- Applies to ALL procedures (including Queries that don't need transactions) +- One transaction per router procedure (can't have multiple independent transactions in same handler) + +--- + +## Approach 2: Custom Base Service Class + +Create a base service class that automatically wraps all methods in transactions. + +### Implementation + +#### 1. Base Service (`base/transactional-base.service.ts`) + +```typescript +import { Injectable } from '@nestjs/common'; +import { Transactional } from '@nestjs-cls/transactional'; + +/** + * Base service that makes all methods transactional by default + * Extend this class to get automatic transaction support + */ +@Injectable() +export abstract class TransactionalBaseService { + /** + * Override this method in subclasses to customize transaction behavior + */ + protected get transactionOptions() { + return { + timeout: 10000, // 10 seconds + isolationLevel: 'ReadCommitted' as const, + }; + } + + /** + * Wrap any method call in a transaction + */ + @Transactional() + protected async withTransaction(fn: () => Promise): Promise { + return fn(); + } +} +``` + +#### 2. Service Extends Base + +```typescript +@Injectable() +export class CrudService extends TransactionalBaseService { + constructor(private readonly crudRepository: CrudRepository) { + super(); + } + + // ❌ This approach doesn't work well - need manual wrapping + async createCrud(data: CreateCrudDto): Promise { + return this.withTransaction(async () => { + const created = await this.crudRepository.create(data); + await this.auditRepository.log({ action: 'CREATED' }); + return created; + }); + } +} +``` + +### Pros & Cons + +⚠️ **Not Recommended:** +- Still requires manual wrapping with `withTransaction()` +- More boilerplate than router middleware approach +- Doesn't truly make things "automatic" + +--- + +## Approach 3: Global Interceptor + +Use NestJS interceptor to wrap ALL requests in transactions (not tRPC-specific). + +### Implementation + +#### 1. Global Transaction Interceptor + +```typescript +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { Transactional } from '@nestjs-cls/transactional'; + +@Injectable() +export class GlobalTransactionInterceptor implements NestInterceptor { + @Transactional() + intercept(context: ExecutionContext, next: CallHandler): Observable { + // Simply pass through - @Transactional handles the rest + return next.handle(); + } +} +``` + +#### 2. Register Globally in `main.ts` + +```typescript +import { GlobalTransactionInterceptor } from './interceptors/transaction.interceptor'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + // Apply globally to ALL requests + app.useGlobalInterceptors(new GlobalTransactionInterceptor()); + + await app.listen(4000); +} +``` + +### Pros & Cons + +✅ **Pros:** +- Truly global - applies to ALL requests (tRPC, REST, GraphQL) +- No need to modify routers or services + +❌ **Cons:** +- Too broad - wraps EVERYTHING (including health checks, static files) +- May cause performance issues for read-heavy operations +- Harder to opt-out for specific endpoints +- Doesn't work well with tRPC's middleware system + +⚠️ **Not Recommended for tRPC apps** + +--- + +## Comparison & Recommendations + +| Approach | Granularity | Ease of Use | Performance | Opt-Out | +|----------|-------------|-------------|-------------|---------| +| **Router Middleware** | Per Router | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | Easy | +| Base Service | Per Method | ⭐⭐ | ⭐⭐⭐⭐⭐ | Manual | +| Global Interceptor | All Requests | ⭐⭐⭐⭐ | ⭐⭐ | Hard | + +### 🏆 **Recommended: Router-Level Middleware** + +**Why?** +1. ✅ Clean code - no decorators on services +2. ✅ Granular control - apply per router +3. ✅ Easy opt-out - use `@UseMiddlewares()` selectively +4. ✅ Works naturally with tRPC +5. ✅ Good performance - only applies to tRPC procedures + +--- + +## Optimizing for Read vs Write + +If you want transactions **only for mutations** (not queries), create two middlewares: + +### Mutation-Only Transactions + +```typescript +// middleware/mutation-transaction.middleware.ts +@Injectable() +export class MutationTransactionMiddleware implements TRPCMiddleware { + @Transactional() + async use(opts: MiddlewareOptions): Promise { + return opts.next(); + } +} + +// Apply only to mutations +@Router({ alias: 'crud' }) +export class CrudRouter { + // ✅ Transactional + @UseMiddlewares(MutationTransactionMiddleware) + @Mutation({ input: ZCreateRequest, output: ZCreateResponse }) + async createCrud(@Input() req: CreateRequest) { + return this.crudService.createCrud(req); + } + + // ✅ No transaction (faster for reads) + @Query({ input: ZFindAllRequest, output: ZFindAllResponse }) + async findAll(@Input() req: FindAllRequest) { + return this.crudService.findAll(); + } +} +``` + +**But:** Router-level middleware already applies to all, so just use: + +```typescript +@UseMiddlewares(TransactionMiddleware) // ← All procedures +@Router({ alias: 'crud' }) +``` + +Queries in transactions are fine - they just use a read-only snapshot. + +--- + +## How to Opt-Out + +If you need a specific procedure to **NOT use transactions**: + +### Method 1: Don't Apply Middleware + +```typescript +@Router({ alias: 'crud' }) +export class CrudRouter { + // ✅ Transactional + @UseMiddlewares(TransactionMiddleware, AuthMiddleware) + @Mutation() + async create() { /* ... */ } + + // ❌ Not transactional (no TransactionMiddleware) + @UseMiddlewares(AuthMiddleware) + @Mutation() + async updateLargeFile() { + // Long-running operation, don't hold transaction open + } +} +``` + +### Method 2: Use Propagation.NOT_SUPPORTED + +```typescript +@Injectable() +export class FileService { + // This runs OUTSIDE any parent transaction + @Transactional({ propagation: Propagation.NOT_SUPPORTED }) + async uploadLargeFile(file: Buffer): Promise { + // External service call - don't need transaction + await s3.upload(file); + } +} +``` + +--- + +## Testing Default Transactions + +### Test 1: Verify Rollback Works + +```typescript +// crud.service.ts +async createCrud(data: CreateCrudDto): Promise { + const created = await this.crudRepository.create(data); + + throw new Error('Test rollback'); // ← Simulate error + + return created; +} +``` + +**Expected:** +- Error thrown ✅ +- Database has NO new record ✅ (rolled back) + +### Test 2: Verify Normal Operation + +```typescript +// Remove the error +async createCrud(data: CreateCrudDto): Promise { + return this.crudRepository.create(data); +} +``` + +**Expected:** +- Record created ✅ +- Database has new record ✅ + +--- + +## Performance Considerations + +### Transaction Overhead + +Each transaction has a small overhead (~1-5ms). For read-heavy apps, consider: + +1. **Skip transactions for Queries** (if using mutation-only approach) +2. **Connection pool sizing** - ensure `connection_limit` is adequate +3. **Transaction timeout** - keep short (5-10 seconds max) + +### Recommended Settings + +```typescript +// In TransactionMiddleware +@Transactional({ + timeout: 10000, // 10 seconds max + isolationLevel: 'ReadCommitted', // Default (fastest) +}) +async use(opts: MiddlewareOptions) { + return opts.next(); +} +``` + +--- + +## Migration Guide + +### Before (Manual Decorators) + +```typescript +@Injectable() +export class UserService { + @Transactional() // ← Remove this + async register(data: RegisterDto) { + const user = await this.userRepo.create(data.user); + const profile = await this.profileRepo.create(data.profile); + return { user, profile }; + } + + @Transactional() // ← Remove this + async update(id: string, data: UpdateDto) { + return this.userRepo.update(id, data); + } +} +``` + +### After (Router Middleware) + +```typescript +// 1. Add middleware to router +@UseMiddlewares(TransactionMiddleware) +@Router({ alias: 'user' }) +export class UserRouter { + // All mutations automatically transactional +} + +// 2. Remove decorators from service +@Injectable() +export class UserService { + // ✅ Automatic transaction (no decorator needed) + async register(data: RegisterDto) { + const user = await this.userRepo.create(data.user); + const profile = await this.profileRepo.create(data.profile); + return { user, profile }; + } + + // ✅ Automatic transaction + async update(id: string, data: UpdateDto) { + return this.userRepo.update(id, data); + } +} +``` + +--- + +## Summary + +### What You Get + +✅ **All mutations are transactional by default** +✅ **No need to add `@Transactional()` to services** +✅ **Automatic rollback on errors** +✅ **Clean, maintainable code** +✅ **Easy to opt-out when needed** + +### What to Remember + +1. Apply `@UseMiddlewares(TransactionMiddleware)` at **Router level** +2. Register `TransactionMiddleware` in **module providers** +3. Remove `@Transactional()` from **service methods** +4. Use `Propagation.NOT_SUPPORTED` to **opt-out** specific methods + +--- + +**Last Updated:** 2025-12-08 diff --git a/apps/api/PRISMA_TRANSACTION_SETUP.md b/apps/api/PRISMA_TRANSACTION_SETUP.md new file mode 100644 index 0000000..1b0b529 --- /dev/null +++ b/apps/api/PRISMA_TRANSACTION_SETUP.md @@ -0,0 +1,324 @@ +# Prisma Transaction Setup - Custom Client Type + +## Overview + +This project uses a **custom PrismaService** (which extends `PrismaClient`) instead of the default Prisma client. To make transactions work properly with `nestjs-cls`, we need to configure the adapter with our custom type. + +## Type Alias Pattern + +To avoid verbose type annotations, we've created a **type alias** for the transaction adapter. + +### Files Structure + +``` +apps/api/src/modules/prisma/ +├── prisma.service.ts # Custom PrismaService (extends PrismaClient) +├── prisma.module.ts # Module exports +├── prisma-transaction.types.ts # Type alias for transactions +``` + +--- + +## Implementation + +### 1. Type Alias (`prisma-transaction.types.ts`) + +```typescript +import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma'; +import { PrismaService } from './prisma.service'; + +/** + * Type alias for the Prisma transaction adapter with custom PrismaService + * This avoids verbose type annotations throughout the codebase + */ +export type PrismaTransactionAdapter = TransactionalAdapterPrisma; +``` + +**Why?** Without this, every repository would need: +```typescript +// ❌ VERBOSE (without alias) +constructor( + private readonly txHost: TransactionHost> +) {} + +// ✅ CLEAN (with alias) +constructor( + private readonly txHost: TransactionHost +) {} +``` + +--- + +### 2. PrismaModule Export (`prisma.module.ts`) + +```typescript +import { Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} + +// Re-export transaction types for convenience +export * from './prisma-transaction.types'; +``` + +This allows clean imports: +```typescript +// ✅ Import both from one place +import { PrismaService, PrismaTransactionAdapter } from '../../prisma/prisma.module'; +``` + +--- + +### 3. Repository Pattern (`crud.repository.ts`) + +```typescript +import { Injectable } from '@nestjs/common'; +import { TransactionHost } from '@nestjs-cls/transactional'; +import { PrismaService, PrismaTransactionAdapter } from '../../prisma/prisma.module'; + +@Injectable() +export class CrudRepository { + constructor( + private readonly txHost: TransactionHost, + ) {} + + // Helper to get the Prisma client (transactional or regular) + private get prisma(): PrismaService { + return this.txHost.tx as PrismaService; + } + + async create(data: CreateDto) { + // Automatically uses transaction context when available + return this.prisma.crud.create({ data }); + } +} +``` + +**Key Points:** +- ✅ Inject `TransactionHost` (not `PrismaService` directly) +- ✅ Use `this.txHost.tx` to get the Prisma client +- ✅ The getter provides type-safe access to `PrismaService` methods + +--- + +### 4. AppModule Configuration (`app.module.ts`) + +```typescript +ClsModule.forRoot({ + global: true, + middleware: { + mount: true, + generateId: true, + idGenerator: (req) => + (req.headers as any)['x-request-id'] ?? crypto.randomUUID(), + }, + plugins: [ + new ClsPluginTransactional({ + imports: [PrismaModule], + adapter: new TransactionalAdapterPrisma({ + prismaInjectionToken: PrismaService, // ← Use custom PrismaService + }), + enableTransactionProxy: true, + }), + ], +}), +``` + +**Critical:** `prismaInjectionToken: PrismaService` tells the adapter to use our custom service. + +--- + +## How It Works + +### Without Transaction (Normal Flow) + +```typescript +Repository → txHost.tx → PrismaService → Database + ↓ + (Regular client, auto-commit) +``` + +### With @Transactional (Transaction Flow) + +```typescript +Service [@Transactional] + ↓ +Repository → txHost.tx → Transactional PrismaClient → Database + ↓ ↓ + (Transaction proxy) (BEGIN → COMMIT/ROLLBACK) +``` + +**Magic:** `txHost.tx` returns: +- **Inside `@Transactional()`**: The transactional client (from CLS context) +- **Outside `@Transactional()`**: The regular PrismaService + +--- + +## Usage Example + +### Service with Transaction + +```typescript +import { Injectable } from '@nestjs/common'; +import { Transactional } from '@nestjs-cls/transactional'; + +@Injectable() +export class UserService { + constructor( + private readonly userRepo: UserRepository, + private readonly profileRepo: ProfileRepository, + ) {} + + @Transactional() + async registerUser(data: RegisterDto) { + // Both operations use the SAME transaction + const user = await this.userRepo.create(data.user); + const profile = await this.profileRepo.create({ + userId: user.id, + ...data.profile, + }); + + // If profile creation fails, user creation rolls back too + return { user, profile }; + } +} +``` + +### Repository (No Changes Needed) + +```typescript +@Injectable() +export class UserRepository { + constructor( + private readonly txHost: TransactionHost, + ) {} + + private get prisma() { + return this.txHost.tx as PrismaService; + } + + async create(data: CreateUserDto) { + // Automatically uses transaction if available + return this.prisma.user.create({ data }); + } +} +``` + +--- + +## Why Not Inject PrismaService Directly? + +### ❌ Wrong (Breaks Transactions) + +```typescript +@Injectable() +export class CrudRepository { + constructor(private readonly prisma: PrismaService) {} + + async create(data: CreateDto) { + // This bypasses the transaction proxy! + return this.prisma.crud.create({ data }); + } +} +``` + +**Problem:** Direct injection uses the global PrismaService instance, which doesn't know about the CLS transaction context. + +### ✅ Correct (Transactions Work) + +```typescript +@Injectable() +export class CrudRepository { + constructor( + private readonly txHost: TransactionHost + ) {} + + private get prisma() { + return this.txHost.tx as PrismaService; + } + + async create(data: CreateDto) { + // This uses the transaction from CLS context + return this.prisma.crud.create({ data }); + } +} +``` + +**Solution:** Use `TransactionHost` to get the correct client (transactional or regular). + +--- + +## Testing Transactions + +### Test Rollback + +1. Add error in service after database operation: +```typescript +@Transactional() +async createCrud(data: CreateDto) { + const created = await this.crudRepo.create(data); + throw new Error('Test rollback'); // Simulate error + return created; +} +``` + +2. Call the mutation and check database: + - ✅ Error is thrown + - ✅ Database has NO new record (rolled back) + +3. Remove the error and test normal operation: + - ✅ Record is created successfully + +--- + +## Common Issues + +### Issue 1: "Cannot read property 'tx' of undefined" + +**Cause:** ClsModule not properly configured or middleware not mounted. + +**Fix:** Ensure `app.module.ts` has: +```typescript +ClsModule.forRoot({ + middleware: { mount: true }, // ← Must be true + plugins: [/* ... */] +}) +``` + +### Issue 2: Transaction doesn't rollback + +**Cause:** Repository injects `PrismaService` directly instead of `TransactionHost`. + +**Fix:** Use the repository pattern shown above. + +### Issue 3: Type errors with `txHost.tx` + +**Cause:** Missing generic type parameter. + +**Fix:** Use `TransactionHost` with the type alias. + +--- + +## Benefits of This Approach + +1. ✅ **Type Safety**: Full TypeScript support for PrismaService methods +2. ✅ **Clean Code**: No verbose type annotations in repositories +3. ✅ **Automatic**: Transactions work transparently without manual `$transaction()` +4. ✅ **Flexible**: Works with Prisma Client Extensions and custom methods +5. ✅ **Testable**: Easy to mock `TransactionHost` in unit tests + +--- + +## References + +- [nestjs-cls Transactional Plugin](https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional) +- [Prisma Adapter Docs](https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional/prisma-adapter) +- [Custom Prisma Client Types](https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional/prisma-adapter#custom-client-type) + +--- + +**Last Updated:** 2025-12-08 diff --git a/apps/api/package.json b/apps/api/package.json index 60b840c..f511e8a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -23,6 +23,8 @@ "dependencies": { "@andeanwide/nestjs-rollbar": "^1.0.0", "@better-auth/expo": "1.3.34", + "@nestjs-cls/transactional": "^3.1.1", + "@nestjs-cls/transactional-adapter-prisma": "^1.3.1", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", @@ -35,6 +37,7 @@ "@thallesp/nestjs-better-auth": "^2.1.0", "better-auth": "1.3.34", "mongoose": "^9.0.0", + "nestjs-cls": "^6.1.0", "nestjs-trpc": "^1.6.1", "reflect-metadata": "^0.2.2", "resend": "^6.4.2", @@ -42,13 +45,13 @@ "zod": "3.25.5" }, "devDependencies": { - "@repo/db-seeder": "workspace:*", "@better-auth/cli": "1.3.34", "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.18.0", "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", + "@repo/db-seeder": "workspace:*", "@repo/eslint-config": "workspace:*", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 0d34d55..1519b99 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -11,6 +11,10 @@ import { EmailModule } from './modules/email/email.module'; import { trpcErrorFormatter } from './trpc/trpc-error-formatter'; import { PrismaModule } from './modules/prisma/prisma.module'; import { MongooseModule } from '@nestjs/mongoose'; +import { ClsModule } from 'nestjs-cls'; +import { ClsPluginTransactional } from '@nestjs-cls/transactional'; +import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma'; +import { PrismaService } from './modules/prisma/prisma.service'; @Module({ imports: [ @@ -25,6 +29,26 @@ import { MongooseModule } from '@nestjs/mongoose'; }), PrismaModule, MongooseModule.forRoot(process.env.DATABASE_URL_MONGODB!), + // Add ClsModule with Transactional Plugin + ClsModule.forRoot({ + global: true, + middleware: { + mount: true, // Mount CLS middleware globally + generateId: true, // Generate request ID + // Fix: Use proper type for tRPC compatibility + idGenerator: (req) => + (req.headers as any)['x-request-id'] ?? crypto.randomUUID(), + }, + plugins: [ + new ClsPluginTransactional({ + imports: [PrismaModule], + adapter: new TransactionalAdapterPrisma({ + prismaInjectionToken: PrismaService, + }), + enableTransactionProxy: true, // Allows direct service injection + }), + ], + }), AuthModule, RollbarModule.register({ accessToken: process.env.ROLLBAR_ACCESS_TOKEN!, diff --git a/apps/api/src/common/base-transactional.service.ts b/apps/api/src/common/base-transactional.service.ts new file mode 100644 index 0000000..d7039dc --- /dev/null +++ b/apps/api/src/common/base-transactional.service.ts @@ -0,0 +1,23 @@ +import { Transactional } from '@nestjs-cls/transactional'; + +/** + * Base service class that provides automatic transaction wrapping + * All public methods that modify data should use the transaction wrapper + */ +export abstract class BaseTransactionalService { + /** + * Wrap any async operation in a transaction + * Use this in your service methods that need transactions + * + * @example + * async createUser(data: CreateUserDto) { + * return this.runInTransaction(() => { + * // All operations here are transactional + * }); + * } + */ + @Transactional() + protected async runInTransaction(operation: () => Promise): Promise { + return operation(); + } +} diff --git a/apps/api/src/common/decorators/auto-transactional.decorator.ts b/apps/api/src/common/decorators/auto-transactional.decorator.ts new file mode 100644 index 0000000..0472d1e --- /dev/null +++ b/apps/api/src/common/decorators/auto-transactional.decorator.ts @@ -0,0 +1,147 @@ +import { Transactional } from '@nestjs-cls/transactional'; + +/** + * List of method names that should automatically be wrapped in transactions + * Customize this list based on your conventions + */ +const WRITE_METHOD_PATTERNS = [ + /^create/i, // createUser, createOrder, etc. + /^update/i, // updateUser, updateOrder, etc. + /^delete/i, // deleteUser, deleteOrder, etc. + /^remove/i, // removeUser, removeOrder, etc. + /^save/i, // saveUser, saveOrder, etc. + /^upsert/i, // upsertUser, upsertOrder, etc. + /^insert/i, // insertUser, insertOrder, etc. + /^register/i, // registerUser, etc. + /^activate/i, // activateUser, etc. + /^deactivate/i, // deactivateUser, etc. + /^archive/i, // archiveUser, etc. +]; + +/** + * Class decorator that automatically applies @Transactional() to write methods + * + * Usage: + * ```typescript + * @Injectable() + * @AutoTransactional() + * export class UserService { + * async createUser() { } // ✅ Automatically transactional + * async updateUser() { } // ✅ Automatically transactional + * async findUser() { } // ❌ Not transactional (doesn't match pattern) + * } + * ``` + */ +export function AutoTransactional(options?: { + /** + * Additional method patterns to consider as write operations + */ + additionalPatterns?: RegExp[]; + + /** + * Exclude specific method names from auto-transaction + */ + exclude?: string[]; + + /** + * Transaction options to apply to all methods + */ + transactionOptions?: Parameters[0]; +}): ClassDecorator { + return function (target: any) { + const patterns = [ + ...WRITE_METHOD_PATTERNS, + ...(options?.additionalPatterns || []), + ]; + const exclude = options?.exclude || []; + + // Get all method names from the prototype + const methodNames = Object.getOwnPropertyNames(target.prototype) + .filter(name => { + // Skip constructor and excluded methods + if (name === 'constructor' || exclude.includes(name)) { + return false; + } + + // Check if method matches any write pattern + return patterns.some(pattern => pattern.test(name)); + }); + + // Apply @Transactional to matching methods + methodNames.forEach(methodName => { + const originalMethod = target.prototype[methodName]; + + // Skip if not a function + if (typeof originalMethod !== 'function') { + return; + } + + // Check if already has @Transactional metadata + const existingMetadata = Reflect.getMetadata( + 'transactional', + target.prototype, + methodName + ); + + if (existingMetadata) { + // Already has @Transactional, skip + return; + } + + // Apply @Transactional decorator + const transactionalDecorator = Transactional(options?.transactionOptions); + + // Apply to the method + const descriptor = Object.getOwnPropertyDescriptor( + target.prototype, + methodName + ); + + if (descriptor) { + transactionalDecorator(target.prototype, methodName, descriptor); + Object.defineProperty(target.prototype, methodName, descriptor); + } + }); + + return target; + }; +} + +/** + * Method decorator to explicitly mark a method as requiring a transaction + * This is mainly for documentation purposes and runtime validation + * + * Usage: + * ```typescript + * @RequiresTransaction() + * async customWriteMethod() { } + * ``` + */ +export function RequiresTransaction( + options?: Parameters[0] +): MethodDecorator { + return function (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) { + // Mark with metadata + Reflect.defineMetadata('requires-transaction', true, target, propertyKey); + + // Apply @Transactional + return Transactional(options)(target, propertyKey, descriptor); + }; +} + +/** + * Method decorator to explicitly exclude a method from auto-transaction + * + * Usage: + * ```typescript + * @NoTransaction() + * async createBatch() { + * // This handles its own transaction logic + * } + * ``` + */ +export function NoTransaction(): MethodDecorator { + return function (target: any, propertyKey: string | symbol) { + Reflect.defineMetadata('no-transaction', true, target, propertyKey); + }; +} diff --git a/apps/api/src/common/guards/transaction-check.guard.ts b/apps/api/src/common/guards/transaction-check.guard.ts new file mode 100644 index 0000000..3781a4b --- /dev/null +++ b/apps/api/src/common/guards/transaction-check.guard.ts @@ -0,0 +1,75 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +/** + * Guard that validates if write methods have transactions enabled + * This runs at runtime to catch missing @Transactional decorators + * + * Enable globally in main.ts: + * ```typescript + * app.useGlobalGuards(new TransactionCheckGuard(app.get(Reflector))); + * ``` + */ +@Injectable() +export class TransactionCheckGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + // Only check in development/test environments + if (process.env.NODE_ENV === 'production') { + return true; + } + + const handler = context.getHandler(); + const className = context.getClass().name; + const methodName = handler.name; + + // Skip if method is explicitly marked with @NoTransaction + const noTransaction = this.reflector.get( + 'no-transaction', + handler + ); + if (noTransaction) { + return true; + } + + // Check if method name suggests it's a write operation + const writePatterns = [ + /^create/i, + /^update/i, + /^delete/i, + /^remove/i, + /^save/i, + /^upsert/i, + /^insert/i, + ]; + + const isWriteMethod = writePatterns.some(pattern => + pattern.test(methodName) + ); + + if (isWriteMethod) { + // Check if @Transactional or @RequiresTransaction is present + const hasTransactional = this.reflector.get( + 'transactional', + handler + ); + const requiresTransaction = this.reflector.get( + 'requires-transaction', + handler + ); + + if (!hasTransactional && !requiresTransaction) { + console.warn( + `⚠️ [TransactionCheck] ${className}.${methodName}() appears to be a write method but doesn't have @Transactional decorator. ` + + `Add @Transactional() or @NoTransaction() to explicitly mark transaction behavior.` + ); + + // In strict mode, you can throw an error instead: + // throw new Error(`${className}.${methodName}() requires @Transactional decorator`); + } + } + + return true; + } +} diff --git a/apps/api/src/common/testing/transaction-test.helper.ts b/apps/api/src/common/testing/transaction-test.helper.ts new file mode 100644 index 0000000..4e20b6c --- /dev/null +++ b/apps/api/src/common/testing/transaction-test.helper.ts @@ -0,0 +1,99 @@ +/** + * Test helper to verify that services have proper transaction decorators + * Use this in your unit tests to enforce transaction usage + */ + +/** + * Verify that a service class has @Transactional on write methods + * + * Usage in tests: + * ```typescript + * describe('UserService', () => { + * it('should have @Transactional on write methods', () => { + * verifyTransactionalMethods(UserService, ['createUser', 'updateUser', 'deleteUser']); + * }); + * }); + * ``` + */ +export function verifyTransactionalMethods( + serviceClass: any, + methodNames: string[] +): void { + methodNames.forEach(methodName => { + const hasMetadata = Reflect.getMetadata( + 'transactional', + serviceClass.prototype, + methodName + ) || Reflect.getMetadata( + 'requires-transaction', + serviceClass.prototype, + methodName + ); + + if (!hasMetadata) { + throw new Error( + `${serviceClass.name}.${methodName}() is missing @Transactional decorator` + ); + } + }); +} + +/** + * Scan a service and find all write methods without @Transactional + * + * Usage: + * ```typescript + * const missing = findMissingTransactionalDecorators(UserService); + * expect(missing).toEqual([]); + * ``` + */ +export function findMissingTransactionalDecorators(serviceClass: any): string[] { + const writePatterns = [ + /^create/i, + /^update/i, + /^delete/i, + /^remove/i, + /^save/i, + /^upsert/i, + ]; + + const methodNames = Object.getOwnPropertyNames(serviceClass.prototype) + .filter(name => { + if (name === 'constructor') return false; + + const method = serviceClass.prototype[name]; + return typeof method === 'function'; + }); + + const missingDecorators: string[] = []; + + methodNames.forEach(methodName => { + const isWriteMethod = writePatterns.some(pattern => + pattern.test(methodName) + ); + + if (isWriteMethod) { + const hasTransactional = Reflect.getMetadata( + 'transactional', + serviceClass.prototype, + methodName + ); + const requiresTransaction = Reflect.getMetadata( + 'requires-transaction', + serviceClass.prototype, + methodName + ); + const noTransaction = Reflect.getMetadata( + 'no-transaction', + serviceClass.prototype, + methodName + ); + + if (!hasTransactional && !requiresTransaction && !noTransaction) { + missingDecorators.push(methodName); + } + } + }); + + return missingDecorators; +} diff --git a/apps/api/src/middleware/transaction.middleware.ts b/apps/api/src/middleware/transaction.middleware.ts new file mode 100644 index 0000000..104ea7c --- /dev/null +++ b/apps/api/src/middleware/transaction.middleware.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { Transactional } from '@nestjs-cls/transactional'; +import { MiddlewareOptions, TRPCMiddleware } from 'nestjs-trpc'; + +/** + * Global transaction middleware for tRPC + * Automatically wraps all mutations in transactions + * Queries run without transactions for better performance + */ +@Injectable() +export class TransactionMiddleware implements TRPCMiddleware { + @Transactional() + async use(opts: MiddlewareOptions): Promise { + const { next } = opts; + + console.log('transaction middleware invoked for', opts.type, opts.path); + + // Simply pass through - the @Transactional decorator handles everything + return next(); + } +} diff --git a/apps/api/src/modules/crud/crud.router.ts b/apps/api/src/modules/crud/crud.router.ts index ed64bdc..2d1d630 100644 --- a/apps/api/src/modules/crud/crud.router.ts +++ b/apps/api/src/modules/crud/crud.router.ts @@ -16,6 +16,7 @@ import { import { AuthMiddleware } from '../auth/auth.middleware'; +// Transactions handled at service level (not middleware) @Router({ alias: 'crud' }) export class CrudRouter { constructor(private readonly crudService: CrudService) {} @@ -28,6 +29,7 @@ export class CrudRouter { async createCrud( @Input() req: CrudSchema.TCrudCreateRequest, ): Promise { + // Transaction handled at service level const created = await this.crudService.createCrud(req); return { success: created != null, diff --git a/apps/api/src/modules/crud/crud.service.ts b/apps/api/src/modules/crud/crud.service.ts index 596690c..afda0d2 100644 --- a/apps/api/src/modules/crud/crud.service.ts +++ b/apps/api/src/modules/crud/crud.service.ts @@ -1,4 +1,5 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import { Transactional } from '@nestjs-cls/transactional'; import { CrudRepository } from './repositories/crud.repository'; import { CreateCrudDto, @@ -11,9 +12,17 @@ export class CrudService { constructor(private readonly crudRepository: CrudRepository) {} async createCrud(data: CreateCrudDto): Promise { - return this.crudRepository.create(data); + // Step 1: Create the CRUD item + const created = await this.crudRepository.create(data); + + // Step 2: Simulate error to test rollback + // If this line is uncommented, the create above should rollback + throw new Error('Simulated error to test transaction rollback'); + + return created; } + // Read operations don't need transactions async findAll(): Promise { return this.crudRepository.find(); } @@ -24,12 +33,14 @@ export class CrudService { return crud; } + @Transactional() async update(id: string, data: UpdateCrudDto): Promise { const updated = await this.crudRepository.update(id, data); if (!updated) throw new NotFoundException(`Crud with id ${id} not found`); return updated; } + @Transactional() async delete(id: string): Promise { const deleted = await this.crudRepository.delete(id); if (!deleted) throw new NotFoundException(`Crud with id ${id} not found`); diff --git a/apps/api/src/modules/crud/repositories/crud.repository.ts b/apps/api/src/modules/crud/repositories/crud.repository.ts index 6dfdea8..2b6c4e4 100644 --- a/apps/api/src/modules/crud/repositories/crud.repository.ts +++ b/apps/api/src/modules/crud/repositories/crud.repository.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { PrismaService } from '../../prisma/prisma.service'; +import { TransactionHost } from '@nestjs-cls/transactional'; +import { PrismaTransactionAdapter } from '../../prisma'; import { CreateCrudDto, CrudEntity, @@ -9,13 +10,13 @@ import { @Injectable() export class CrudRepository { constructor( - private readonly prisma: PrismaService, + private readonly txHost: TransactionHost, // @InjectModel(CrudDocument.name) // private readonly crudModel: Model, ) {} async find(): Promise { - return this.prisma.crud.findMany({ + return this.txHost.tx.crud.findMany({ orderBy: { createdAt: 'desc' }, }); @@ -24,7 +25,7 @@ export class CrudRepository { } async findOne(id: string): Promise { - return this.prisma.crud.findUnique({ + return this.txHost.tx.crud.findUnique({ where: { id }, }); @@ -33,7 +34,8 @@ export class CrudRepository { } async create(data: CreateCrudDto): Promise { - return this.prisma.crud.create({ + // Uses transaction from CLS context via TransactionHost + return this.txHost.tx.crud.create({ data, }); @@ -42,7 +44,7 @@ export class CrudRepository { } async update(id: string, data: UpdateCrudDto): Promise { - return this.prisma.crud.update({ + return this.txHost.tx.crud.update({ where: { id }, data, }); @@ -54,7 +56,7 @@ export class CrudRepository { } async delete(id: string): Promise { - return this.prisma.crud.delete({ + return this.txHost.tx.crud.delete({ where: { id }, }); diff --git a/apps/api/src/modules/prisma/index.ts b/apps/api/src/modules/prisma/index.ts new file mode 100644 index 0000000..e838280 --- /dev/null +++ b/apps/api/src/modules/prisma/index.ts @@ -0,0 +1,4 @@ +// Barrel export for Prisma module +export { PrismaModule } from './prisma.module'; +export { PrismaService } from './prisma.service'; +export { PrismaTransactionAdapter } from './prisma-transaction.types'; diff --git a/apps/api/src/modules/prisma/prisma-transaction.types.ts b/apps/api/src/modules/prisma/prisma-transaction.types.ts new file mode 100644 index 0000000..16eceda --- /dev/null +++ b/apps/api/src/modules/prisma/prisma-transaction.types.ts @@ -0,0 +1,8 @@ +import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma'; +import { PrismaService } from './prisma.service'; + +/** + * Type alias for the Prisma transaction adapter with custom PrismaService + * This avoids verbose type annotations throughout the codebase + */ +export type PrismaTransactionAdapter = TransactionalAdapterPrisma; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ce0feb..c7733d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,12 @@ importers: '@better-auth/expo': specifier: 1.3.34 version: 1.3.34(better-auth@1.3.34(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(expo-constants@18.0.9(expo@54.0.13(@babel/core@7.28.4)(@expo/metro-runtime@6.1.2)(expo-router@6.0.12)(graphql@16.12.0)(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.0)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.0)(react@19.1.0)))(expo-crypto@15.0.7(expo@54.0.13(@babel/core@7.28.4)(@expo/metro-runtime@6.1.2)(expo-router@6.0.12)(graphql@16.12.0)(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.0)(react@19.1.0))(react@19.1.0)))(expo-linking@8.0.8(expo@54.0.13)(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.0)(react@19.1.0))(react@19.1.0))(expo-secure-store@15.0.7(expo@54.0.13(@babel/core@7.28.4)(@expo/metro-runtime@6.1.2)(expo-router@6.0.12)(graphql@16.12.0)(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.0)(react@19.1.0))(react@19.1.0)))(expo-web-browser@15.0.8(expo@54.0.13(@babel/core@7.28.4)(@expo/metro-runtime@6.1.2)(expo-router@6.0.12)(graphql@16.12.0)(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.0)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.0)(react@19.1.0))) + '@nestjs-cls/transactional': + specifier: ^3.1.1 + version: 3.1.1(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(nestjs-cls@6.1.0(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs-cls/transactional-adapter-prisma': + specifier: ^1.3.1 + version: 1.3.1(@nestjs-cls/transactional@3.1.1(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(nestjs-cls@6.1.0(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(@prisma/client@6.17.1(prisma@6.17.1(typescript@5.9.2))(typescript@5.9.2))(nestjs-cls@6.1.0(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(prisma@6.17.1(typescript@5.9.2)) '@nestjs/common': specifier: ^11.0.1 version: 11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -77,6 +83,9 @@ importers: mongoose: specifier: ^9.0.0 version: 9.0.0 + nestjs-cls: + specifier: ^6.1.0 + version: 6.1.0(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) nestjs-trpc: specifier: ^1.6.1 version: 1.6.1(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@trpc/server@11.1.2(typescript@5.9.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)(zod@3.25.5) @@ -1760,6 +1769,25 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@nestjs-cls/transactional-adapter-prisma@1.3.1': + resolution: {integrity: sha512-u9MmT1DmOuIbxcK6dSqNqMZu0M0YakEGl9PMG8TwN6gyU5AN7XwXYf3EOlIFG2z916pCqUn7+DXXnXd3kUC5PQ==} + engines: {node: '>=18'} + peerDependencies: + '@nestjs-cls/transactional': ^3.1.1 + '@prisma/client': '> 4 < 7' + nestjs-cls: ^6.1.0 + prisma: '> 4 < 7' + + '@nestjs-cls/transactional@3.1.1': + resolution: {integrity: sha512-wP45dYbhMmlxSuEyCpULr7/T67v6vMy0Wh2vDSlvEBm/Dx3FYLlFR+yWeqQWQW+bpGe7wusbip3cNZ9F/4izkQ==} + engines: {node: '>=18'} + peerDependencies: + '@nestjs/common': '>= 10 < 12' + '@nestjs/core': '>= 10 < 12' + nestjs-cls: ^6.1.0 + reflect-metadata: '*' + rxjs: '>= 7' + '@nestjs/cli@11.0.10': resolution: {integrity: sha512-4waDT0yGWANg0pKz4E47+nUrqIJv/UqrZ5wLPkCqc7oMGRMWKAaw1NDZ9rKsaqhqvxb2LfI5+uXOWr4yi94DOQ==} engines: {node: '>= 20.11'} @@ -5903,6 +5931,15 @@ packages: nested-error-stacks@2.0.1: resolution: {integrity: sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==} + nestjs-cls@6.1.0: + resolution: {integrity: sha512-n4T2aXy5cQxrB8VIGzUF3JBK8lXbRDaWdDS0g7uDKshvYn8NGWMe46PX1p+sb2SxiC0Gjrsff44hkk0kvpS+Ug==} + engines: {node: '>=18'} + peerDependencies: + '@nestjs/common': '>= 10 < 12' + '@nestjs/core': '>= 10 < 12' + reflect-metadata: '*' + rxjs: '>= 7' + nestjs-trpc@1.6.1: resolution: {integrity: sha512-FZ0n1n9czPaZ5HHoDM7hlWsR1g3C79MhZ1Dg1xidehrl9Agk4F3XX71aLh8fgVD5sNR08B3d/xITaLP3PkOUWw==} peerDependencies: @@ -5916,6 +5953,7 @@ packages: next@15.5.6: resolution: {integrity: sha512-zTxsnI3LQo3c9HSdSf91O1jMNsEzIXDShXd4wVdg9y5shwLqBXi4ZtUUJyB86KGVSJLZx0PFONvO54aheGX8QQ==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details. hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 @@ -9478,6 +9516,21 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@nestjs-cls/transactional-adapter-prisma@1.3.1(@nestjs-cls/transactional@3.1.1(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(nestjs-cls@6.1.0(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(@prisma/client@6.17.1(prisma@6.17.1(typescript@5.9.2))(typescript@5.9.2))(nestjs-cls@6.1.0(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(prisma@6.17.1(typescript@5.9.2))': + dependencies: + '@nestjs-cls/transactional': 3.1.1(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(nestjs-cls@6.1.0(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@prisma/client': 6.17.1(prisma@6.17.1(typescript@5.9.2))(typescript@5.9.2) + nestjs-cls: 6.1.0(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + prisma: 6.17.1(typescript@5.9.2) + + '@nestjs-cls/transactional@3.1.1(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(nestjs-cls@6.1.0(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + nestjs-cls: 6.1.0(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + '@nestjs/cli@11.0.10(@types/node@22.18.11)': dependencies: '@angular-devkit/core': 19.2.15(chokidar@4.0.3) @@ -14286,6 +14339,13 @@ snapshots: nested-error-stacks@2.0.1: {} + nestjs-cls@6.1.0(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2): + dependencies: + '@nestjs/common': 11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + nestjs-trpc@1.6.1(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@trpc/server@11.1.2(typescript@5.9.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)(zod@3.25.5): dependencies: '@nestjs/common': 11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2) From 0cb9d50eb1c1906e39e9d800a7f343d7fbc851f9 Mon Sep 17 00:00:00 2001 From: Anns Shahbaz Date: Tue, 9 Dec 2025 11:39:55 +0500 Subject: [PATCH 02/37] transaction in both mongo and prisma --- apps/api/docker-compose.mongo.yml | 23 +-- apps/api/package.json | 1 + apps/api/src/app.module.ts | 21 ++- apps/api/src/common/constants.ts | 1 + .../auto-transactional.decorator.ts | 147 ------------------ .../class/auto-transaction.decorator.ts | 41 +++++ .../method/no-transaction.decorator.ts | 4 + apps/api/src/modules/crud/crud.service.ts | 26 +++- .../crud/repositories/crud.repository.ts | 122 ++++++++++----- apps/api/src/modules/prisma/index.ts | 4 - .../prisma/prisma-transaction.types.ts | 8 - apps/api/src/modules/prisma/prisma.module.ts | 4 + pnpm-lock.yaml | 17 ++ 13 files changed, 198 insertions(+), 221 deletions(-) create mode 100644 apps/api/src/common/constants.ts delete mode 100644 apps/api/src/common/decorators/auto-transactional.decorator.ts create mode 100644 apps/api/src/common/decorators/class/auto-transaction.decorator.ts create mode 100644 apps/api/src/common/decorators/method/no-transaction.decorator.ts delete mode 100644 apps/api/src/modules/prisma/index.ts delete mode 100644 apps/api/src/modules/prisma/prisma-transaction.types.ts diff --git a/apps/api/docker-compose.mongo.yml b/apps/api/docker-compose.mongo.yml index 34b42bb..3987495 100644 --- a/apps/api/docker-compose.mongo.yml +++ b/apps/api/docker-compose.mongo.yml @@ -1,31 +1,34 @@ name: app-mongo-db services: mongodb: - image: mongodb/mongodb-community-server:latest + image: bitnami/mongodb:latest container_name: ${MONGODB_CONTAINER_NAME} restart: unless-stopped environment: - MONGO_INITDB_ROOT_USERNAME: ${MONGODB_USER} - MONGO_INITDB_ROOT_PASSWORD: ${MONGODB_PASSWORD} + MONGODB_REPLICA_SET_MODE: primary + MONGODB_REPLICA_SET_NAME: rs0 + MONGODB_REPLICA_SET_KEY: ${MONGODB_REPLICA_SET_KEY} + MONGODB_ROOT_USER: ${MONGODB_USER} + MONGODB_ROOT_PASSWORD: ${MONGODB_PASSWORD} + MONGODB_ADVERTISED_HOSTNAME: mongodb ports: - "${MONGODB_PORT}:27017" volumes: - - mongodb_data:/data/db + - mongodb_data:/bitnami/mongodb healthcheck: - test: mongosh -u ${MONGODB_USER} -p ${MONGODB_PASSWORD} --authenticationDatabase admin --quiet --eval "db.runCommand('ping').ok" + test: > + bash -c "mongosh -u ${MONGODB_USER} -p ${MONGODB_PASSWORD} --authenticationDatabase admin --quiet --eval 'rs.status().ok' | grep 1" interval: 10s timeout: 5s - retries: 5 - start_period: 20s + retries: 30 + start_period: 10s mongo-express: image: mongo-express:latest container_name: ${MONGO_EXPRESS_CONTAINER_NAME} restart: unless-stopped environment: - ME_CONFIG_MONGODB_SERVER: mongodb - ME_CONFIG_MONGODB_ADMINUSERNAME: ${MONGODB_USER} - ME_CONFIG_MONGODB_ADMINPASSWORD: ${MONGODB_PASSWORD} + ME_CONFIG_MONGODB_URL: "mongodb://${MONGODB_USER}:${MONGODB_PASSWORD}@mongodb:27017/?authSource=admin&replicaSet=rs0" ME_CONFIG_BASICAUTH_USERNAME: ${MONGO_EXPRESS_USER} ME_CONFIG_BASICAUTH_PASSWORD: ${MONGO_EXPRESS_PASSWORD} ports: diff --git a/apps/api/package.json b/apps/api/package.json index f511e8a..80ef80f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -24,6 +24,7 @@ "@andeanwide/nestjs-rollbar": "^1.0.0", "@better-auth/expo": "1.3.34", "@nestjs-cls/transactional": "^3.1.1", + "@nestjs-cls/transactional-adapter-mongoose": "^1.1.25", "@nestjs-cls/transactional-adapter-prisma": "^1.3.1", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 1519b99..cc69df0 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -10,11 +10,12 @@ import { AuthModule } from './modules/auth/auth.module'; import { EmailModule } from './modules/email/email.module'; import { trpcErrorFormatter } from './trpc/trpc-error-formatter'; import { PrismaModule } from './modules/prisma/prisma.module'; -import { MongooseModule } from '@nestjs/mongoose'; +import { PrismaService } from './modules/prisma/prisma.service'; +import { getConnectionToken, MongooseModule } from '@nestjs/mongoose'; import { ClsModule } from 'nestjs-cls'; import { ClsPluginTransactional } from '@nestjs-cls/transactional'; +import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter-mongoose'; import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma'; -import { PrismaService } from './modules/prisma/prisma.service'; @Module({ imports: [ @@ -37,15 +38,27 @@ import { PrismaService } from './modules/prisma/prisma.service'; generateId: true, // Generate request ID // Fix: Use proper type for tRPC compatibility idGenerator: (req) => - (req.headers as any)['x-request-id'] ?? crypto.randomUUID(), + req.headers['x-request-id'] ?? crypto.randomUUID(), }, plugins: [ new ClsPluginTransactional({ + connectionName: 'MONGOOSE_CONNECTION', + imports: [ + // module in which the Connection instance is provided + MongooseModule, + ], + adapter: new TransactionalAdapterMongoose({ + // the injection token of the mongoose Connection + mongooseConnectionToken: getConnectionToken(), + }), + }), + new ClsPluginTransactional({ + connectionName: 'PRISMA_CONNECTION', imports: [PrismaModule], adapter: new TransactionalAdapterPrisma({ prismaInjectionToken: PrismaService, }), - enableTransactionProxy: true, // Allows direct service injection + enableTransactionProxy: true, }), ], }), diff --git a/apps/api/src/common/constants.ts b/apps/api/src/common/constants.ts new file mode 100644 index 0000000..1d501ff --- /dev/null +++ b/apps/api/src/common/constants.ts @@ -0,0 +1 @@ +export const NO_TRANSACTION_KEY = 'no-transaction'; diff --git a/apps/api/src/common/decorators/auto-transactional.decorator.ts b/apps/api/src/common/decorators/auto-transactional.decorator.ts deleted file mode 100644 index 0472d1e..0000000 --- a/apps/api/src/common/decorators/auto-transactional.decorator.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { Transactional } from '@nestjs-cls/transactional'; - -/** - * List of method names that should automatically be wrapped in transactions - * Customize this list based on your conventions - */ -const WRITE_METHOD_PATTERNS = [ - /^create/i, // createUser, createOrder, etc. - /^update/i, // updateUser, updateOrder, etc. - /^delete/i, // deleteUser, deleteOrder, etc. - /^remove/i, // removeUser, removeOrder, etc. - /^save/i, // saveUser, saveOrder, etc. - /^upsert/i, // upsertUser, upsertOrder, etc. - /^insert/i, // insertUser, insertOrder, etc. - /^register/i, // registerUser, etc. - /^activate/i, // activateUser, etc. - /^deactivate/i, // deactivateUser, etc. - /^archive/i, // archiveUser, etc. -]; - -/** - * Class decorator that automatically applies @Transactional() to write methods - * - * Usage: - * ```typescript - * @Injectable() - * @AutoTransactional() - * export class UserService { - * async createUser() { } // ✅ Automatically transactional - * async updateUser() { } // ✅ Automatically transactional - * async findUser() { } // ❌ Not transactional (doesn't match pattern) - * } - * ``` - */ -export function AutoTransactional(options?: { - /** - * Additional method patterns to consider as write operations - */ - additionalPatterns?: RegExp[]; - - /** - * Exclude specific method names from auto-transaction - */ - exclude?: string[]; - - /** - * Transaction options to apply to all methods - */ - transactionOptions?: Parameters[0]; -}): ClassDecorator { - return function (target: any) { - const patterns = [ - ...WRITE_METHOD_PATTERNS, - ...(options?.additionalPatterns || []), - ]; - const exclude = options?.exclude || []; - - // Get all method names from the prototype - const methodNames = Object.getOwnPropertyNames(target.prototype) - .filter(name => { - // Skip constructor and excluded methods - if (name === 'constructor' || exclude.includes(name)) { - return false; - } - - // Check if method matches any write pattern - return patterns.some(pattern => pattern.test(name)); - }); - - // Apply @Transactional to matching methods - methodNames.forEach(methodName => { - const originalMethod = target.prototype[methodName]; - - // Skip if not a function - if (typeof originalMethod !== 'function') { - return; - } - - // Check if already has @Transactional metadata - const existingMetadata = Reflect.getMetadata( - 'transactional', - target.prototype, - methodName - ); - - if (existingMetadata) { - // Already has @Transactional, skip - return; - } - - // Apply @Transactional decorator - const transactionalDecorator = Transactional(options?.transactionOptions); - - // Apply to the method - const descriptor = Object.getOwnPropertyDescriptor( - target.prototype, - methodName - ); - - if (descriptor) { - transactionalDecorator(target.prototype, methodName, descriptor); - Object.defineProperty(target.prototype, methodName, descriptor); - } - }); - - return target; - }; -} - -/** - * Method decorator to explicitly mark a method as requiring a transaction - * This is mainly for documentation purposes and runtime validation - * - * Usage: - * ```typescript - * @RequiresTransaction() - * async customWriteMethod() { } - * ``` - */ -export function RequiresTransaction( - options?: Parameters[0] -): MethodDecorator { - return function (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) { - // Mark with metadata - Reflect.defineMetadata('requires-transaction', true, target, propertyKey); - - // Apply @Transactional - return Transactional(options)(target, propertyKey, descriptor); - }; -} - -/** - * Method decorator to explicitly exclude a method from auto-transaction - * - * Usage: - * ```typescript - * @NoTransaction() - * async createBatch() { - * // This handles its own transaction logic - * } - * ``` - */ -export function NoTransaction(): MethodDecorator { - return function (target: any, propertyKey: string | symbol) { - Reflect.defineMetadata('no-transaction', true, target, propertyKey); - }; -} diff --git a/apps/api/src/common/decorators/class/auto-transaction.decorator.ts b/apps/api/src/common/decorators/class/auto-transaction.decorator.ts new file mode 100644 index 0000000..07586c1 --- /dev/null +++ b/apps/api/src/common/decorators/class/auto-transaction.decorator.ts @@ -0,0 +1,41 @@ +import 'reflect-metadata'; +import { Transactional } from '@nestjs-cls/transactional'; +import { NO_TRANSACTION_KEY } from '../../constants'; + +/** + * Class decorator that automatically applies @Transactional to all methods, + * except those marked with @NoTransaction. + */ +export function AutoTransaction(): ClassDecorator { + return (target) => { + const proto = target.prototype as Record; + + for (const name of Object.getOwnPropertyNames(proto)) { + if (name === 'constructor') continue; + + const descriptor = Object.getOwnPropertyDescriptor(proto, name); + if (!descriptor || typeof descriptor.value !== 'function') continue; + + const keys = Reflect.getMetadataKeys(proto, name); + console.log(keys); + + // Skip methods marked with @NoTransaction + const noTransaction = Reflect.hasMetadata( + NO_TRANSACTION_KEY, + proto, + name, + ); + + if (noTransaction) { + continue; + } + + const transactionalDescriptor = Transactional()(proto, name, descriptor); + Object.defineProperty(proto, name, transactionalDescriptor || descriptor); + + console.log( + `[AutoTransaction] Applied @Transactional to method: ${name}`, + ); + } + }; +} diff --git a/apps/api/src/common/decorators/method/no-transaction.decorator.ts b/apps/api/src/common/decorators/method/no-transaction.decorator.ts new file mode 100644 index 0000000..651711d --- /dev/null +++ b/apps/api/src/common/decorators/method/no-transaction.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; +import { NO_TRANSACTION_KEY } from '../../constants'; + +export const NoTransaction = () => SetMetadata(NO_TRANSACTION_KEY, true); diff --git a/apps/api/src/modules/crud/crud.service.ts b/apps/api/src/modules/crud/crud.service.ts index afda0d2..549fc91 100644 --- a/apps/api/src/modules/crud/crud.service.ts +++ b/apps/api/src/modules/crud/crud.service.ts @@ -1,28 +1,31 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import { Transactional } from '@nestjs-cls/transactional'; import { CrudRepository } from './repositories/crud.repository'; import { CreateCrudDto, CrudEntity, UpdateCrudDto, } from './schemas/crud.schema'; +// import { AutoTransaction } from '../../common/decorators/class/auto-transaction.decorator'; +// import { NoTransaction } from '../../common/decorators/method/no-transaction.decorator'; +import { Transactional } from '@nestjs-cls/transactional'; @Injectable() export class CrudService { constructor(private readonly crudRepository: CrudRepository) {} + @Transactional('PRISMA_CONNECTION') async createCrud(data: CreateCrudDto): Promise { // Step 1: Create the CRUD item const created = await this.crudRepository.create(data); + console.log(created); // Step 2: Simulate error to test rollback // If this line is uncommented, the create above should rollback - throw new Error('Simulated error to test transaction rollback'); + throw new Error('Simulated delete error to test transaction rollback'); return created; } - // Read operations don't need transactions async findAll(): Promise { return this.crudRepository.find(); } @@ -33,17 +36,26 @@ export class CrudService { return crud; } - @Transactional() - async update(id: string, data: UpdateCrudDto): Promise { + @Transactional('PRISMA_CONNECTION') + async update(id: string, data: UpdateCrudDto): Promise { const updated = await this.crudRepository.update(id, data); if (!updated) throw new NotFoundException(`Crud with id ${id} not found`); + + console.log(updated); + + throw new Error('Simulated delete error to test transaction rollback'); + return updated; } - @Transactional() - async delete(id: string): Promise { + @Transactional('PRISMA_CONNECTION') + async delete(id: string): Promise { const deleted = await this.crudRepository.delete(id); if (!deleted) throw new NotFoundException(`Crud with id ${id} not found`); + + console.log(deleted); + throw new Error('Simulated delete error to test transaction rollback'); + return deleted; } } diff --git a/apps/api/src/modules/crud/repositories/crud.repository.ts b/apps/api/src/modules/crud/repositories/crud.repository.ts index 2b6c4e4..78851b3 100644 --- a/apps/api/src/modules/crud/repositories/crud.repository.ts +++ b/apps/api/src/modules/crud/repositories/crud.repository.ts @@ -1,75 +1,115 @@ import { Injectable } from '@nestjs/common'; -import { TransactionHost } from '@nestjs-cls/transactional'; -import { PrismaTransactionAdapter } from '../../prisma'; +import { + InjectTransactionHost, + TransactionHost, +} from '@nestjs-cls/transactional'; import { CreateCrudDto, CrudEntity, UpdateCrudDto, } from '../schemas/crud.schema'; +import { PrismaTransactionAdapter } from '../../prisma/prisma.module'; @Injectable() export class CrudRepository { + // ====================== + // Prisma Implementation + // ====================== + constructor( - private readonly txHost: TransactionHost, - // @InjectModel(CrudDocument.name) - // private readonly crudModel: Model, + @InjectTransactionHost('PRISMA_CONNECTION') + private readonly prismaTxHost: TransactionHost, ) {} async find(): Promise { - return this.txHost.tx.crud.findMany({ + return this.prismaTxHost.tx.crud.findMany({ orderBy: { createdAt: 'desc' }, }); - - // const docs = await this.crudModel.find().sort({ createdAt: -1 }).exec(); - // return docs.map((doc) => this.toEntity(doc)); } async findOne(id: string): Promise { - return this.txHost.tx.crud.findUnique({ + return this.prismaTxHost.tx.crud.findUnique({ where: { id }, }); - - // const doc = await this.crudModel.findById(id).exec(); - // return doc ? this.toEntity(doc) : null; } async create(data: CreateCrudDto): Promise { - // Uses transaction from CLS context via TransactionHost - return this.txHost.tx.crud.create({ - data, - }); - - // const doc = await this.crudModel.create(data); - // return this.toEntity(doc); + return this.prismaTxHost.tx.crud.create({ data }); } async update(id: string, data: UpdateCrudDto): Promise { - return this.txHost.tx.crud.update({ + return this.prismaTxHost.tx.crud.update({ where: { id }, data, }); - - // const doc = await this.crudModel - // .findByIdAndUpdate(id, data, { new: true }) - // .exec(); - // return doc ? this.toEntity(doc) : null; } async delete(id: string): Promise { - return this.txHost.tx.crud.delete({ - where: { id }, - }); - - // const doc = await this.crudModel.findByIdAndDelete(id).exec(); - // return doc ? this.toEntity(doc) : null; + return this.prismaTxHost.tx.crud.delete({ where: { id } }); } - - // private toEntity(doc: CrudDocument): CrudEntity { - // return { - // id: doc._id.toString(), - // content: doc.content, - // createdAt: doc.createdAt, - // updatedAt: doc.updatedAt, - // }; - // } } + +// import { InjectModel } from '@nestjs/mongoose'; +// import { Model } from 'mongoose'; +// import { CrudDocument } from '../models/crud.model'; +// import { +// CreateCrudDto, +// CrudEntity, +// UpdateCrudDto, +// } from '../schemas/crud.schema'; +// import { +// InjectTransactionHost, +// TransactionHost, +// } from '@nestjs-cls/transactional'; +// import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter-mongoose'; +// +// export class CrudRepository { +// constructor( +// @InjectModel(CrudDocument.name) +// private readonly crudModel: Model, +// @InjectTransactionHost('MONGOOSE_CONNECTION') +// private readonly mongoTxHost: TransactionHost, +// ) {} +// +// async find(): Promise { +// const docs = await this.crudModel +// .find() +// .sort({ createdAt: -1 }) +// .session(this.mongoTxHost.tx); +// return docs.map((doc) => this.toEntity(doc)); +// } +// +// async findOne(id: string): Promise { +// const doc = await this.crudModel.findById(id).session(this.mongoTxHost.tx); +// return doc ? this.toEntity(doc) : null; +// } +// +// async create(data: CreateCrudDto): Promise { +// const doc = new CrudDocument(data); +// await doc.save({ session: this.mongoTxHost.tx }); +// return this.toEntity(doc); +// } +// +// async update(id: string, data: UpdateCrudDto): Promise { +// const doc = await this.crudModel +// .findByIdAndUpdate(id, data, { new: true }) +// .session(this.mongoTxHost.tx); +// return doc ? this.toEntity(doc) : null; +// } +// +// async delete(id: string): Promise { +// const doc = await this.crudModel +// .findByIdAndDelete(id) +// .session(this.mongoTxHost.tx); +// return doc ? this.toEntity(doc) : null; +// } +// +// private toEntity(doc: CrudDocument): CrudEntity { +// return { +// id: doc._id.toString(), +// content: doc.content, +// createdAt: doc.createdAt, +// updatedAt: doc.updatedAt, +// }; +// } +// } diff --git a/apps/api/src/modules/prisma/index.ts b/apps/api/src/modules/prisma/index.ts deleted file mode 100644 index e838280..0000000 --- a/apps/api/src/modules/prisma/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Barrel export for Prisma module -export { PrismaModule } from './prisma.module'; -export { PrismaService } from './prisma.service'; -export { PrismaTransactionAdapter } from './prisma-transaction.types'; diff --git a/apps/api/src/modules/prisma/prisma-transaction.types.ts b/apps/api/src/modules/prisma/prisma-transaction.types.ts deleted file mode 100644 index 16eceda..0000000 --- a/apps/api/src/modules/prisma/prisma-transaction.types.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma'; -import { PrismaService } from './prisma.service'; - -/** - * Type alias for the Prisma transaction adapter with custom PrismaService - * This avoids verbose type annotations throughout the codebase - */ -export type PrismaTransactionAdapter = TransactionalAdapterPrisma; diff --git a/apps/api/src/modules/prisma/prisma.module.ts b/apps/api/src/modules/prisma/prisma.module.ts index ec0ce32..87d35d8 100644 --- a/apps/api/src/modules/prisma/prisma.module.ts +++ b/apps/api/src/modules/prisma/prisma.module.ts @@ -1,8 +1,12 @@ import { Module } from '@nestjs/common'; import { PrismaService } from './prisma.service'; +import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma'; @Module({ providers: [PrismaService], exports: [PrismaService], }) export class PrismaModule {} + +export type PrismaTransactionAdapter = + TransactionalAdapterPrisma; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7733d7..eb663db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@nestjs-cls/transactional': specifier: ^3.1.1 version: 3.1.1(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(nestjs-cls@6.1.0(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs-cls/transactional-adapter-mongoose': + specifier: ^1.1.25 + version: 1.1.25(@nestjs-cls/transactional@3.1.1(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(nestjs-cls@6.1.0(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(mongoose@9.0.0)(nestjs-cls@6.1.0(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)) '@nestjs-cls/transactional-adapter-prisma': specifier: ^1.3.1 version: 1.3.1(@nestjs-cls/transactional@3.1.1(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(nestjs-cls@6.1.0(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(@prisma/client@6.17.1(prisma@6.17.1(typescript@5.9.2))(typescript@5.9.2))(nestjs-cls@6.1.0(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(prisma@6.17.1(typescript@5.9.2)) @@ -1769,6 +1772,14 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@nestjs-cls/transactional-adapter-mongoose@1.1.25': + resolution: {integrity: sha512-UdzqunX9cyU9O/3m1SLsIimVv/cDc496PFfoJGXzlMEGXilWByLcjfZ1+n/6pTICI7qhFvM+PZBITyJTjR617Q==} + engines: {node: '>=18'} + peerDependencies: + '@nestjs-cls/transactional': ^3.1.1 + mongoose: '>= 8' + nestjs-cls: ^6.1.0 + '@nestjs-cls/transactional-adapter-prisma@1.3.1': resolution: {integrity: sha512-u9MmT1DmOuIbxcK6dSqNqMZu0M0YakEGl9PMG8TwN6gyU5AN7XwXYf3EOlIFG2z916pCqUn7+DXXnXd3kUC5PQ==} engines: {node: '>=18'} @@ -9516,6 +9527,12 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@nestjs-cls/transactional-adapter-mongoose@1.1.25(@nestjs-cls/transactional@3.1.1(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(nestjs-cls@6.1.0(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(mongoose@9.0.0)(nestjs-cls@6.1.0(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs-cls/transactional': 3.1.1(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(nestjs-cls@6.1.0(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + mongoose: 9.0.0 + nestjs-cls: 6.1.0(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs-cls/transactional-adapter-prisma@1.3.1(@nestjs-cls/transactional@3.1.1(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(nestjs-cls@6.1.0(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(@prisma/client@6.17.1(prisma@6.17.1(typescript@5.9.2))(typescript@5.9.2))(nestjs-cls@6.1.0(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(prisma@6.17.1(typescript@5.9.2))': dependencies: '@nestjs-cls/transactional': 3.1.1(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(nestjs-cls@6.1.0(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) From d1a2bbe602c6101c0fb21aa5d1a1af21c778d17e Mon Sep 17 00:00:00 2001 From: Anns Shahbaz Date: Tue, 9 Dec 2025 12:21:10 +0500 Subject: [PATCH 03/37] lint if not decorated --- apps/api/eslint-rules/plugin.mjs | 8 ++ .../eslint-rules/require-transactional.mjs | 52 ++++++++++ apps/api/eslint.config.mjs | 17 +++- apps/api/src/app.module.ts | 8 +- .../class/auto-transaction.decorator.ts | 6 +- .../src/common/{ => decorators}/constants.ts | 0 .../method/no-transaction.decorator.ts | 2 +- .../common/guards/transaction-check.guard.ts | 75 -------------- .../common/testing/transaction-test.helper.ts | 99 ------------------- .../src/middleware/transaction.middleware.ts | 21 ---- apps/api/src/modules/auth/auth.service.ts | 1 + apps/api/src/modules/crud/crud.service.ts | 33 ++++--- .../crud/repositories/crud.repository.ts | 7 +- 13 files changed, 100 insertions(+), 229 deletions(-) create mode 100644 apps/api/eslint-rules/plugin.mjs create mode 100644 apps/api/eslint-rules/require-transactional.mjs rename apps/api/src/common/{ => decorators}/constants.ts (100%) delete mode 100644 apps/api/src/common/guards/transaction-check.guard.ts delete mode 100644 apps/api/src/common/testing/transaction-test.helper.ts delete mode 100644 apps/api/src/middleware/transaction.middleware.ts diff --git a/apps/api/eslint-rules/plugin.mjs b/apps/api/eslint-rules/plugin.mjs new file mode 100644 index 0000000..415b173 --- /dev/null +++ b/apps/api/eslint-rules/plugin.mjs @@ -0,0 +1,8 @@ +// eslint-rules/plugin.js +import { requireTransactional } from './require-transactional.mjs'; + +export const plugin = { + rules: { + 'require-transactional': requireTransactional, + }, +}; diff --git a/apps/api/eslint-rules/require-transactional.mjs b/apps/api/eslint-rules/require-transactional.mjs new file mode 100644 index 0000000..b34b53d --- /dev/null +++ b/apps/api/eslint-rules/require-transactional.mjs @@ -0,0 +1,52 @@ +export const requireTransactional = { + meta: { + type: 'problem', + docs: { + description: + 'Warn if a service method is missing @Transactional decorator', + recommended: 'warn', + }, + messages: { + missingTransactional: + "Service method '{{name}}' is missing @Transactional decorator.", + }, + schema: [], + }, + create(context) { + return { + ClassDeclaration(node) { + const className = node.id?.name || ''; + if (!className.endsWith('Service')) return; + + for (const method of node.body.body) { + // Only process methods (ignore constructors or properties) + if ( + method.type !== 'MethodDefinition' && + method.type !== 'TSMethodDefinition' + ) + continue; + + // Ignore constructors + if (method.kind === 'constructor') continue; + + // Ignore methods already decorated with @Transactional + const decorators = method.decorators || []; + const hasTransactional = decorators.some( + (d) => + d.expression?.callee?.name === 'Transactional' || + d.expression?.name === 'Transactional', + ); + + if (!hasTransactional) { + const methodName = method.key?.name || ''; + context.report({ + node: method, + messageId: 'missingTransactional', + data: { name: methodName }, + }); + } + } + }, + }; + }, +}; diff --git a/apps/api/eslint.config.mjs b/apps/api/eslint.config.mjs index 4f0adc0..0de090f 100644 --- a/apps/api/eslint.config.mjs +++ b/apps/api/eslint.config.mjs @@ -1,11 +1,12 @@ // @ts-check -import { config as baseConfig } from "@repo/eslint-config/base"; -import tseslint from "typescript-eslint"; +import { config as baseConfig } from '@repo/eslint-config/base'; +import tseslint from 'typescript-eslint'; +import { plugin as customPlugin } from './eslint-rules/plugin.mjs'; export default tseslint.config( ...baseConfig, { - ignores: ["eslint.config.mjs", "src/generated/**"], + ignores: ['eslint.config.mjs', 'src/generated/**'], }, { languageOptions: { @@ -14,5 +15,11 @@ export default tseslint.config( tsconfigRootDir: import.meta.dirname, }, }, - } -); \ No newline at end of file + plugins: { + custom: customPlugin, + }, + rules: { + 'custom/require-transactional': 'warn', + }, + }, +); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index cc69df0..6e2c1da 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -34,11 +34,7 @@ import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-pr ClsModule.forRoot({ global: true, middleware: { - mount: true, // Mount CLS middleware globally - generateId: true, // Generate request ID - // Fix: Use proper type for tRPC compatibility - idGenerator: (req) => - req.headers['x-request-id'] ?? crypto.randomUUID(), + mount: true, }, plugins: [ new ClsPluginTransactional({ @@ -53,7 +49,7 @@ import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-pr }), }), new ClsPluginTransactional({ - connectionName: 'PRISMA_CONNECTION', + connectionName: PrismaModule.name, imports: [PrismaModule], adapter: new TransactionalAdapterPrisma({ prismaInjectionToken: PrismaService, diff --git a/apps/api/src/common/decorators/class/auto-transaction.decorator.ts b/apps/api/src/common/decorators/class/auto-transaction.decorator.ts index 07586c1..518e383 100644 --- a/apps/api/src/common/decorators/class/auto-transaction.decorator.ts +++ b/apps/api/src/common/decorators/class/auto-transaction.decorator.ts @@ -1,11 +1,7 @@ import 'reflect-metadata'; import { Transactional } from '@nestjs-cls/transactional'; -import { NO_TRANSACTION_KEY } from '../../constants'; +import { NO_TRANSACTION_KEY } from '../constants'; -/** - * Class decorator that automatically applies @Transactional to all methods, - * except those marked with @NoTransaction. - */ export function AutoTransaction(): ClassDecorator { return (target) => { const proto = target.prototype as Record; diff --git a/apps/api/src/common/constants.ts b/apps/api/src/common/decorators/constants.ts similarity index 100% rename from apps/api/src/common/constants.ts rename to apps/api/src/common/decorators/constants.ts diff --git a/apps/api/src/common/decorators/method/no-transaction.decorator.ts b/apps/api/src/common/decorators/method/no-transaction.decorator.ts index 651711d..c6fbee0 100644 --- a/apps/api/src/common/decorators/method/no-transaction.decorator.ts +++ b/apps/api/src/common/decorators/method/no-transaction.decorator.ts @@ -1,4 +1,4 @@ import { SetMetadata } from '@nestjs/common'; -import { NO_TRANSACTION_KEY } from '../../constants'; +import { NO_TRANSACTION_KEY } from '../constants'; export const NoTransaction = () => SetMetadata(NO_TRANSACTION_KEY, true); diff --git a/apps/api/src/common/guards/transaction-check.guard.ts b/apps/api/src/common/guards/transaction-check.guard.ts deleted file mode 100644 index 3781a4b..0000000 --- a/apps/api/src/common/guards/transaction-check.guard.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; - -/** - * Guard that validates if write methods have transactions enabled - * This runs at runtime to catch missing @Transactional decorators - * - * Enable globally in main.ts: - * ```typescript - * app.useGlobalGuards(new TransactionCheckGuard(app.get(Reflector))); - * ``` - */ -@Injectable() -export class TransactionCheckGuard implements CanActivate { - constructor(private reflector: Reflector) {} - - canActivate(context: ExecutionContext): boolean { - // Only check in development/test environments - if (process.env.NODE_ENV === 'production') { - return true; - } - - const handler = context.getHandler(); - const className = context.getClass().name; - const methodName = handler.name; - - // Skip if method is explicitly marked with @NoTransaction - const noTransaction = this.reflector.get( - 'no-transaction', - handler - ); - if (noTransaction) { - return true; - } - - // Check if method name suggests it's a write operation - const writePatterns = [ - /^create/i, - /^update/i, - /^delete/i, - /^remove/i, - /^save/i, - /^upsert/i, - /^insert/i, - ]; - - const isWriteMethod = writePatterns.some(pattern => - pattern.test(methodName) - ); - - if (isWriteMethod) { - // Check if @Transactional or @RequiresTransaction is present - const hasTransactional = this.reflector.get( - 'transactional', - handler - ); - const requiresTransaction = this.reflector.get( - 'requires-transaction', - handler - ); - - if (!hasTransactional && !requiresTransaction) { - console.warn( - `⚠️ [TransactionCheck] ${className}.${methodName}() appears to be a write method but doesn't have @Transactional decorator. ` + - `Add @Transactional() or @NoTransaction() to explicitly mark transaction behavior.` - ); - - // In strict mode, you can throw an error instead: - // throw new Error(`${className}.${methodName}() requires @Transactional decorator`); - } - } - - return true; - } -} diff --git a/apps/api/src/common/testing/transaction-test.helper.ts b/apps/api/src/common/testing/transaction-test.helper.ts deleted file mode 100644 index 4e20b6c..0000000 --- a/apps/api/src/common/testing/transaction-test.helper.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Test helper to verify that services have proper transaction decorators - * Use this in your unit tests to enforce transaction usage - */ - -/** - * Verify that a service class has @Transactional on write methods - * - * Usage in tests: - * ```typescript - * describe('UserService', () => { - * it('should have @Transactional on write methods', () => { - * verifyTransactionalMethods(UserService, ['createUser', 'updateUser', 'deleteUser']); - * }); - * }); - * ``` - */ -export function verifyTransactionalMethods( - serviceClass: any, - methodNames: string[] -): void { - methodNames.forEach(methodName => { - const hasMetadata = Reflect.getMetadata( - 'transactional', - serviceClass.prototype, - methodName - ) || Reflect.getMetadata( - 'requires-transaction', - serviceClass.prototype, - methodName - ); - - if (!hasMetadata) { - throw new Error( - `${serviceClass.name}.${methodName}() is missing @Transactional decorator` - ); - } - }); -} - -/** - * Scan a service and find all write methods without @Transactional - * - * Usage: - * ```typescript - * const missing = findMissingTransactionalDecorators(UserService); - * expect(missing).toEqual([]); - * ``` - */ -export function findMissingTransactionalDecorators(serviceClass: any): string[] { - const writePatterns = [ - /^create/i, - /^update/i, - /^delete/i, - /^remove/i, - /^save/i, - /^upsert/i, - ]; - - const methodNames = Object.getOwnPropertyNames(serviceClass.prototype) - .filter(name => { - if (name === 'constructor') return false; - - const method = serviceClass.prototype[name]; - return typeof method === 'function'; - }); - - const missingDecorators: string[] = []; - - methodNames.forEach(methodName => { - const isWriteMethod = writePatterns.some(pattern => - pattern.test(methodName) - ); - - if (isWriteMethod) { - const hasTransactional = Reflect.getMetadata( - 'transactional', - serviceClass.prototype, - methodName - ); - const requiresTransaction = Reflect.getMetadata( - 'requires-transaction', - serviceClass.prototype, - methodName - ); - const noTransaction = Reflect.getMetadata( - 'no-transaction', - serviceClass.prototype, - methodName - ); - - if (!hasTransactional && !requiresTransaction && !noTransaction) { - missingDecorators.push(methodName); - } - } - }); - - return missingDecorators; -} diff --git a/apps/api/src/middleware/transaction.middleware.ts b/apps/api/src/middleware/transaction.middleware.ts deleted file mode 100644 index 104ea7c..0000000 --- a/apps/api/src/middleware/transaction.middleware.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { Transactional } from '@nestjs-cls/transactional'; -import { MiddlewareOptions, TRPCMiddleware } from 'nestjs-trpc'; - -/** - * Global transaction middleware for tRPC - * Automatically wraps all mutations in transactions - * Queries run without transactions for better performance - */ -@Injectable() -export class TransactionMiddleware implements TRPCMiddleware { - @Transactional() - async use(opts: MiddlewareOptions): Promise { - const { next } = opts; - - console.log('transaction middleware invoked for', opts.type, opts.path); - - // Simply pass through - the @Transactional decorator handles everything - return next(); - } -} diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index 7e6d2c1..6f4d74b 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable custom/require-transactional */ import { Injectable } from '@nestjs/common'; import type { Auth } from 'better-auth'; import { EmailService } from '../email/email.service'; diff --git a/apps/api/src/modules/crud/crud.service.ts b/apps/api/src/modules/crud/crud.service.ts index 549fc91..de1b37b 100644 --- a/apps/api/src/modules/crud/crud.service.ts +++ b/apps/api/src/modules/crud/crud.service.ts @@ -8,24 +8,32 @@ import { // import { AutoTransaction } from '../../common/decorators/class/auto-transaction.decorator'; // import { NoTransaction } from '../../common/decorators/method/no-transaction.decorator'; import { Transactional } from '@nestjs-cls/transactional'; +import { PrismaModule } from '../prisma/prisma.module'; @Injectable() export class CrudService { constructor(private readonly crudRepository: CrudRepository) {} - @Transactional('PRISMA_CONNECTION') + @Transactional(PrismaModule.name) + async runInTransaction(fn: () => Promise): Promise { + return await fn(); + } + async createCrud(data: CreateCrudDto): Promise { - // Step 1: Create the CRUD item - const created = await this.crudRepository.create(data); - console.log(created); + return await this.runInTransaction(async () => { + // Step 1: Create the CRUD item + const created = await this.crudRepository.create(data); + console.log(created); - // Step 2: Simulate error to test rollback - // If this line is uncommented, the create above should rollback - throw new Error('Simulated delete error to test transaction rollback'); + // Step 2: Simulate error to test rollback + // If this line is uncommented, the create above should rollback + throw new Error('Simulated delete error to test transaction rollback'); - return created; + return created; + }); } + // eslint-disable-next-line custom/require-transactional async findAll(): Promise { return this.crudRepository.find(); } @@ -36,19 +44,14 @@ export class CrudService { return crud; } - @Transactional('PRISMA_CONNECTION') + @Transactional(PrismaModule.name) async update(id: string, data: UpdateCrudDto): Promise { const updated = await this.crudRepository.update(id, data); if (!updated) throw new NotFoundException(`Crud with id ${id} not found`); - - console.log(updated); - - throw new Error('Simulated delete error to test transaction rollback'); - return updated; } - @Transactional('PRISMA_CONNECTION') + @Transactional(PrismaModule.name) async delete(id: string): Promise { const deleted = await this.crudRepository.delete(id); if (!deleted) throw new NotFoundException(`Crud with id ${id} not found`); diff --git a/apps/api/src/modules/crud/repositories/crud.repository.ts b/apps/api/src/modules/crud/repositories/crud.repository.ts index 78851b3..5816120 100644 --- a/apps/api/src/modules/crud/repositories/crud.repository.ts +++ b/apps/api/src/modules/crud/repositories/crud.repository.ts @@ -8,7 +8,10 @@ import { CrudEntity, UpdateCrudDto, } from '../schemas/crud.schema'; -import { PrismaTransactionAdapter } from '../../prisma/prisma.module'; +import { + PrismaModule, + PrismaTransactionAdapter, +} from '../../prisma/prisma.module'; @Injectable() export class CrudRepository { @@ -17,7 +20,7 @@ export class CrudRepository { // ====================== constructor( - @InjectTransactionHost('PRISMA_CONNECTION') + @InjectTransactionHost(PrismaModule.name) private readonly prismaTxHost: TransactionHost, ) {} From 0a7a94ad313359d8c4c314a4a523265f94ce1b6b Mon Sep 17 00:00:00 2001 From: Anns Shahbaz Date: Tue, 9 Dec 2025 12:28:30 +0500 Subject: [PATCH 04/37] use mongo --- apps/api/src/app.module.ts | 2 +- apps/api/src/modules/crud/crud.service.ts | 8 +- .../crud/repositories/crud.repository.ts | 184 +++++++++--------- 3 files changed, 97 insertions(+), 97 deletions(-) diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 6e2c1da..6fc0c90 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -38,7 +38,7 @@ import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-pr }, plugins: [ new ClsPluginTransactional({ - connectionName: 'MONGOOSE_CONNECTION', + connectionName: MongooseModule.name, imports: [ // module in which the Connection instance is provided MongooseModule, diff --git a/apps/api/src/modules/crud/crud.service.ts b/apps/api/src/modules/crud/crud.service.ts index de1b37b..68395e5 100644 --- a/apps/api/src/modules/crud/crud.service.ts +++ b/apps/api/src/modules/crud/crud.service.ts @@ -8,13 +8,13 @@ import { // import { AutoTransaction } from '../../common/decorators/class/auto-transaction.decorator'; // import { NoTransaction } from '../../common/decorators/method/no-transaction.decorator'; import { Transactional } from '@nestjs-cls/transactional'; -import { PrismaModule } from '../prisma/prisma.module'; +import { MongooseModule } from '@nestjs/mongoose'; @Injectable() export class CrudService { constructor(private readonly crudRepository: CrudRepository) {} - @Transactional(PrismaModule.name) + @Transactional(MongooseModule.name) async runInTransaction(fn: () => Promise): Promise { return await fn(); } @@ -44,14 +44,14 @@ export class CrudService { return crud; } - @Transactional(PrismaModule.name) + @Transactional(MongooseModule.name) async update(id: string, data: UpdateCrudDto): Promise { const updated = await this.crudRepository.update(id, data); if (!updated) throw new NotFoundException(`Crud with id ${id} not found`); return updated; } - @Transactional(PrismaModule.name) + @Transactional(MongooseModule.name) async delete(id: string): Promise { const deleted = await this.crudRepository.delete(id); if (!deleted) throw new NotFoundException(`Crud with id ${id} not found`); diff --git a/apps/api/src/modules/crud/repositories/crud.repository.ts b/apps/api/src/modules/crud/repositories/crud.repository.ts index 5816120..5ec4dfd 100644 --- a/apps/api/src/modules/crud/repositories/crud.repository.ts +++ b/apps/api/src/modules/crud/repositories/crud.repository.ts @@ -1,118 +1,118 @@ -import { Injectable } from '@nestjs/common'; -import { - InjectTransactionHost, - TransactionHost, -} from '@nestjs-cls/transactional'; -import { - CreateCrudDto, - CrudEntity, - UpdateCrudDto, -} from '../schemas/crud.schema'; -import { - PrismaModule, - PrismaTransactionAdapter, -} from '../../prisma/prisma.module'; - -@Injectable() -export class CrudRepository { - // ====================== - // Prisma Implementation - // ====================== - - constructor( - @InjectTransactionHost(PrismaModule.name) - private readonly prismaTxHost: TransactionHost, - ) {} - - async find(): Promise { - return this.prismaTxHost.tx.crud.findMany({ - orderBy: { createdAt: 'desc' }, - }); - } - - async findOne(id: string): Promise { - return this.prismaTxHost.tx.crud.findUnique({ - where: { id }, - }); - } - - async create(data: CreateCrudDto): Promise { - return this.prismaTxHost.tx.crud.create({ data }); - } - - async update(id: string, data: UpdateCrudDto): Promise { - return this.prismaTxHost.tx.crud.update({ - where: { id }, - data, - }); - } - - async delete(id: string): Promise { - return this.prismaTxHost.tx.crud.delete({ where: { id } }); - } -} - -// import { InjectModel } from '@nestjs/mongoose'; -// import { Model } from 'mongoose'; -// import { CrudDocument } from '../models/crud.model'; +// import { Injectable } from '@nestjs/common'; +// import { +// InjectTransactionHost, +// TransactionHost, +// } from '@nestjs-cls/transactional'; // import { // CreateCrudDto, // CrudEntity, // UpdateCrudDto, // } from '../schemas/crud.schema'; // import { -// InjectTransactionHost, -// TransactionHost, -// } from '@nestjs-cls/transactional'; -// import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter-mongoose'; +// PrismaModule, +// PrismaTransactionAdapter, +// } from '../../prisma/prisma.module'; // +// @Injectable() // export class CrudRepository { +// // ====================== +// // Prisma Implementation +// // ====================== +// // constructor( -// @InjectModel(CrudDocument.name) -// private readonly crudModel: Model, -// @InjectTransactionHost('MONGOOSE_CONNECTION') -// private readonly mongoTxHost: TransactionHost, +// @InjectTransactionHost(PrismaModule.name) +// private readonly prismaTxHost: TransactionHost, // ) {} // // async find(): Promise { -// const docs = await this.crudModel -// .find() -// .sort({ createdAt: -1 }) -// .session(this.mongoTxHost.tx); -// return docs.map((doc) => this.toEntity(doc)); +// return this.prismaTxHost.tx.crud.findMany({ +// orderBy: { createdAt: 'desc' }, +// }); // } // // async findOne(id: string): Promise { -// const doc = await this.crudModel.findById(id).session(this.mongoTxHost.tx); -// return doc ? this.toEntity(doc) : null; +// return this.prismaTxHost.tx.crud.findUnique({ +// where: { id }, +// }); // } // // async create(data: CreateCrudDto): Promise { -// const doc = new CrudDocument(data); -// await doc.save({ session: this.mongoTxHost.tx }); -// return this.toEntity(doc); +// return this.prismaTxHost.tx.crud.create({ data }); // } // // async update(id: string, data: UpdateCrudDto): Promise { -// const doc = await this.crudModel -// .findByIdAndUpdate(id, data, { new: true }) -// .session(this.mongoTxHost.tx); -// return doc ? this.toEntity(doc) : null; +// return this.prismaTxHost.tx.crud.update({ +// where: { id }, +// data, +// }); // } // // async delete(id: string): Promise { -// const doc = await this.crudModel -// .findByIdAndDelete(id) -// .session(this.mongoTxHost.tx); -// return doc ? this.toEntity(doc) : null; -// } -// -// private toEntity(doc: CrudDocument): CrudEntity { -// return { -// id: doc._id.toString(), -// content: doc.content, -// createdAt: doc.createdAt, -// updatedAt: doc.updatedAt, -// }; +// return this.prismaTxHost.tx.crud.delete({ where: { id } }); // } // } + +import { InjectModel, MongooseModule } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { CrudDocument } from '../models/crud.model'; +import { + CreateCrudDto, + CrudEntity, + UpdateCrudDto, +} from '../schemas/crud.schema'; +import { + InjectTransactionHost, + TransactionHost, +} from '@nestjs-cls/transactional'; +import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter-mongoose'; + +export class CrudRepository { + constructor( + @InjectModel(CrudDocument.name) + private readonly crudModel: Model, + @InjectTransactionHost(MongooseModule.name) + private readonly mongoTxHost: TransactionHost, + ) {} + + async find(): Promise { + const docs = await this.crudModel + .find() + .sort({ createdAt: -1 }) + .session(this.mongoTxHost.tx); + return docs.map((doc) => this.toEntity(doc)); + } + + async findOne(id: string): Promise { + const doc = await this.crudModel.findById(id).session(this.mongoTxHost.tx); + return doc ? this.toEntity(doc) : null; + } + + async create(data: CreateCrudDto): Promise { + const doc = new CrudDocument(data); + await doc.save({ session: this.mongoTxHost.tx }); + return this.toEntity(doc); + } + + async update(id: string, data: UpdateCrudDto): Promise { + const doc = await this.crudModel + .findByIdAndUpdate(id, data, { new: true }) + .session(this.mongoTxHost.tx); + return doc ? this.toEntity(doc) : null; + } + + async delete(id: string): Promise { + const doc = await this.crudModel + .findByIdAndDelete(id) + .session(this.mongoTxHost.tx); + return doc ? this.toEntity(doc) : null; + } + + private toEntity(doc: CrudDocument): CrudEntity { + return { + id: doc._id.toString(), + content: doc.content, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }; + } +} From d043d5184cbed8231f1f7ed47713f785610ca8f0 Mon Sep 17 00:00:00 2001 From: Anns Shahbaz Date: Tue, 9 Dec 2025 16:22:33 +0500 Subject: [PATCH 05/37] class decorator --- .../eslint-rules/require-transactional.mjs | 45 +-- .../src/common/base-transactional.service.ts | 23 -- .../class/auto-transaction.decorator.ts | 37 --- .../class/transactional.decorator.ts | 296 ++++++++++++++++++ .../src/{common => }/decorators/constants.ts | 0 .../method/no-transaction.decorator.ts | 1 + apps/api/src/modules/crud/crud.service.ts | 33 +- .../crud/repositories/crud.repository.ts | 2 +- apps/api/src/modules/email/email.service.ts | 5 +- apps/api/src/modules/prisma/prisma.service.ts | 1 + .../mongoose/base.mongo.entity.ts | 0 .../interfaces/base.abstract.repository.ts | 0 .../interfaces/base.interface.repository.ts | 0 13 files changed, 327 insertions(+), 116 deletions(-) delete mode 100644 apps/api/src/common/base-transactional.service.ts delete mode 100644 apps/api/src/common/decorators/class/auto-transaction.decorator.ts create mode 100644 apps/api/src/decorators/class/transactional.decorator.ts rename apps/api/src/{common => }/decorators/constants.ts (100%) rename apps/api/src/{common => }/decorators/method/no-transaction.decorator.ts (66%) create mode 100644 apps/api/src/repositories/mongoose/base.mongo.entity.ts create mode 100644 apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts create mode 100644 apps/api/src/repositories/mongoose/interfaces/base.interface.repository.ts diff --git a/apps/api/eslint-rules/require-transactional.mjs b/apps/api/eslint-rules/require-transactional.mjs index b34b53d..7ae4611 100644 --- a/apps/api/eslint-rules/require-transactional.mjs +++ b/apps/api/eslint-rules/require-transactional.mjs @@ -3,12 +3,12 @@ export const requireTransactional = { type: 'problem', docs: { description: - 'Warn if a service method is missing @Transactional decorator', + 'Warn if a service class is missing @Transactional decorator', recommended: 'warn', }, messages: { - missingTransactional: - "Service method '{{name}}' is missing @Transactional decorator.", + missingClassTransactional: + "Service class '{{name}}' is missing @Transactional decorator.", }, schema: [], }, @@ -16,35 +16,22 @@ export const requireTransactional = { return { ClassDeclaration(node) { const className = node.id?.name || ''; + // Only check classes ending with 'Service' if (!className.endsWith('Service')) return; - for (const method of node.body.body) { - // Only process methods (ignore constructors or properties) - if ( - method.type !== 'MethodDefinition' && - method.type !== 'TSMethodDefinition' - ) - continue; + const decorators = node.decorators || []; + const hasTransactional = decorators.some( + (d) => + d.expression?.callee?.name === 'Transactional' || + d.expression?.name === 'Transactional', + ); - // Ignore constructors - if (method.kind === 'constructor') continue; - - // Ignore methods already decorated with @Transactional - const decorators = method.decorators || []; - const hasTransactional = decorators.some( - (d) => - d.expression?.callee?.name === 'Transactional' || - d.expression?.name === 'Transactional', - ); - - if (!hasTransactional) { - const methodName = method.key?.name || ''; - context.report({ - node: method, - messageId: 'missingTransactional', - data: { name: methodName }, - }); - } + if (!hasTransactional) { + context.report({ + node, + messageId: 'missingClassTransactional', + data: { name: className }, + }); } }, }; diff --git a/apps/api/src/common/base-transactional.service.ts b/apps/api/src/common/base-transactional.service.ts deleted file mode 100644 index d7039dc..0000000 --- a/apps/api/src/common/base-transactional.service.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Transactional } from '@nestjs-cls/transactional'; - -/** - * Base service class that provides automatic transaction wrapping - * All public methods that modify data should use the transaction wrapper - */ -export abstract class BaseTransactionalService { - /** - * Wrap any async operation in a transaction - * Use this in your service methods that need transactions - * - * @example - * async createUser(data: CreateUserDto) { - * return this.runInTransaction(() => { - * // All operations here are transactional - * }); - * } - */ - @Transactional() - protected async runInTransaction(operation: () => Promise): Promise { - return operation(); - } -} diff --git a/apps/api/src/common/decorators/class/auto-transaction.decorator.ts b/apps/api/src/common/decorators/class/auto-transaction.decorator.ts deleted file mode 100644 index 518e383..0000000 --- a/apps/api/src/common/decorators/class/auto-transaction.decorator.ts +++ /dev/null @@ -1,37 +0,0 @@ -import 'reflect-metadata'; -import { Transactional } from '@nestjs-cls/transactional'; -import { NO_TRANSACTION_KEY } from '../constants'; - -export function AutoTransaction(): ClassDecorator { - return (target) => { - const proto = target.prototype as Record; - - for (const name of Object.getOwnPropertyNames(proto)) { - if (name === 'constructor') continue; - - const descriptor = Object.getOwnPropertyDescriptor(proto, name); - if (!descriptor || typeof descriptor.value !== 'function') continue; - - const keys = Reflect.getMetadataKeys(proto, name); - console.log(keys); - - // Skip methods marked with @NoTransaction - const noTransaction = Reflect.hasMetadata( - NO_TRANSACTION_KEY, - proto, - name, - ); - - if (noTransaction) { - continue; - } - - const transactionalDescriptor = Transactional()(proto, name, descriptor); - Object.defineProperty(proto, name, transactionalDescriptor || descriptor); - - console.log( - `[AutoTransaction] Applied @Transactional to method: ${name}`, - ); - } - }; -} diff --git a/apps/api/src/decorators/class/transactional.decorator.ts b/apps/api/src/decorators/class/transactional.decorator.ts new file mode 100644 index 0000000..3b4dcf0 --- /dev/null +++ b/apps/api/src/decorators/class/transactional.decorator.ts @@ -0,0 +1,296 @@ +import 'reflect-metadata'; +import { Transactional as ClsTransactional } from '@nestjs-cls/transactional'; +import { NO_TRANSACTION_KEY } from '../constants'; +import { writeFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; + +// Logger utility for transaction validation +class TransactionalLogger { + private processDir: string; + private processId: string; + + constructor() { + const baseLogDir = join(process.cwd(), 'tmp', 'transaction'); + + // Create unique process ID using timestamp + random string + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const random = Math.random().toString(36).substring(2, 8); + this.processId = `${timestamp}_${random}`; + + // Create unique directory for this process + this.processDir = join(baseLogDir, this.processId); + mkdirSync(this.processDir, { recursive: true }); + + this.log('SESSION', 'Transaction validation session started'); + this.log('SESSION', `Process ID: ${this.processId}`); + this.log('SESSION', `Log directory: ${this.processDir}`); + } + + log(className: string, message: string): void { + const timestamp = new Date().toISOString(); + const logMessage = `[${timestamp}] ${message}`; + + // Use a single log file per class (without timestamp in filename) + const classLogFile = join(this.processDir, `${className}.log`); + + // Append to class-specific file + writeFileSync(classLogFile, logMessage + '\n', { + flag: 'a', + encoding: 'utf-8', + }); + + console.log(`[${timestamp}] [${className}] ${message}`); + } + + getProcessDir(): string { + return this.processDir; + } + + getProcessId(): string { + return this.processId; + } +} + +const logger = new TransactionalLogger(); + +/** + * Validates that a descriptor was successfully modified by the @Transactional decorator + * ClsTransactional mutates the descriptor in place by replacing descriptor.value with a Proxy. + * We validate that the mutation occurred by comparing the original function reference. + */ +function validateTransactionalApplication( + className: string, + methodName: string, + originalDescriptor: PropertyDescriptor, + mutatedDescriptor: PropertyDescriptor, +): PropertyDescriptor { + const descriptorToValidate = mutatedDescriptor; + + // Validate that the descriptor has required properties + if (!descriptorToValidate.value && !descriptorToValidate.get) { + throw new Error( + `@Transactional failed to apply on ${className}.${methodName}: ` + + `Descriptor has no value or getter after decoration`, + ); + } + + // Validate that the method is still callable + if ( + descriptorToValidate.value && + typeof descriptorToValidate.value !== 'function' + ) { + throw new Error( + `@Transactional failed to apply on ${className}.${methodName}: ` + + `Descriptor value is not a function (got ${typeof descriptorToValidate.value})`, + ); + } + + // CRITICAL: Validate that the function was actually wrapped (changed reference) + // If the decorator returns void, it should have mutated the descriptor in place + // If it returns a new descriptor, the function reference should be different + const originalFunction = originalDescriptor.value as + | ((...args: unknown[]) => unknown) + | undefined; + const finalFunction = descriptorToValidate.value as + | ((...args: unknown[]) => unknown) + | undefined; + + if (originalFunction && finalFunction) { + // Check if the function reference changed (indicates wrapping occurred) + const functionWasWrapped = originalFunction !== finalFunction; + + // Check if the function has metadata added by ClsTransactional + // The @nestjs-cls/transactional library may add metadata or wrap the function + const hasClsMetadata = + Reflect.hasMetadata('__cls_transactional__', finalFunction as object) || + Reflect.getMetadataKeys(finalFunction as object).some((key) => + String(key).includes('transactional'), + ); + + // Check if function name or properties suggest wrapping + const originalName = originalFunction.name || ''; + const finalName = finalFunction.name || ''; + const functionNameSuggestsWrapping = + finalName.includes('wrapped') || + finalName.includes('proxy') || + finalName === '' || // Arrow functions used in wrapping + finalName !== originalName; + + // At least ONE of these should be true if decoration was successful + if ( + !functionWasWrapped && + !hasClsMetadata && + !functionNameSuggestsWrapping + ) { + throw new Error( + `@Transactional may not have been applied on ${className}.${methodName}: ` + + `Function reference unchanged and no transactional metadata detected. ` + + `Original: ${originalName}, Final: ${finalName}`, + ); + } + + // Log what we detected for debugging + if (functionWasWrapped) { + logger.log( + className, + `✓ ${methodName}: Function reference changed (wrapped)`, + ); + } else if (hasClsMetadata) { + logger.log(className, `✓ ${methodName}: CLS metadata detected`); + } else if (functionNameSuggestsWrapping) { + logger.log( + className, + `✓ ${methodName}: Function name changed (${originalName} → ${finalName})`, + ); + } + } + + // Validate that writable/configurable flags are appropriate + if ( + descriptorToValidate.value && + descriptorToValidate.writable === false && + originalDescriptor.writable === true + ) { + throw new Error( + `@Transactional failed to apply on ${className}.${methodName}: ` + + `Method became non-writable after decoration`, + ); + } + + return descriptorToValidate; +} + +/** + * Validates that the property was successfully defined on the prototype + */ +function validatePropertyDefinition( + className: string, + methodName: string, + proto: Record, +): void { + const finalDescriptor = Object.getOwnPropertyDescriptor(proto, methodName); + + if (!finalDescriptor) { + throw new Error( + `@Transactional failed to apply on ${className}.${methodName}: ` + + `Property descriptor not found after Object.defineProperty`, + ); + } + + if (!finalDescriptor.value && !finalDescriptor.get) { + throw new Error( + `@Transactional failed to apply on ${className}.${methodName}: ` + + `Final property has no value or getter`, + ); + } + + if (finalDescriptor.value && typeof finalDescriptor.value !== 'function') { + throw new Error( + `@Transactional failed to apply on ${className}.${methodName}: ` + + `Final property value is not a function (got ${typeof finalDescriptor.value})`, + ); + } +} + +export function Transactional(connectionName?: string): ClassDecorator { + return (target) => { + const className = target.name; + const proto = target.prototype as Record; + + // Validate that the target has a prototype + if (!proto) { + throw new Error( + `@Transactional failed on ${className}: Target has no prototype`, + ); + } + + const methodsProcessed: string[] = []; + const methodsSkipped: string[] = []; + const methodsWithNoTransaction: string[] = []; + + for (const name of Object.getOwnPropertyNames(proto)) { + if (name === 'constructor') { + continue; + } + + const descriptor = Object.getOwnPropertyDescriptor(proto, name); + if (!descriptor) { + methodsSkipped.push(`${name} (no descriptor)`); + continue; + } + + if (typeof descriptor.value !== 'function') { + methodsSkipped.push(`${name} (not a function)`); + continue; + } + + const noTransaction = Reflect.hasMetadata( + NO_TRANSACTION_KEY, + descriptor.value as object, + ); + + if (noTransaction) { + methodsWithNoTransaction.push(name); + continue; + } + + // Save the original function reference BEFORE applying the decorator + const originalFunctionRef = descriptor.value as + | ((...args: unknown[]) => unknown) + | undefined; + + // Apply the @Transactional decorator + // Note: ClsTransactional ALWAYS mutates the descriptor in place and returns void + ClsTransactional(connectionName)(proto, name, descriptor); + + // Validate that the decorator was applied correctly + // The descriptor has been mutated in place, so we validate it directly + const validatedDescriptor = validateTransactionalApplication( + className, + name, + { + ...descriptor, + value: originalFunctionRef, + } as PropertyDescriptor, // Pass original for comparison + descriptor, // Pass the mutated descriptor + ); + + // Define the property with the validated descriptor + Object.defineProperty(proto, name, validatedDescriptor); + + // Validate that the property was defined correctly + validatePropertyDefinition(className, name, proto); + + methodsProcessed.push(name); + } + + // Log summary + logger.log(className, `Summary:`); + logger.log( + className, + ` - Methods wrapped: ${methodsProcessed.length} (${methodsProcessed.join(', ') || 'none'})`, + ); + logger.log( + className, + ` - Methods with @NoTransaction: ${methodsWithNoTransaction.length} (${methodsWithNoTransaction.join(', ') || 'none'})`, + ); + logger.log( + className, + ` - Methods skipped: ${methodsSkipped.length} (${methodsSkipped.join(', ') || 'none'})`, + ); + + // Validation: ensure at least some methods were processed (optional strict check) + if ( + methodsProcessed.length === 0 && + methodsWithNoTransaction.length === 0 + ) { + logger.log( + className, + `⚠ Warning: No methods were wrapped. This may indicate the decorator is applied to a class with no methods.`, + ); + } + + // Log process directory info + logger.log(className, `Process directory: ${logger.getProcessDir()}`); + }; +} diff --git a/apps/api/src/common/decorators/constants.ts b/apps/api/src/decorators/constants.ts similarity index 100% rename from apps/api/src/common/decorators/constants.ts rename to apps/api/src/decorators/constants.ts diff --git a/apps/api/src/common/decorators/method/no-transaction.decorator.ts b/apps/api/src/decorators/method/no-transaction.decorator.ts similarity index 66% rename from apps/api/src/common/decorators/method/no-transaction.decorator.ts rename to apps/api/src/decorators/method/no-transaction.decorator.ts index c6fbee0..defe48d 100644 --- a/apps/api/src/common/decorators/method/no-transaction.decorator.ts +++ b/apps/api/src/decorators/method/no-transaction.decorator.ts @@ -1,4 +1,5 @@ import { SetMetadata } from '@nestjs/common'; import { NO_TRANSACTION_KEY } from '../constants'; +// Decorator to indicate that a method should not be wrapped in a database transaction export const NoTransaction = () => SetMetadata(NO_TRANSACTION_KEY, true); diff --git a/apps/api/src/modules/crud/crud.service.ts b/apps/api/src/modules/crud/crud.service.ts index 68395e5..aa729f5 100644 --- a/apps/api/src/modules/crud/crud.service.ts +++ b/apps/api/src/modules/crud/crud.service.ts @@ -5,60 +5,45 @@ import { CrudEntity, UpdateCrudDto, } from './schemas/crud.schema'; -// import { AutoTransaction } from '../../common/decorators/class/auto-transaction.decorator'; -// import { NoTransaction } from '../../common/decorators/method/no-transaction.decorator'; -import { Transactional } from '@nestjs-cls/transactional'; +import { NoTransaction } from '../../decorators/method/no-transaction.decorator'; +import { Transactional } from '../../decorators/class/transactional.decorator'; import { MongooseModule } from '@nestjs/mongoose'; @Injectable() +@Transactional(MongooseModule.name) export class CrudService { constructor(private readonly crudRepository: CrudRepository) {} - @Transactional(MongooseModule.name) - async runInTransaction(fn: () => Promise): Promise { - return await fn(); - } - async createCrud(data: CreateCrudDto): Promise { - return await this.runInTransaction(async () => { - // Step 1: Create the CRUD item - const created = await this.crudRepository.create(data); - console.log(created); - - // Step 2: Simulate error to test rollback - // If this line is uncommented, the create above should rollback - throw new Error('Simulated delete error to test transaction rollback'); - - return created; - }); + const created = await this.crudRepository.create(data); + console.log(created); + throw new Error('Simulated delete error to test transaction rollback'); + return created; } - // eslint-disable-next-line custom/require-transactional + @NoTransaction() async findAll(): Promise { return this.crudRepository.find(); } + @NoTransaction() async findOne(id: string): Promise { const crud = await this.crudRepository.findOne(id); if (!crud) throw new NotFoundException(`Crud with id ${id} not found`); return crud; } - @Transactional(MongooseModule.name) async update(id: string, data: UpdateCrudDto): Promise { const updated = await this.crudRepository.update(id, data); if (!updated) throw new NotFoundException(`Crud with id ${id} not found`); return updated; } - @Transactional(MongooseModule.name) async delete(id: string): Promise { const deleted = await this.crudRepository.delete(id); if (!deleted) throw new NotFoundException(`Crud with id ${id} not found`); - console.log(deleted); throw new Error('Simulated delete error to test transaction rollback'); - return deleted; } } diff --git a/apps/api/src/modules/crud/repositories/crud.repository.ts b/apps/api/src/modules/crud/repositories/crud.repository.ts index 5ec4dfd..ad65c5b 100644 --- a/apps/api/src/modules/crud/repositories/crud.repository.ts +++ b/apps/api/src/modules/crud/repositories/crud.repository.ts @@ -88,7 +88,7 @@ export class CrudRepository { } async create(data: CreateCrudDto): Promise { - const doc = new CrudDocument(data); + const doc = new this.crudModel(data); await doc.save({ session: this.mongoTxHost.tx }); return this.toEntity(doc); } diff --git a/apps/api/src/modules/email/email.service.ts b/apps/api/src/modules/email/email.service.ts index f93e004..61469b6 100644 --- a/apps/api/src/modules/email/email.service.ts +++ b/apps/api/src/modules/email/email.service.ts @@ -1,15 +1,16 @@ +/* eslint-disable custom/require-transactional */ import { Injectable } from '@nestjs/common'; import { - Logger, COMPANY_NAME, + Logger, PRODUCT_NAME, SUPPORT_EMAIL, } from '@repo/utils-core'; import { Resend } from 'resend'; import { EmailTemplateName, - SendEmailArgs, RenderedEmail, + SendEmailArgs, } from './types/email.types'; import { renderEmail } from './email.renderer'; diff --git a/apps/api/src/modules/prisma/prisma.service.ts b/apps/api/src/modules/prisma/prisma.service.ts index 06c2426..0a1c5e9 100644 --- a/apps/api/src/modules/prisma/prisma.service.ts +++ b/apps/api/src/modules/prisma/prisma.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable custom/require-transactional */ import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { PrismaClient } from '@repo/prisma-db'; diff --git a/apps/api/src/repositories/mongoose/base.mongo.entity.ts b/apps/api/src/repositories/mongoose/base.mongo.entity.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts b/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/repositories/mongoose/interfaces/base.interface.repository.ts b/apps/api/src/repositories/mongoose/interfaces/base.interface.repository.ts new file mode 100644 index 0000000..e69de29 From 62ff66a800db5f19c3027ee522d3e07824310e3d Mon Sep 17 00:00:00 2001 From: Anns Shahbaz Date: Tue, 9 Dec 2025 22:07:12 +0500 Subject: [PATCH 06/37] abstraction of mongo repository --- apps/api/src/modules/crud/crud.module.ts | 6 +- apps/api/src/modules/crud/crud.service.ts | 8 +-- .../mongoose/crud.mongo.repository.ts | 33 +++++++++++ .../src/modules/crud/schemas/crud.schema.ts | 11 ++-- .../mongoose/base.mongo.entity.ts | 11 ++++ .../interfaces/base.abstract.repository.ts | 58 +++++++++++++++++++ .../interfaces/base.interface.repository.ts | 13 +++++ apps/api/src/schemas/base.schema.ts | 7 +++ packages/trpc/src/server/server.ts | 15 +++-- 9 files changed, 145 insertions(+), 17 deletions(-) create mode 100644 apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts diff --git a/apps/api/src/modules/crud/crud.module.ts b/apps/api/src/modules/crud/crud.module.ts index 2a7a149..5089b32 100644 --- a/apps/api/src/modules/crud/crud.module.ts +++ b/apps/api/src/modules/crud/crud.module.ts @@ -2,9 +2,9 @@ import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; import { PrismaModule } from '../prisma/prisma.module'; import { CrudDocument, CrudSchema } from './models/crud.model'; -import { CrudRepository } from './repositories/crud.repository'; import { CrudService } from './crud.service'; import { CrudRouter } from './crud.router'; +import { CrudMongoRepository } from './repositories/mongoose/crud.mongo.repository'; @Module({ imports: [ @@ -13,7 +13,7 @@ import { CrudRouter } from './crud.router'; { name: CrudDocument.name, schema: CrudSchema }, ]), ], - providers: [CrudRepository, CrudService, CrudRouter], - exports: [CrudRepository, CrudService], + providers: [CrudMongoRepository, CrudService, CrudRouter], + exports: [CrudMongoRepository, CrudService], }) export class CrudModule {} diff --git a/apps/api/src/modules/crud/crud.service.ts b/apps/api/src/modules/crud/crud.service.ts index aa729f5..42b2a71 100644 --- a/apps/api/src/modules/crud/crud.service.ts +++ b/apps/api/src/modules/crud/crud.service.ts @@ -1,5 +1,4 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import { CrudRepository } from './repositories/crud.repository'; import { CreateCrudDto, CrudEntity, @@ -8,11 +7,12 @@ import { import { NoTransaction } from '../../decorators/method/no-transaction.decorator'; import { Transactional } from '../../decorators/class/transactional.decorator'; import { MongooseModule } from '@nestjs/mongoose'; +import { CrudMongoRepository } from './repositories/mongoose/crud.mongo.repository'; @Injectable() @Transactional(MongooseModule.name) export class CrudService { - constructor(private readonly crudRepository: CrudRepository) {} + constructor(private readonly crudRepository: CrudMongoRepository) {} async createCrud(data: CreateCrudDto): Promise { const created = await this.crudRepository.create(data); @@ -28,7 +28,7 @@ export class CrudService { @NoTransaction() async findOne(id: string): Promise { - const crud = await this.crudRepository.findOne(id); + const crud = await this.crudRepository.findOneById(id); if (!crud) throw new NotFoundException(`Crud with id ${id} not found`); return crud; } @@ -40,7 +40,7 @@ export class CrudService { } async delete(id: string): Promise { - const deleted = await this.crudRepository.delete(id); + const deleted = await this.crudRepository.deleteById(id); if (!deleted) throw new NotFoundException(`Crud with id ${id} not found`); console.log(deleted); throw new Error('Simulated delete error to test transaction rollback'); diff --git a/apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts b/apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts new file mode 100644 index 0000000..8e88193 --- /dev/null +++ b/apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts @@ -0,0 +1,33 @@ +import { InjectModel, MongooseModule } from '@nestjs/mongoose'; +import { BaseRepositoryMongo } from '../../../../repositories/mongoose/interfaces/base.abstract.repository'; +import { CrudDocument } from '../../models/crud.model'; +import { CrudEntity } from '../../schemas/crud.schema'; +import { Model } from 'mongoose'; +import { + InjectTransactionHost, + TransactionHost, +} from '@nestjs-cls/transactional'; +import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter-mongoose'; + +export class CrudMongoRepository extends BaseRepositoryMongo< + CrudDocument, + CrudEntity +> { + constructor( + @InjectModel(CrudDocument.name) + crudModel: Model, + @InjectTransactionHost(MongooseModule.name) + mongoTxHost: TransactionHost, + ) { + super(crudModel, mongoTxHost); + } + + protected toDomainEntity(dbEntity: CrudDocument): CrudEntity { + return { + id: dbEntity._id.toString(), + content: dbEntity.content.toString(), + createdAt: dbEntity.createdAt, + updatedAt: dbEntity.updatedAt, + }; + } +} diff --git a/apps/api/src/modules/crud/schemas/crud.schema.ts b/apps/api/src/modules/crud/schemas/crud.schema.ts index 1b51a3b..07c58e9 100644 --- a/apps/api/src/modules/crud/schemas/crud.schema.ts +++ b/apps/api/src/modules/crud/schemas/crud.schema.ts @@ -1,11 +1,12 @@ import { z } from 'zod'; -import { ZBaseRequest, ZBaseResponse } from '../../../schemas/base.schema'; +import { + BaseEntity, + ZBaseRequest, + ZBaseResponse, +} from '../../../schemas/base.schema'; -export const ZCrudEntity = z.object({ - id: z.string(), +export const ZCrudEntity = BaseEntity.extend({ content: z.string().min(1).max(1000), - createdAt: z.date(), - updatedAt: z.date(), }); export const ZCreateCrudDto = ZCrudEntity.pick({ diff --git a/apps/api/src/repositories/mongoose/base.mongo.entity.ts b/apps/api/src/repositories/mongoose/base.mongo.entity.ts index e69de29..620be92 100644 --- a/apps/api/src/repositories/mongoose/base.mongo.entity.ts +++ b/apps/api/src/repositories/mongoose/base.mongo.entity.ts @@ -0,0 +1,11 @@ +import { Document } from 'mongoose'; +import { Prop, Schema } from '@nestjs/mongoose'; + +@Schema({ timestamps: true }) +export class MongoDbEntity extends Document { + @Prop() + createdAt: Date; + + @Prop() + updatedAt: Date; +} diff --git a/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts b/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts index e69de29..837e8bf 100644 --- a/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts +++ b/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts @@ -0,0 +1,58 @@ +import { MongoDbEntity } from '../base.mongo.entity'; +import { MongooseRepositoryInterface } from './base.interface.repository'; +import { Model } from 'mongoose'; +import { Entity } from '../../../schemas/base.schema'; +import { TransactionHost } from '@nestjs-cls/transactional'; +import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter-mongoose'; + +export abstract class BaseRepositoryMongo< + TDbEntity extends MongoDbEntity, + TDomainEntity extends Entity, +> implements MongooseRepositoryInterface +{ + protected readonly model: Model; + protected readonly mongoTxHost: TransactionHost; + + protected constructor( + model: Model, + mongoTxHost: TransactionHost, + ) { + this.model = model; + this.mongoTxHost = mongoTxHost; + } + + async create(entity: Partial): Promise { + const doc = new this.model(entity); + await doc.save({ session: this.mongoTxHost.tx }); + return this.toDomainEntity(doc); + } + + async find(): Promise { + const docs = await this.model.find().session(this.mongoTxHost.tx); + return docs.map((doc) => this.toDomainEntity(doc)); + } + + async findOneById(id: string): Promise { + const doc = await this.model.findById(id).session(this.mongoTxHost.tx); + return doc ? this.toDomainEntity(doc) : null; + } + + async deleteById(id: string): Promise { + const deletedDoc = await this.model + .findByIdAndDelete(id) + .session(this.mongoTxHost.tx); + return deletedDoc ? this.toDomainEntity(deletedDoc) : null; + } + + async update( + id: string, + update: Partial, + ): Promise { + const updatedDoc = await this.model + .findByIdAndUpdate(id, update, { new: true }) + .session(this.mongoTxHost.tx); + return updatedDoc ? this.toDomainEntity(updatedDoc) : null; + } + + protected abstract toDomainEntity(dbEntity: TDbEntity): TDomainEntity; +} diff --git a/apps/api/src/repositories/mongoose/interfaces/base.interface.repository.ts b/apps/api/src/repositories/mongoose/interfaces/base.interface.repository.ts index e69de29..4331d4f 100644 --- a/apps/api/src/repositories/mongoose/interfaces/base.interface.repository.ts +++ b/apps/api/src/repositories/mongoose/interfaces/base.interface.repository.ts @@ -0,0 +1,13 @@ +import { MongoDbEntity } from '../base.mongo.entity'; +import { Entity } from '../../../schemas/base.schema'; + +export interface MongooseRepositoryInterface< + TDbEntity extends MongoDbEntity, + TDomainEntity extends Entity, +> { + create(entity: Partial): Promise; + findOneById(id: string): Promise; + find(): Promise; + deleteById(id: string): Promise; + update(id: string, update: Partial): Promise; +} diff --git a/apps/api/src/schemas/base.schema.ts b/apps/api/src/schemas/base.schema.ts index 85ad944..855f4fd 100644 --- a/apps/api/src/schemas/base.schema.ts +++ b/apps/api/src/schemas/base.schema.ts @@ -10,5 +10,12 @@ export const ZBaseResponse = z.object({ message: z.string().optional(), }); +export const BaseEntity = z.object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), +}); + export type TBaseRequest = z.infer; export type TBaseResponse = z.infer; +export type Entity = z.infer; diff --git a/packages/trpc/src/server/server.ts b/packages/trpc/src/server/server.ts index 3090a09..3ba2522 100644 --- a/packages/trpc/src/server/server.ts +++ b/packages/trpc/src/server/server.ts @@ -11,9 +11,10 @@ const appRouter = t.router({ timestamp: z.number().optional(), }).merge(z.object({ id: z.string(), - content: z.string().min(1).max(1000), createdAt: z.date(), updatedAt: z.date(), + }).extend({ + content: z.string().min(1).max(1000), }).pick({ content: true, }))).output(z.object({ @@ -34,9 +35,10 @@ const appRouter = t.router({ }).extend({ cruds: z.array(z.object({ id: z.string(), - content: z.string().min(1).max(1000), createdAt: z.date(), updatedAt: z.date(), + }).extend({ + content: z.string().min(1).max(1000), })), total: z.number().int().nonnegative(), limit: z.number().int().positive(), @@ -49,9 +51,10 @@ const appRouter = t.router({ id: z.string(), })).output(z.object({ id: z.string(), - content: z.string().min(1).max(1000), createdAt: z.date(), updatedAt: z.date(), + }).extend({ + content: z.string().min(1).max(1000), }).nullable()).query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), updateCrud: publicProcedure.input(z.object({ requestId: z.string().uuid().optional(), @@ -60,9 +63,10 @@ const appRouter = t.router({ id: z.string(), data: z.object({ id: z.string(), - content: z.string().min(1).max(1000), createdAt: z.date(), updatedAt: z.date(), + }).extend({ + content: z.string().min(1).max(1000), }).pick({ content: true, }).refine((data) => Object.keys(data).length > 0, { @@ -74,9 +78,10 @@ const appRouter = t.router({ }).extend({ data: z.object({ id: z.string(), - content: z.string().min(1).max(1000), createdAt: z.date(), updatedAt: z.date(), + }).extend({ + content: z.string().min(1).max(1000), }).optional(), })).mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), deleteCrud: publicProcedure.input(z.object({ From 9db06211638e03c92f7e27cfdf98a8c0a183c3c2 Mon Sep 17 00:00:00 2001 From: Anns Shahbaz Date: Wed, 10 Dec 2025 12:09:44 +0500 Subject: [PATCH 07/37] so far with tdbdoc --- apps/api/scripts/seed/seeders/crud.seeder.ts | 5 +- .../class/transactional.decorator.ts | 4 +- apps/api/src/modules/crud/crud.module.ts | 6 +- apps/api/src/modules/crud/crud.service.ts | 20 ++- .../src/modules/crud/entities/crud.entity.ts | 12 ++ .../api/src/modules/crud/models/crud.model.ts | 16 --- .../crud/repositories/crud.repository.ts | 124 +++++++++--------- .../mongoose/crud.mongo.repository.ts | 18 +-- .../src/modules/crud/schemas/crud.schema.ts | 14 +- .../mongoose/base.mongo.entity.ts | 13 +- .../interfaces/base.abstract.repository.ts | 29 ++-- .../interfaces/base.interface.repository.ts | 10 +- 12 files changed, 136 insertions(+), 135 deletions(-) create mode 100644 apps/api/src/modules/crud/entities/crud.entity.ts delete mode 100644 apps/api/src/modules/crud/models/crud.model.ts diff --git a/apps/api/scripts/seed/seeders/crud.seeder.ts b/apps/api/scripts/seed/seeders/crud.seeder.ts index 630c094..d25a676 100644 --- a/apps/api/scripts/seed/seeders/crud.seeder.ts +++ b/apps/api/scripts/seed/seeders/crud.seeder.ts @@ -1,8 +1,9 @@ import * as mongoose from 'mongoose'; import { CrudDocument, + CrudEntity, CrudSchema, -} from '../../../src/modules/crud/models/crud.model'; +} from '../../../src/modules/crud/entities/crud.entity'; import { MongooseSeeder } from './mongoose.seeder'; import { SeedLogger } from '@repo/db-seeder'; import { StringExtensions } from '@repo/utils-core'; @@ -14,7 +15,7 @@ export class CrudSeeder extends MongooseSeeder> { constructor() { super(); - this.model = mongoose.model(CrudDocument.name, CrudSchema); + this.model = mongoose.model(CrudEntity.name, CrudSchema); } validate(): string[] { diff --git a/apps/api/src/decorators/class/transactional.decorator.ts b/apps/api/src/decorators/class/transactional.decorator.ts index 3b4dcf0..0df2d74 100644 --- a/apps/api/src/decorators/class/transactional.decorator.ts +++ b/apps/api/src/decorators/class/transactional.decorator.ts @@ -1,7 +1,7 @@ import 'reflect-metadata'; import { Transactional as ClsTransactional } from '@nestjs-cls/transactional'; import { NO_TRANSACTION_KEY } from '../constants'; -import { writeFileSync, mkdirSync } from 'fs'; +import { mkdirSync, writeFileSync } from 'fs'; import { join } from 'path'; // Logger utility for transaction validation @@ -39,7 +39,7 @@ class TransactionalLogger { encoding: 'utf-8', }); - console.log(`[${timestamp}] [${className}] ${message}`); + // console.log(`[${timestamp}] [${className}] ${message}`); } getProcessDir(): string { diff --git a/apps/api/src/modules/crud/crud.module.ts b/apps/api/src/modules/crud/crud.module.ts index 5089b32..c9ec7c3 100644 --- a/apps/api/src/modules/crud/crud.module.ts +++ b/apps/api/src/modules/crud/crud.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; import { PrismaModule } from '../prisma/prisma.module'; -import { CrudDocument, CrudSchema } from './models/crud.model'; +import { CrudEntity, CrudSchema } from './entities/crud.entity'; import { CrudService } from './crud.service'; import { CrudRouter } from './crud.router'; import { CrudMongoRepository } from './repositories/mongoose/crud.mongo.repository'; @@ -9,9 +9,7 @@ import { CrudMongoRepository } from './repositories/mongoose/crud.mongo.reposito @Module({ imports: [ PrismaModule, - MongooseModule.forFeature([ - { name: CrudDocument.name, schema: CrudSchema }, - ]), + MongooseModule.forFeature([{ name: CrudEntity.name, schema: CrudSchema }]), ], providers: [CrudMongoRepository, CrudService, CrudRouter], exports: [CrudMongoRepository, CrudService], diff --git a/apps/api/src/modules/crud/crud.service.ts b/apps/api/src/modules/crud/crud.service.ts index 42b2a71..9f3db5f 100644 --- a/apps/api/src/modules/crud/crud.service.ts +++ b/apps/api/src/modules/crud/crud.service.ts @@ -1,9 +1,5 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import { - CreateCrudDto, - CrudEntity, - UpdateCrudDto, -} from './schemas/crud.schema'; +import { Crud } from './schemas/crud.schema'; import { NoTransaction } from '../../decorators/method/no-transaction.decorator'; import { Transactional } from '../../decorators/class/transactional.decorator'; import { MongooseModule } from '@nestjs/mongoose'; @@ -14,36 +10,36 @@ import { CrudMongoRepository } from './repositories/mongoose/crud.mongo.reposito export class CrudService { constructor(private readonly crudRepository: CrudMongoRepository) {} - async createCrud(data: CreateCrudDto): Promise { + async createCrud(data: Partial): Promise { const created = await this.crudRepository.create(data); console.log(created); - throw new Error('Simulated delete error to test transaction rollback'); + // throw new Error('Simulated delete error to test transaction rollback'); return created; } @NoTransaction() - async findAll(): Promise { + async findAll(): Promise { return this.crudRepository.find(); } @NoTransaction() - async findOne(id: string): Promise { + async findOne(id: string): Promise { const crud = await this.crudRepository.findOneById(id); if (!crud) throw new NotFoundException(`Crud with id ${id} not found`); return crud; } - async update(id: string, data: UpdateCrudDto): Promise { + async update(id: string, data: Partial): Promise { const updated = await this.crudRepository.update(id, data); if (!updated) throw new NotFoundException(`Crud with id ${id} not found`); return updated; } - async delete(id: string): Promise { + async delete(id: string): Promise { const deleted = await this.crudRepository.deleteById(id); if (!deleted) throw new NotFoundException(`Crud with id ${id} not found`); console.log(deleted); - throw new Error('Simulated delete error to test transaction rollback'); + // throw new Error('Simulated delete error to test transaction rollback'); return deleted; } } diff --git a/apps/api/src/modules/crud/entities/crud.entity.ts b/apps/api/src/modules/crud/entities/crud.entity.ts new file mode 100644 index 0000000..d8d9c04 --- /dev/null +++ b/apps/api/src/modules/crud/entities/crud.entity.ts @@ -0,0 +1,12 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { MongooseEntity } from '../../../repositories/mongoose/base.mongo.entity'; +import { HydratedDocument } from 'mongoose'; + +@Schema({ collection: 'crud', timestamps: true }) +export class CrudEntity extends MongooseEntity { + @Prop({ required: true }) + content: string; +} + +export type CrudDocument = HydratedDocument; +export const CrudSchema = SchemaFactory.createForClass(CrudEntity); diff --git a/apps/api/src/modules/crud/models/crud.model.ts b/apps/api/src/modules/crud/models/crud.model.ts deleted file mode 100644 index c1595c1..0000000 --- a/apps/api/src/modules/crud/models/crud.model.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Schema, Prop, SchemaFactory } from '@nestjs/mongoose'; -import { Document } from 'mongoose'; - -@Schema({ timestamps: true, collection: 'crud' }) -export class CrudDocument extends Document { - @Prop({ required: true }) - content: string; - - @Prop() - createdAt: Date; - - @Prop() - updatedAt: Date; -} - -export const CrudSchema = SchemaFactory.createForClass(CrudDocument); diff --git a/apps/api/src/modules/crud/repositories/crud.repository.ts b/apps/api/src/modules/crud/repositories/crud.repository.ts index ad65c5b..cc3ad56 100644 --- a/apps/api/src/modules/crud/repositories/crud.repository.ts +++ b/apps/api/src/modules/crud/repositories/crud.repository.ts @@ -52,67 +52,63 @@ // } // } -import { InjectModel, MongooseModule } from '@nestjs/mongoose'; -import { Model } from 'mongoose'; -import { CrudDocument } from '../models/crud.model'; -import { - CreateCrudDto, - CrudEntity, - UpdateCrudDto, -} from '../schemas/crud.schema'; -import { - InjectTransactionHost, - TransactionHost, -} from '@nestjs-cls/transactional'; -import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter-mongoose'; - -export class CrudRepository { - constructor( - @InjectModel(CrudDocument.name) - private readonly crudModel: Model, - @InjectTransactionHost(MongooseModule.name) - private readonly mongoTxHost: TransactionHost, - ) {} - - async find(): Promise { - const docs = await this.crudModel - .find() - .sort({ createdAt: -1 }) - .session(this.mongoTxHost.tx); - return docs.map((doc) => this.toEntity(doc)); - } - - async findOne(id: string): Promise { - const doc = await this.crudModel.findById(id).session(this.mongoTxHost.tx); - return doc ? this.toEntity(doc) : null; - } - - async create(data: CreateCrudDto): Promise { - const doc = new this.crudModel(data); - await doc.save({ session: this.mongoTxHost.tx }); - return this.toEntity(doc); - } - - async update(id: string, data: UpdateCrudDto): Promise { - const doc = await this.crudModel - .findByIdAndUpdate(id, data, { new: true }) - .session(this.mongoTxHost.tx); - return doc ? this.toEntity(doc) : null; - } - - async delete(id: string): Promise { - const doc = await this.crudModel - .findByIdAndDelete(id) - .session(this.mongoTxHost.tx); - return doc ? this.toEntity(doc) : null; - } - - private toEntity(doc: CrudDocument): CrudEntity { - return { - id: doc._id.toString(), - content: doc.content, - createdAt: doc.createdAt, - updatedAt: doc.updatedAt, - }; - } -} +// import { InjectModel, MongooseModule } from '@nestjs/mongoose'; +// import { Model } from 'mongoose'; +// import { CrudDocument, CrudEntity } from '../entities/crud.entity'; +// import { CreateCrudDto, Crud, UpdateCrudDto } from '../schemas/crud.schema'; +// import { +// InjectTransactionHost, +// TransactionHost, +// } from '@nestjs-cls/transactional'; +// import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter-mongoose'; +// +// export class CrudRepository { +// constructor( +// @InjectModel(CrudEntity.name) +// private readonly crudModel: Model, +// @InjectTransactionHost(MongooseModule.name) +// private readonly mongoTxHost: TransactionHost, +// ) {} +// +// async find(): Promise { +// const docs = await this.crudModel +// .find() +// .sort({ createdAt: -1 }) +// .session(this.mongoTxHost.tx); +// return docs.map((doc) => this.toEntity(doc)); +// } +// +// async findOne(id: string): Promise { +// const doc = await this.crudModel.findById(id).session(this.mongoTxHost.tx); +// return doc ? this.toEntity(doc) : null; +// } +// +// async create(data: CreateCrudDto): Promise { +// const doc = new this.crudModel(data); +// await doc.save({ session: this.mongoTxHost.tx }); +// return this.toEntity(doc); +// } +// +// async update(id: string, data: UpdateCrudDto): Promise { +// const doc = await this.crudModel +// .findByIdAndUpdate(id, data, { new: true }) +// .session(this.mongoTxHost.tx); +// return doc ? this.toEntity(doc) : null; +// } +// +// async delete(id: string): Promise { +// const doc = await this.crudModel +// .findByIdAndDelete(id) +// .session(this.mongoTxHost.tx); +// return doc ? this.toEntity(doc) : null; +// } +// +// private toEntity(doc: CrudDocument): Crud { +// return { +// id: doc._id.toString(), +// content: doc.content, +// createdAt: doc.createdAt, +// updatedAt: doc.updatedAt, +// }; +// } +// } diff --git a/apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts b/apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts index 8e88193..ebe15d6 100644 --- a/apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts +++ b/apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts @@ -1,20 +1,20 @@ import { InjectModel, MongooseModule } from '@nestjs/mongoose'; import { BaseRepositoryMongo } from '../../../../repositories/mongoose/interfaces/base.abstract.repository'; -import { CrudDocument } from '../../models/crud.model'; -import { CrudEntity } from '../../schemas/crud.schema'; +import { CrudDocument, CrudEntity } from '../../entities/crud.entity'; import { Model } from 'mongoose'; import { InjectTransactionHost, TransactionHost, } from '@nestjs-cls/transactional'; import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter-mongoose'; +import { Crud } from '../../schemas/crud.schema'; export class CrudMongoRepository extends BaseRepositoryMongo< CrudDocument, - CrudEntity + Crud > { constructor( - @InjectModel(CrudDocument.name) + @InjectModel(CrudEntity.name) crudModel: Model, @InjectTransactionHost(MongooseModule.name) mongoTxHost: TransactionHost, @@ -22,12 +22,12 @@ export class CrudMongoRepository extends BaseRepositoryMongo< super(crudModel, mongoTxHost); } - protected toDomainEntity(dbEntity: CrudDocument): CrudEntity { + protected toDomainEntity(dbDoc: CrudDocument): Crud { return { - id: dbEntity._id.toString(), - content: dbEntity.content.toString(), - createdAt: dbEntity.createdAt, - updatedAt: dbEntity.updatedAt, + id: dbDoc._id.toString(), + content: dbDoc.content.toString(), + createdAt: dbDoc.createdAt, + updatedAt: dbDoc.updatedAt, }; } } diff --git a/apps/api/src/modules/crud/schemas/crud.schema.ts b/apps/api/src/modules/crud/schemas/crud.schema.ts index 07c58e9..e2e7021 100644 --- a/apps/api/src/modules/crud/schemas/crud.schema.ts +++ b/apps/api/src/modules/crud/schemas/crud.schema.ts @@ -5,15 +5,15 @@ import { ZBaseResponse, } from '../../../schemas/base.schema'; -export const ZCrudEntity = BaseEntity.extend({ +export const ZCrud = BaseEntity.extend({ content: z.string().min(1).max(1000), }); -export const ZCreateCrudDto = ZCrudEntity.pick({ +export const ZCreateCrudDto = ZCrud.pick({ content: true, }); -export const ZUpdateCrudDto = ZCrudEntity.pick({ +export const ZUpdateCrudDto = ZCrud.pick({ content: true, }); @@ -27,7 +27,7 @@ export const ZCrudFindOneRequest = ZBaseRequest.extend({ id: z.string(), }); -export const ZCrudFindOneResponse = ZCrudEntity.nullable(); +export const ZCrudFindOneResponse = ZCrud.nullable(); export const ZCrudFindAllRequest = ZBaseRequest.extend({ limit: z.number().int().positive().max(100).default(10).optional(), @@ -35,7 +35,7 @@ export const ZCrudFindAllRequest = ZBaseRequest.extend({ }); export const ZCrudFindAllResponse = ZBaseResponse.extend({ - cruds: z.array(ZCrudEntity), + cruds: z.array(ZCrud), total: z.number().int().nonnegative(), limit: z.number().int().positive(), offset: z.number().int().nonnegative(), @@ -49,7 +49,7 @@ export const ZCrudUpdateRequest = ZBaseRequest.extend({ }); export const ZCrudUpdateResponse = ZBaseResponse.extend({ - data: ZCrudEntity.optional(), + data: ZCrud.optional(), }); export const ZCrudDeleteRequest = ZBaseRequest.extend({ @@ -58,7 +58,7 @@ export const ZCrudDeleteRequest = ZBaseRequest.extend({ export const ZCrudDeleteResponse = ZBaseResponse; -export type CrudEntity = z.infer; +export type Crud = z.infer; export type CreateCrudDto = z.infer; export type UpdateCrudDto = z.infer; diff --git a/apps/api/src/repositories/mongoose/base.mongo.entity.ts b/apps/api/src/repositories/mongoose/base.mongo.entity.ts index 620be92..a54b07c 100644 --- a/apps/api/src/repositories/mongoose/base.mongo.entity.ts +++ b/apps/api/src/repositories/mongoose/base.mongo.entity.ts @@ -1,11 +1,6 @@ -import { Document } from 'mongoose'; -import { Prop, Schema } from '@nestjs/mongoose'; +import { Prop } from '@nestjs/mongoose'; -@Schema({ timestamps: true }) -export class MongoDbEntity extends Document { - @Prop() - createdAt: Date; - - @Prop() - updatedAt: Date; +export class MongooseEntity { + @Prop() createdAt: Date; + @Prop() updatedAt: Date; } diff --git a/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts b/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts index 837e8bf..1fcba87 100644 --- a/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts +++ b/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts @@ -1,20 +1,21 @@ -import { MongoDbEntity } from '../base.mongo.entity'; import { MongooseRepositoryInterface } from './base.interface.repository'; -import { Model } from 'mongoose'; +import { Document, InsertManyOptions, Model } from 'mongoose'; import { Entity } from '../../../schemas/base.schema'; import { TransactionHost } from '@nestjs-cls/transactional'; import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter-mongoose'; +import { MongooseEntity } from '../base.mongo.entity'; export abstract class BaseRepositoryMongo< - TDbEntity extends MongoDbEntity, TDomainEntity extends Entity, -> implements MongooseRepositoryInterface + TDbEntity extends MongooseEntity, + TDbDoc extends Document, +> implements MongooseRepositoryInterface { - protected readonly model: Model; + protected readonly model: Model; protected readonly mongoTxHost: TransactionHost; protected constructor( - model: Model, + model: Model, mongoTxHost: TransactionHost, ) { this.model = model; @@ -27,6 +28,18 @@ export abstract class BaseRepositoryMongo< return this.toDomainEntity(doc); } + async createMany( + entities: Partial[], + options?: InsertManyOptions, + ): Promise { + const docs = await this.model.insertMany(entities, { + ...options, + session: this.mongoTxHost.tx, + }); + + return docs.map((doc) => this.toDomainEntity(doc)); + } + async find(): Promise { const docs = await this.model.find().session(this.mongoTxHost.tx); return docs.map((doc) => this.toDomainEntity(doc)); @@ -49,10 +62,10 @@ export abstract class BaseRepositoryMongo< update: Partial, ): Promise { const updatedDoc = await this.model - .findByIdAndUpdate(id, update, { new: true }) + .findByIdAndUpdate(id, { updateOne: update }, { new: true }) .session(this.mongoTxHost.tx); return updatedDoc ? this.toDomainEntity(updatedDoc) : null; } - protected abstract toDomainEntity(dbEntity: TDbEntity): TDomainEntity; + protected abstract toDomainEntity(tDbDoc: TDbDoc): TDomainEntity; } diff --git a/apps/api/src/repositories/mongoose/interfaces/base.interface.repository.ts b/apps/api/src/repositories/mongoose/interfaces/base.interface.repository.ts index 4331d4f..4efe4b6 100644 --- a/apps/api/src/repositories/mongoose/interfaces/base.interface.repository.ts +++ b/apps/api/src/repositories/mongoose/interfaces/base.interface.repository.ts @@ -1,11 +1,17 @@ -import { MongoDbEntity } from '../base.mongo.entity'; +import { InsertManyOptions } from 'mongoose'; +import { MongooseEntity } from '../base.mongo.entity'; import { Entity } from '../../../schemas/base.schema'; export interface MongooseRepositoryInterface< - TDbEntity extends MongoDbEntity, TDomainEntity extends Entity, + TDbEntity extends MongooseEntity, > { create(entity: Partial): Promise; + createMany( + docs: Partial[], + options?: InsertManyOptions, + ): Promise; + findOneById(id: string): Promise; find(): Promise; deleteById(id: string): Promise; From f7be39aecb85e52b2b749e58e33b8e5e6d88ec6f Mon Sep 17 00:00:00 2001 From: Anns Shahbaz Date: Wed, 10 Dec 2025 12:44:50 +0500 Subject: [PATCH 08/37] major progress --- apps/api/scripts/seed/seeders/crud.seeder.ts | 7 +- .../scripts/seed/seeders/mongoose.seeder.ts | 5 +- apps/api/src/modules/crud/crud.service.ts | 4 +- .../mongoose/crud.mongo.repository.ts | 19 +-- .../mongoose/base.mongo.entity.ts | 2 + .../interfaces/base.abstract.repository.ts | 153 ++++++++++++++++-- .../interfaces/base.interface.repository.ts | 51 +++++- 7 files changed, 202 insertions(+), 39 deletions(-) diff --git a/apps/api/scripts/seed/seeders/crud.seeder.ts b/apps/api/scripts/seed/seeders/crud.seeder.ts index d25a676..42f5f2e 100644 --- a/apps/api/scripts/seed/seeders/crud.seeder.ts +++ b/apps/api/scripts/seed/seeders/crud.seeder.ts @@ -1,6 +1,5 @@ import * as mongoose from 'mongoose'; import { - CrudDocument, CrudEntity, CrudSchema, } from '../../../src/modules/crud/entities/crud.entity'; @@ -8,14 +7,12 @@ import { MongooseSeeder } from './mongoose.seeder'; import { SeedLogger } from '@repo/db-seeder'; import { StringExtensions } from '@repo/utils-core'; -export class CrudSeeder extends MongooseSeeder> { +export class CrudSeeder extends MongooseSeeder> { readonly entityName = 'CRUD'; readonly seedDataFile = 'crud.json'; - readonly model: mongoose.Model; constructor() { - super(); - this.model = mongoose.model(CrudEntity.name, CrudSchema); + super(mongoose.model(CrudEntity.name, CrudSchema)); } validate(): string[] { diff --git a/apps/api/scripts/seed/seeders/mongoose.seeder.ts b/apps/api/scripts/seed/seeders/mongoose.seeder.ts index b2b4e6b..7dd4a48 100644 --- a/apps/api/scripts/seed/seeders/mongoose.seeder.ts +++ b/apps/api/scripts/seed/seeders/mongoose.seeder.ts @@ -3,9 +3,10 @@ import * as path from 'node:path'; import { BaseSeeder } from '@repo/db-seeder'; export abstract class MongooseSeeder extends BaseSeeder { - abstract readonly model: mongoose.Model; + protected readonly model: mongoose.Model; - protected constructor() { + protected constructor(model: mongoose.Model) { super(path.join(__dirname, '..', 'seed-data')); + this.model = model; } } diff --git a/apps/api/src/modules/crud/crud.service.ts b/apps/api/src/modules/crud/crud.service.ts index 9f3db5f..8f72bbe 100644 --- a/apps/api/src/modules/crud/crud.service.ts +++ b/apps/api/src/modules/crud/crud.service.ts @@ -24,13 +24,13 @@ export class CrudService { @NoTransaction() async findOne(id: string): Promise { - const crud = await this.crudRepository.findOneById(id); + const crud = await this.crudRepository.findById(id); if (!crud) throw new NotFoundException(`Crud with id ${id} not found`); return crud; } async update(id: string, data: Partial): Promise { - const updated = await this.crudRepository.update(id, data); + const updated = await this.crudRepository.findByIdAndUpdate(id, data); if (!updated) throw new NotFoundException(`Crud with id ${id} not found`); return updated; } diff --git a/apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts b/apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts index ebe15d6..3bb2b8c 100644 --- a/apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts +++ b/apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts @@ -1,6 +1,6 @@ import { InjectModel, MongooseModule } from '@nestjs/mongoose'; import { BaseRepositoryMongo } from '../../../../repositories/mongoose/interfaces/base.abstract.repository'; -import { CrudDocument, CrudEntity } from '../../entities/crud.entity'; +import { CrudEntity } from '../../entities/crud.entity'; import { Model } from 'mongoose'; import { InjectTransactionHost, @@ -9,25 +9,22 @@ import { import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter-mongoose'; import { Crud } from '../../schemas/crud.schema'; -export class CrudMongoRepository extends BaseRepositoryMongo< - CrudDocument, - Crud -> { +export class CrudMongoRepository extends BaseRepositoryMongo { constructor( @InjectModel(CrudEntity.name) - crudModel: Model, + crudModel: Model, @InjectTransactionHost(MongooseModule.name) mongoTxHost: TransactionHost, ) { super(crudModel, mongoTxHost); } - protected toDomainEntity(dbDoc: CrudDocument): Crud { + protected toDomainEntity(dbEntity: CrudEntity): Crud { return { - id: dbDoc._id.toString(), - content: dbDoc.content.toString(), - createdAt: dbDoc.createdAt, - updatedAt: dbDoc.updatedAt, + id: dbEntity._id?.toString() || '', + content: dbEntity.content, + createdAt: dbEntity.createdAt, + updatedAt: dbEntity.updatedAt, }; } } diff --git a/apps/api/src/repositories/mongoose/base.mongo.entity.ts b/apps/api/src/repositories/mongoose/base.mongo.entity.ts index a54b07c..c60e205 100644 --- a/apps/api/src/repositories/mongoose/base.mongo.entity.ts +++ b/apps/api/src/repositories/mongoose/base.mongo.entity.ts @@ -1,6 +1,8 @@ +import { Types } from 'mongoose'; import { Prop } from '@nestjs/mongoose'; export class MongooseEntity { + _id?: Types.ObjectId; @Prop() createdAt: Date; @Prop() updatedAt: Date; } diff --git a/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts b/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts index 1fcba87..8ecb4c1 100644 --- a/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts +++ b/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts @@ -1,5 +1,12 @@ import { MongooseRepositoryInterface } from './base.interface.repository'; -import { Document, InsertManyOptions, Model } from 'mongoose'; +import { + InsertManyOptions, + Model, + ProjectionType, + QueryFilter, + QueryOptions, + UpdateQuery, +} from 'mongoose'; import { Entity } from '../../../schemas/base.schema'; import { TransactionHost } from '@nestjs-cls/transactional'; import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter-mongoose'; @@ -8,14 +15,13 @@ import { MongooseEntity } from '../base.mongo.entity'; export abstract class BaseRepositoryMongo< TDomainEntity extends Entity, TDbEntity extends MongooseEntity, - TDbDoc extends Document, > implements MongooseRepositoryInterface { - protected readonly model: Model; + protected readonly model: Model; protected readonly mongoTxHost: TransactionHost; protected constructor( - model: Model, + model: Model, mongoTxHost: TransactionHost, ) { this.model = model; @@ -37,19 +43,47 @@ export abstract class BaseRepositoryMongo< session: this.mongoTxHost.tx, }); - return docs.map((doc) => this.toDomainEntity(doc)); + return docs.map((doc) => this.toDomainEntity(doc as unknown as TDbEntity)); } - async find(): Promise { - const docs = await this.model.find().session(this.mongoTxHost.tx); + async find( + filter?: QueryFilter, + projection?: ProjectionType, + options?: QueryOptions, + ): Promise { + const docs = await this.model + .find(filter, projection, options) + .session(this.mongoTxHost.tx) + .lean(); + return docs.map((doc) => this.toDomainEntity(doc)); } - async findOneById(id: string): Promise { - const doc = await this.model.findById(id).session(this.mongoTxHost.tx); + async findById( + id: string, + projection?: ProjectionType, + options?: QueryOptions, + ): Promise { + const doc = await this.model + .findById(id, projection, options) + .session(this.mongoTxHost.tx) + .lean(); + return doc ? this.toDomainEntity(doc) : null; } + async findOne( + filter?: QueryFilter, + projection?: ProjectionType, + options?: QueryOptions, + ): Promise { + const doc = await this.model + .findOne(filter, projection, options) + .session(this.mongoTxHost.tx); + + return this.toDomainEntity(doc as unknown as TDbEntity); + } + async deleteById(id: string): Promise { const deletedDoc = await this.model .findByIdAndDelete(id) @@ -57,15 +91,104 @@ export abstract class BaseRepositoryMongo< return deletedDoc ? this.toDomainEntity(deletedDoc) : null; } - async update( + async updateOneById( + id: string, + update: UpdateQuery, + ): Promise { + const updateResult = await this.model + .updateOne({ id }, update) + .session(this.mongoTxHost.tx); + + return updateResult.acknowledged; + } + + async updateOne( + filter: QueryFilter, + update: UpdateQuery, + ): Promise { + const updateResult = await this.model + .updateOne(filter, update) + .session(this.mongoTxHost.tx); + + return updateResult.acknowledged; + } + + async updateMany( + filter: QueryFilter, + update: UpdateQuery, + ): Promise { + const updateResult = await this.model + .updateMany(filter, update) + .session(this.mongoTxHost.tx); + + return updateResult.acknowledged; + } + + async findByIdAndUpdate( id: string, - update: Partial, + update: UpdateQuery, + options?: QueryOptions, + ): Promise { + const doc = await this.model + .findByIdAndUpdate(id, update, options) + .session(this.mongoTxHost.tx) + .lean(); + + return doc ? this.toDomainEntity(doc) : null; + } + + async findOneAndUpdate( + filter: QueryFilter, + update: UpdateQuery, + options?: QueryOptions, ): Promise { - const updatedDoc = await this.model - .findByIdAndUpdate(id, { updateOne: update }, { new: true }) + const doc = await this.model + .findByIdAndUpdate(filter, update, options) + .session(this.mongoTxHost.tx) + .lean(); + + return doc ? this.toDomainEntity(doc) : null; + } + + async deleteOneById(id: string): Promise { + return this.deleteOne({ id }); + } + + async deleteOne(filter: QueryFilter): Promise { + const deleteResult = await this.model + .deleteOne(filter) + .session(this.mongoTxHost.tx); + + return deleteResult.acknowledged; + } + + async deleteMany(filter: QueryFilter): Promise { + const deleteResult = await this.model + .deleteMany(filter) .session(this.mongoTxHost.tx); - return updatedDoc ? this.toDomainEntity(updatedDoc) : null; + + return deleteResult.acknowledged; + } + + async findByIdAndDelete(id: string): Promise { + const deletedDoc = await this.model + .findByIdAndDelete(id) + .session(this.mongoTxHost.tx) + .lean(); + + return deletedDoc ? this.toDomainEntity(deletedDoc) : null; + } + + async findOneAndDelete( + filter: QueryFilter, + ): Promise { + const deletedDoc = await this.model + .findOneAndDelete(filter) + .session(this.mongoTxHost.tx) + .lean(); + + return deletedDoc ? this.toDomainEntity(deletedDoc) : null; } - protected abstract toDomainEntity(tDbDoc: TDbDoc): TDomainEntity; + protected abstract toDomainEntity(tDbEntity: TDbEntity): TDomainEntity; } diff --git a/apps/api/src/repositories/mongoose/interfaces/base.interface.repository.ts b/apps/api/src/repositories/mongoose/interfaces/base.interface.repository.ts index 4efe4b6..b76fc8f 100644 --- a/apps/api/src/repositories/mongoose/interfaces/base.interface.repository.ts +++ b/apps/api/src/repositories/mongoose/interfaces/base.interface.repository.ts @@ -1,4 +1,10 @@ -import { InsertManyOptions } from 'mongoose'; +import { + InsertManyOptions, + ProjectionType, + QueryFilter, + QueryOptions, + UpdateQuery, +} from 'mongoose'; import { MongooseEntity } from '../base.mongo.entity'; import { Entity } from '../../../schemas/base.schema'; @@ -12,8 +18,45 @@ export interface MongooseRepositoryInterface< options?: InsertManyOptions, ): Promise; - findOneById(id: string): Promise; - find(): Promise; + find( + filter?: QueryFilter, + projection?: ProjectionType, + options?: QueryOptions, + ): Promise; + findById(id: string): Promise; + findOne( + filter?: QueryFilter, + projection?: ProjectionType, + options?: QueryOptions, + ): Promise; + + updateOneById(id: string, update: UpdateQuery): Promise; + updateOne( + filter: QueryFilter, + update: UpdateQuery, + ): Promise; + updateMany( + filter: QueryFilter, + update: UpdateQuery, + ): Promise; + findByIdAndUpdate( + id: string, + update: UpdateQuery, + options?: QueryOptions, + ): Promise; + findOneAndUpdate( + filter: QueryFilter, + update: UpdateQuery, + options?: QueryOptions, + ): Promise; + + deleteOneById(id: string): Promise; + deleteOne(filter: QueryFilter): Promise; + deleteMany(filter: QueryFilter): Promise; + findByIdAndDelete(id: string): Promise; + findOneAndDelete( + filter: QueryFilter, + ): Promise; + deleteById(id: string): Promise; - update(id: string, update: Partial): Promise; } From 23e9bdb1a8640de60d3d071d7f2d73c04141fe38 Mon Sep 17 00:00:00 2001 From: Anns Shahbaz Date: Wed, 10 Dec 2025 15:18:25 +0500 Subject: [PATCH 09/37] _id --- apps/api/src/repositories/mongoose/base.mongo.entity.ts | 3 ++- .../mongoose/interfaces/base.abstract.repository.ts | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/api/src/repositories/mongoose/base.mongo.entity.ts b/apps/api/src/repositories/mongoose/base.mongo.entity.ts index c60e205..579c2a8 100644 --- a/apps/api/src/repositories/mongoose/base.mongo.entity.ts +++ b/apps/api/src/repositories/mongoose/base.mongo.entity.ts @@ -2,7 +2,8 @@ import { Types } from 'mongoose'; import { Prop } from '@nestjs/mongoose'; export class MongooseEntity { - _id?: Types.ObjectId; @Prop() createdAt: Date; @Prop() updatedAt: Date; + + _id?: Types.ObjectId; } diff --git a/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts b/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts index 8ecb4c1..849f519 100644 --- a/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts +++ b/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts @@ -56,6 +56,8 @@ export abstract class BaseRepositoryMongo< .session(this.mongoTxHost.tx) .lean(); + console.log(docs); + return docs.map((doc) => this.toDomainEntity(doc)); } From ef02480049c138b42a7328bb442bff1804506141 Mon Sep 17 00:00:00 2001 From: Anns Shahbaz Date: Wed, 10 Dec 2025 15:29:43 +0500 Subject: [PATCH 10/37] id --- .../mongoose/crud.mongo.repository.ts | 2 +- .../mongoose/base.mongo.entity.ts | 4 ++++ .../interfaces/base.abstract.repository.ts | 20 ++++++------------- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts b/apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts index 3bb2b8c..c09ff09 100644 --- a/apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts +++ b/apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts @@ -21,7 +21,7 @@ export class CrudMongoRepository extends BaseRepositoryMongo { protected toDomainEntity(dbEntity: CrudEntity): Crud { return { - id: dbEntity._id?.toString() || '', + id: dbEntity.id, content: dbEntity.content, createdAt: dbEntity.createdAt, updatedAt: dbEntity.updatedAt, diff --git a/apps/api/src/repositories/mongoose/base.mongo.entity.ts b/apps/api/src/repositories/mongoose/base.mongo.entity.ts index 579c2a8..9fd7ecd 100644 --- a/apps/api/src/repositories/mongoose/base.mongo.entity.ts +++ b/apps/api/src/repositories/mongoose/base.mongo.entity.ts @@ -6,4 +6,8 @@ export class MongooseEntity { @Prop() updatedAt: Date; _id?: Types.ObjectId; + + get id(): string { + return this._id?.toString() || ''; + } } diff --git a/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts b/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts index 849f519..bb1f78c 100644 --- a/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts +++ b/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts @@ -53,10 +53,7 @@ export abstract class BaseRepositoryMongo< ): Promise { const docs = await this.model .find(filter, projection, options) - .session(this.mongoTxHost.tx) - .lean(); - - console.log(docs); + .session(this.mongoTxHost.tx); return docs.map((doc) => this.toDomainEntity(doc)); } @@ -68,8 +65,7 @@ export abstract class BaseRepositoryMongo< ): Promise { const doc = await this.model .findById(id, projection, options) - .session(this.mongoTxHost.tx) - .lean(); + .session(this.mongoTxHost.tx); return doc ? this.toDomainEntity(doc) : null; } @@ -133,8 +129,7 @@ export abstract class BaseRepositoryMongo< ): Promise { const doc = await this.model .findByIdAndUpdate(id, update, options) - .session(this.mongoTxHost.tx) - .lean(); + .session(this.mongoTxHost.tx); return doc ? this.toDomainEntity(doc) : null; } @@ -146,8 +141,7 @@ export abstract class BaseRepositoryMongo< ): Promise { const doc = await this.model .findByIdAndUpdate(filter, update, options) - .session(this.mongoTxHost.tx) - .lean(); + .session(this.mongoTxHost.tx); return doc ? this.toDomainEntity(doc) : null; } @@ -175,8 +169,7 @@ export abstract class BaseRepositoryMongo< async findByIdAndDelete(id: string): Promise { const deletedDoc = await this.model .findByIdAndDelete(id) - .session(this.mongoTxHost.tx) - .lean(); + .session(this.mongoTxHost.tx); return deletedDoc ? this.toDomainEntity(deletedDoc) : null; } @@ -186,8 +179,7 @@ export abstract class BaseRepositoryMongo< ): Promise { const deletedDoc = await this.model .findOneAndDelete(filter) - .session(this.mongoTxHost.tx) - .lean(); + .session(this.mongoTxHost.tx); return deletedDoc ? this.toDomainEntity(deletedDoc) : null; } From ee6aab41b5da8484c30f3f547702c592cd84a0ea Mon Sep 17 00:00:00 2001 From: Anns Shahbaz Date: Wed, 10 Dec 2025 15:33:18 +0500 Subject: [PATCH 11/37] improve --- .../interfaces/base.abstract.repository.ts | 21 +++++-------------- .../interfaces/base.interface.repository.ts | 2 -- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts b/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts index bb1f78c..b60d4b2 100644 --- a/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts +++ b/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts @@ -38,12 +38,12 @@ export abstract class BaseRepositoryMongo< entities: Partial[], options?: InsertManyOptions, ): Promise { - const docs = await this.model.insertMany(entities, { + const docs = (await this.model.insertMany(entities, { ...options, session: this.mongoTxHost.tx, - }); + })) as unknown as TDbEntity[]; - return docs.map((doc) => this.toDomainEntity(doc as unknown as TDbEntity)); + return docs.map((doc) => this.toDomainEntity(doc)); } async find( @@ -82,22 +82,11 @@ export abstract class BaseRepositoryMongo< return this.toDomainEntity(doc as unknown as TDbEntity); } - async deleteById(id: string): Promise { - const deletedDoc = await this.model - .findByIdAndDelete(id) - .session(this.mongoTxHost.tx); - return deletedDoc ? this.toDomainEntity(deletedDoc) : null; - } - async updateOneById( id: string, update: UpdateQuery, ): Promise { - const updateResult = await this.model - .updateOne({ id }, update) - .session(this.mongoTxHost.tx); - - return updateResult.acknowledged; + return this.updateOne({ id }, update); } async updateOne( @@ -140,7 +129,7 @@ export abstract class BaseRepositoryMongo< options?: QueryOptions, ): Promise { const doc = await this.model - .findByIdAndUpdate(filter, update, options) + .findOneAndUpdate(filter, update, options) .session(this.mongoTxHost.tx); return doc ? this.toDomainEntity(doc) : null; diff --git a/apps/api/src/repositories/mongoose/interfaces/base.interface.repository.ts b/apps/api/src/repositories/mongoose/interfaces/base.interface.repository.ts index b76fc8f..571aa26 100644 --- a/apps/api/src/repositories/mongoose/interfaces/base.interface.repository.ts +++ b/apps/api/src/repositories/mongoose/interfaces/base.interface.repository.ts @@ -57,6 +57,4 @@ export interface MongooseRepositoryInterface< findOneAndDelete( filter: QueryFilter, ): Promise; - - deleteById(id: string): Promise; } From b1b995138800fa15008ff1105341af8deaad79d4 Mon Sep 17 00:00:00 2001 From: Anns Shahbaz Date: Wed, 10 Dec 2025 16:19:09 +0500 Subject: [PATCH 12/37] ok --- apps/api/src/modules/crud/crud.service.ts | 2 +- .../mongoose/crud.mongo.repository.ts | 2 +- .../mongoose/base.mongo.entity.ts | 8 +--- .../interfaces/base.abstract.repository.ts | 47 +++++++++++-------- 4 files changed, 31 insertions(+), 28 deletions(-) diff --git a/apps/api/src/modules/crud/crud.service.ts b/apps/api/src/modules/crud/crud.service.ts index 8f72bbe..d051ebe 100644 --- a/apps/api/src/modules/crud/crud.service.ts +++ b/apps/api/src/modules/crud/crud.service.ts @@ -36,7 +36,7 @@ export class CrudService { } async delete(id: string): Promise { - const deleted = await this.crudRepository.deleteById(id); + const deleted = await this.crudRepository.findByIdAndDelete(id); if (!deleted) throw new NotFoundException(`Crud with id ${id} not found`); console.log(deleted); // throw new Error('Simulated delete error to test transaction rollback'); diff --git a/apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts b/apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts index c09ff09..f62ce8d 100644 --- a/apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts +++ b/apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts @@ -21,7 +21,7 @@ export class CrudMongoRepository extends BaseRepositoryMongo { protected toDomainEntity(dbEntity: CrudEntity): Crud { return { - id: dbEntity.id, + id: dbEntity._id?.toString() ?? '', content: dbEntity.content, createdAt: dbEntity.createdAt, updatedAt: dbEntity.updatedAt, diff --git a/apps/api/src/repositories/mongoose/base.mongo.entity.ts b/apps/api/src/repositories/mongoose/base.mongo.entity.ts index 9fd7ecd..5f8e1b9 100644 --- a/apps/api/src/repositories/mongoose/base.mongo.entity.ts +++ b/apps/api/src/repositories/mongoose/base.mongo.entity.ts @@ -2,12 +2,8 @@ import { Types } from 'mongoose'; import { Prop } from '@nestjs/mongoose'; export class MongooseEntity { - @Prop() createdAt: Date; - @Prop() updatedAt: Date; - _id?: Types.ObjectId; - get id(): string { - return this._id?.toString() || ''; - } + @Prop() createdAt: Date; + @Prop() updatedAt: Date; } diff --git a/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts b/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts index b60d4b2..afad389 100644 --- a/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts +++ b/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts @@ -31,19 +31,19 @@ export abstract class BaseRepositoryMongo< async create(entity: Partial): Promise { const doc = new this.model(entity); await doc.save({ session: this.mongoTxHost.tx }); - return this.toDomainEntity(doc); + return this.toDomainEntity(doc.toObject()); } async createMany( entities: Partial[], options?: InsertManyOptions, ): Promise { - const docs = (await this.model.insertMany(entities, { + const docs = await this.model.insertMany(entities, { ...options, session: this.mongoTxHost.tx, - })) as unknown as TDbEntity[]; + }); - return docs.map((doc) => this.toDomainEntity(doc)); + return docs.map((doc) => this.toDomainEntity(doc.toObject())); } async find( @@ -53,7 +53,8 @@ export abstract class BaseRepositoryMongo< ): Promise { const docs = await this.model .find(filter, projection, options) - .session(this.mongoTxHost.tx); + .session(this.mongoTxHost.tx) + .lean(); return docs.map((doc) => this.toDomainEntity(doc)); } @@ -65,7 +66,8 @@ export abstract class BaseRepositoryMongo< ): Promise { const doc = await this.model .findById(id, projection, options) - .session(this.mongoTxHost.tx); + .session(this.mongoTxHost.tx) + .lean(); return doc ? this.toDomainEntity(doc) : null; } @@ -77,16 +79,17 @@ export abstract class BaseRepositoryMongo< ): Promise { const doc = await this.model .findOne(filter, projection, options) - .session(this.mongoTxHost.tx); + .session(this.mongoTxHost.tx) + .lean(); - return this.toDomainEntity(doc as unknown as TDbEntity); + return doc ? this.toDomainEntity(doc) : null; } async updateOneById( id: string, update: UpdateQuery, ): Promise { - return this.updateOne({ id }, update); + return this.updateOne({ _id: id }, update); } async updateOne( @@ -97,7 +100,7 @@ export abstract class BaseRepositoryMongo< .updateOne(filter, update) .session(this.mongoTxHost.tx); - return updateResult.acknowledged; + return updateResult.modifiedCount > 0; } async updateMany( @@ -108,7 +111,7 @@ export abstract class BaseRepositoryMongo< .updateMany(filter, update) .session(this.mongoTxHost.tx); - return updateResult.acknowledged; + return updateResult.modifiedCount > 0; } async findByIdAndUpdate( @@ -117,8 +120,9 @@ export abstract class BaseRepositoryMongo< options?: QueryOptions, ): Promise { const doc = await this.model - .findByIdAndUpdate(id, update, options) - .session(this.mongoTxHost.tx); + .findByIdAndUpdate(id, update, { new: true, ...options }) + .session(this.mongoTxHost.tx) + .lean(); return doc ? this.toDomainEntity(doc) : null; } @@ -129,14 +133,15 @@ export abstract class BaseRepositoryMongo< options?: QueryOptions, ): Promise { const doc = await this.model - .findOneAndUpdate(filter, update, options) - .session(this.mongoTxHost.tx); + .findOneAndUpdate(filter, update, { new: true, ...options }) + .session(this.mongoTxHost.tx) + .lean(); return doc ? this.toDomainEntity(doc) : null; } async deleteOneById(id: string): Promise { - return this.deleteOne({ id }); + return this.deleteOne({ _id: id }); } async deleteOne(filter: QueryFilter): Promise { @@ -144,7 +149,7 @@ export abstract class BaseRepositoryMongo< .deleteOne(filter) .session(this.mongoTxHost.tx); - return deleteResult.acknowledged; + return deleteResult.deletedCount > 0; } async deleteMany(filter: QueryFilter): Promise { @@ -152,13 +157,14 @@ export abstract class BaseRepositoryMongo< .deleteMany(filter) .session(this.mongoTxHost.tx); - return deleteResult.acknowledged; + return deleteResult.deletedCount > 0; } async findByIdAndDelete(id: string): Promise { const deletedDoc = await this.model .findByIdAndDelete(id) - .session(this.mongoTxHost.tx); + .session(this.mongoTxHost.tx) + .lean(); return deletedDoc ? this.toDomainEntity(deletedDoc) : null; } @@ -168,7 +174,8 @@ export abstract class BaseRepositoryMongo< ): Promise { const deletedDoc = await this.model .findOneAndDelete(filter) - .session(this.mongoTxHost.tx); + .session(this.mongoTxHost.tx) + .lean(); return deletedDoc ? this.toDomainEntity(deletedDoc) : null; } From 9397012904a806ef67c1a6480886905249b8bb88 Mon Sep 17 00:00:00 2001 From: Anns Shahbaz Date: Wed, 10 Dec 2025 23:56:21 +0500 Subject: [PATCH 13/37] auto gen --- apps/api/package.json | 4 +- apps/api/scripts/README.md | 42 ++++ .../scripts/mongoose/generate-repository.ts | 128 ++++++++++ .../api/scripts/prisma/generate-repository.ts | 231 ++++++++++++++++++ apps/api/scripts/seed/seeders/crud.seeder.ts | 2 +- apps/api/src/modules/crud/crud.module.ts | 5 +- .../crud/repositories/crud.repository.ts | 161 ++++-------- .../mongoose/crud.mongo.repository.ts | 6 +- .../mongoose}/entities/crud.entity.ts | 4 +- .../prisma/crud.repository.abstract.ts | 76 ++++++ .../prisma/crud.repository.interface.ts | 42 ++++ .../repositories/prisma/crud.repository.ts | 11 + 12 files changed, 591 insertions(+), 121 deletions(-) create mode 100644 apps/api/scripts/README.md create mode 100644 apps/api/scripts/mongoose/generate-repository.ts create mode 100644 apps/api/scripts/prisma/generate-repository.ts rename apps/api/src/modules/crud/{ => repositories/mongoose}/entities/crud.entity.ts (60%) create mode 100644 apps/api/src/modules/crud/repositories/prisma/crud.repository.abstract.ts create mode 100644 apps/api/src/modules/crud/repositories/prisma/crud.repository.interface.ts create mode 100644 apps/api/src/modules/crud/repositories/prisma/crud.repository.ts diff --git a/apps/api/package.json b/apps/api/package.json index 80ef80f..f3e4fb8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -18,7 +18,9 @@ "test:e2e": "jest --config ./test/jest-e2e.json", "db:seed:mongo": "dotenvx run -- ts-node -r tsconfig-paths/register scripts/seed/seed-mongoose.ts", "db:seed:prisma": "cd ../../packages/prisma-db && pnpm run db:seed", - "db:seed:all": "pnpm run db:seed:prisma && pnpm run db:seed:mongo" + "db:seed:all": "pnpm run db:seed:prisma && pnpm run db:seed:mongo", + "generate:repo:prisma": "ts-node -r tsconfig-paths/register scripts/prisma/generate-repository.ts", + "generate:repo:mongo": "ts-node -r tsconfig-paths/register scripts/mongoose/generate-repository.ts" }, "dependencies": { "@andeanwide/nestjs-rollbar": "^1.0.0", diff --git a/apps/api/scripts/README.md b/apps/api/scripts/README.md new file mode 100644 index 0000000..f1f5e07 --- /dev/null +++ b/apps/api/scripts/README.md @@ -0,0 +1,42 @@ +# API Scripts + +## Repository Generators + +### Prisma Repository Generator + +Generates type-safe Prisma repositories. + +```bash +pnpm run generate:repo:prisma +``` + +Example: +```bash +pnpm run generate:repo:prisma crud +``` + +Output: `src/modules/{entity}/repositories/prisma/` +- `{entity}.repository.interface.ts` +- `{entity}.repository.abstract.ts` +- `{entity}.repository.ts` + +### Mongoose Repository Generator + +Generates Mongoose repositories with entity schema. + +```bash +pnpm run generate:repo:mongo +``` + +Example: +```bash +pnpm run generate:repo:mongo crud +``` + +Output: `src/modules/{entity}/repositories/mongoose/` +- `entities/{entity}.entity.ts` - Empty entity class (add @Prop decorators) +- `{entity}.mongo.repository.ts` - Repository implementation + +Note: +1. Add properties to the entity class using @Prop decorators +2. Implement the `toDomainEntity` method in the repository diff --git a/apps/api/scripts/mongoose/generate-repository.ts b/apps/api/scripts/mongoose/generate-repository.ts new file mode 100644 index 0000000..b92344e --- /dev/null +++ b/apps/api/scripts/mongoose/generate-repository.ts @@ -0,0 +1,128 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +interface GeneratorConfig { + entityName: string; +} + +export class RepositoryGenerator { + private readonly entityName: string; + private readonly entityNameCapitalized: string; + private readonly outputDir: string; + + constructor(config: GeneratorConfig) { + this.entityName = config.entityName.toLowerCase(); + this.entityNameCapitalized = + this.entityName.charAt(0).toUpperCase() + this.entityName.slice(1); + this.outputDir = path.join( + __dirname, + '../../src/modules', + this.entityName, + 'repositories/mongoose', + ); + } + + generate(): void { + if (!fs.existsSync(this.outputDir)) { + fs.mkdirSync(this.outputDir, { recursive: true }); + } + + const entitiesDir = path.join(this.outputDir, 'entities'); + if (!fs.existsSync(entitiesDir)) { + fs.mkdirSync(entitiesDir, { recursive: true }); + } + + this.generateEntity(); + this.generateRepository(); + + console.log( + `✅ Generated repository for ${this.entityNameCapitalized} in ${this.outputDir}`, + ); + } + + private generateEntity(): void { + const content = this.getEntityTemplate(); + const filePath = path.join( + this.outputDir, + 'entities', + `${this.entityName}.entity.ts`, + ); + fs.writeFileSync(filePath, content, 'utf-8'); + } + + private generateRepository(): void { + const content = this.getRepositoryTemplate(); + const filePath = path.join( + this.outputDir, + `${this.entityName}.mongo.repository.ts`, + ); + fs.writeFileSync(filePath, content, 'utf-8'); + } + + private getEntityTemplate(): string { + return `import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { MongooseEntity } from '../../../../../repositories/mongoose/base.mongo.entity'; + +@Schema({ collection: '${this.entityName}', timestamps: true }) +export class ${this.entityNameCapitalized}Entity extends MongooseEntity {} + +export const ${this.entityNameCapitalized}Schema = SchemaFactory.createForClass(${this.entityNameCapitalized}Entity); +`; + } + + private getRepositoryTemplate(): string { + return `import { Injectable } from '@nestjs/common'; +import { InjectModel, MongooseModule } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { + InjectTransactionHost, + TransactionHost, +} from '@nestjs-cls/transactional'; +import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter-mongoose'; +import { BaseRepositoryMongo } from '../../../../repositories/mongoose/interfaces/base.abstract.repository'; +import { ${this.entityNameCapitalized}Entity } from './entities/${this.entityName}.entity'; +import { ${this.entityNameCapitalized} } from '../../schemas/${this.entityName}.schema'; + +@Injectable() +export class ${this.entityNameCapitalized}MongoRepository extends BaseRepositoryMongo< + ${this.entityNameCapitalized}, + ${this.entityNameCapitalized}Entity +> { + constructor( + @InjectModel(${this.entityNameCapitalized}Entity.name) + ${this.entityName}Model: Model<${this.entityNameCapitalized}Entity>, + @InjectTransactionHost(MongooseModule.name) + mongoTxHost: TransactionHost, + ) { + super(${this.entityName}Model, mongoTxHost); + } + + protected toDomainEntity(dbEntity: ${this.entityNameCapitalized}Entity): ${this.entityNameCapitalized} { + throw new Error('Method not implemented.'); + + // Complete Conversion Below + // return { + // id: dbEntity._id?.toString() ?? '', + // }; + } +} +`; + } +} + +if (require.main === module) { + const args = process.argv.slice(2); + const entityName = args[0]; + + if (!entityName) { + console.error('❌ Usage: pnpm run generate:repo:mongo '); + console.error(' Example: pnpm run generate:repo:mongo crud'); + process.exit(1); + } + + const generator = new RepositoryGenerator({ + entityName, + }); + + generator.generate(); +} diff --git a/apps/api/scripts/prisma/generate-repository.ts b/apps/api/scripts/prisma/generate-repository.ts new file mode 100644 index 0000000..4341438 --- /dev/null +++ b/apps/api/scripts/prisma/generate-repository.ts @@ -0,0 +1,231 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +interface GeneratorConfig { + entityName: string; +} + +export class RepositoryGenerator { + private readonly entityName: string; + private readonly entityNameCapitalized: string; + private readonly outputDir: string; + + constructor(config: GeneratorConfig) { + this.entityName = config.entityName.toLowerCase(); + this.entityNameCapitalized = + this.entityName.charAt(0).toUpperCase() + this.entityName.slice(1); + this.outputDir = path.join( + __dirname, + '../../src/modules', + this.entityName, + 'repositories/prisma', + ); + } + + generate(): void { + if (!fs.existsSync(this.outputDir)) { + fs.mkdirSync(this.outputDir, { recursive: true }); + } + + this.generateInterface(); + this.generateAbstractClass(); + this.generateImplementation(); + + console.log( + `✅ Generated repository for ${this.entityNameCapitalized} in ${this.outputDir}`, + ); + } + + private generateInterface(): void { + const content = this.getInterfaceTemplate(); + const filePath = path.join( + this.outputDir, + `${this.entityName}.repository.interface.ts`, + ); + fs.writeFileSync(filePath, content, 'utf-8'); + } + + private generateAbstractClass(): void { + const content = this.getAbstractClassTemplate(); + const filePath = path.join( + this.outputDir, + `${this.entityName}.repository.abstract.ts`, + ); + fs.writeFileSync(filePath, content, 'utf-8'); + } + + private generateImplementation(): void { + const content = this.getImplementationTemplate(); + const filePath = path.join( + this.outputDir, + `${this.entityName}.repository.ts`, + ); + fs.writeFileSync(filePath, content, 'utf-8'); + } + + private getInterfaceTemplate(): string { + return `import { Prisma } from '@repo/prisma-db'; + +export interface ${this.entityNameCapitalized}RepositoryInterface { + create( + args: Prisma.${this.entityNameCapitalized}CreateArgs, + ): Promise>; + createMany( + args: Prisma.${this.entityNameCapitalized}CreateManyArgs, + ): Promise; + + findFirst( + args?: Prisma.${this.entityNameCapitalized}FindFirstArgs, + ): Promise | null>; + findFirstOrThrow( + args?: Prisma.${this.entityNameCapitalized}FindFirstArgs, + ): Promise>; + findUnique( + args: Prisma.${this.entityNameCapitalized}FindUniqueArgs, + ): Promise | null>; + findUniqueOrThrow( + args: Prisma.${this.entityNameCapitalized}FindUniqueArgs, + ): Promise>; + findMany( + args?: Prisma.${this.entityNameCapitalized}FindManyArgs, + ): Promise[]>; + + update( + args: Prisma.${this.entityNameCapitalized}UpdateArgs, + ): Promise>; + updateMany( + args: Prisma.${this.entityNameCapitalized}UpdateManyArgs, + ): Promise; + upsert( + args: Prisma.${this.entityNameCapitalized}UpsertArgs, + ): Promise>; + + delete( + args: Prisma.${this.entityNameCapitalized}DeleteArgs, + ): Promise>; + deleteMany( + args?: Prisma.${this.entityNameCapitalized}DeleteManyArgs, + ): Promise; + + count( + args?: Prisma.${this.entityNameCapitalized}CountArgs, + ): Promise; + aggregate( + args: Prisma.${this.entityNameCapitalized}AggregateArgs, + ): Promise>; +} +`; + } + + private getAbstractClassTemplate(): string { + return `import { TransactionHost } from '@nestjs-cls/transactional'; +import { Prisma } from '@repo/prisma-db'; +import { ${this.entityNameCapitalized}RepositoryInterface } from './${this.entityName}.repository.interface'; +import { PrismaTransactionAdapter } from '../../../prisma/prisma.module'; + +export abstract class ${this.entityNameCapitalized}RepositoryAbstract + implements ${this.entityNameCapitalized}RepositoryInterface +{ + protected readonly prismaTxHost: TransactionHost; + + protected constructor( + prismaTxHost: TransactionHost, + ) { + this.prismaTxHost = prismaTxHost; + } + + protected get delegate() { + return this.prismaTxHost.tx.${this.entityName}; + } + + create(args: Prisma.${this.entityNameCapitalized}CreateArgs) { + return this.delegate.create(args); + } + + createMany(args: Prisma.${this.entityNameCapitalized}CreateManyArgs) { + return this.delegate.createMany(args); + } + + findFirst(args?: Prisma.${this.entityNameCapitalized}FindFirstArgs) { + return this.delegate.findFirst(args); + } + + findFirstOrThrow(args?: Prisma.${this.entityNameCapitalized}FindFirstArgs) { + return this.delegate.findFirstOrThrow(args); + } + + findUnique(args: Prisma.${this.entityNameCapitalized}FindUniqueArgs) { + return this.delegate.findUnique(args); + } + + findUniqueOrThrow(args: Prisma.${this.entityNameCapitalized}FindUniqueArgs) { + return this.delegate.findUniqueOrThrow(args); + } + + findMany(args?: Prisma.${this.entityNameCapitalized}FindManyArgs) { + return this.delegate.findMany(args); + } + + update(args: Prisma.${this.entityNameCapitalized}UpdateArgs) { + return this.delegate.update(args); + } + + updateMany(args: Prisma.${this.entityNameCapitalized}UpdateManyArgs) { + return this.delegate.updateMany(args); + } + + upsert(args: Prisma.${this.entityNameCapitalized}UpsertArgs) { + return this.delegate.upsert(args); + } + + delete(args: Prisma.${this.entityNameCapitalized}DeleteArgs) { + return this.delegate.delete(args); + } + + deleteMany(args?: Prisma.${this.entityNameCapitalized}DeleteManyArgs) { + return this.delegate.deleteMany(args); + } + + count(args?: Prisma.${this.entityNameCapitalized}CountArgs) { + return this.delegate.count(args); + } + + aggregate(args: Prisma.${this.entityNameCapitalized}AggregateArgs) { + return this.delegate.aggregate(args); + } +} +`; + } + + private getImplementationTemplate(): string { + return `import { Injectable } from '@nestjs/common'; +import { TransactionHost } from '@nestjs-cls/transactional'; +import { ${this.entityNameCapitalized}RepositoryAbstract } from './${this.entityName}.repository.abstract'; +import { PrismaTransactionAdapter } from '../../../prisma/prisma.module'; + +@Injectable() +export class ${this.entityNameCapitalized}Repository extends ${this.entityNameCapitalized}RepositoryAbstract { + constructor(prismaTxHost: TransactionHost) { + super(prismaTxHost); + } +} +`; + } +} + +if (require.main === module) { + const args = process.argv.slice(2); + const entityName = args[0]; + + if (!entityName) { + console.error('❌ Usage: pnpm run generate:repo:prisma '); + console.error(' Example: pnpm run generate:repo:prisma crud'); + process.exit(1); + } + + const generator = new RepositoryGenerator({ + entityName, + }); + + generator.generate(); +} diff --git a/apps/api/scripts/seed/seeders/crud.seeder.ts b/apps/api/scripts/seed/seeders/crud.seeder.ts index 42f5f2e..9e9841f 100644 --- a/apps/api/scripts/seed/seeders/crud.seeder.ts +++ b/apps/api/scripts/seed/seeders/crud.seeder.ts @@ -2,7 +2,7 @@ import * as mongoose from 'mongoose'; import { CrudEntity, CrudSchema, -} from '../../../src/modules/crud/entities/crud.entity'; +} from '../../../src/modules/crud/repositories/mongoose/entities/crud.entity'; import { MongooseSeeder } from './mongoose.seeder'; import { SeedLogger } from '@repo/db-seeder'; import { StringExtensions } from '@repo/utils-core'; diff --git a/apps/api/src/modules/crud/crud.module.ts b/apps/api/src/modules/crud/crud.module.ts index c9ec7c3..7717e6a 100644 --- a/apps/api/src/modules/crud/crud.module.ts +++ b/apps/api/src/modules/crud/crud.module.ts @@ -1,7 +1,10 @@ import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; import { PrismaModule } from '../prisma/prisma.module'; -import { CrudEntity, CrudSchema } from './entities/crud.entity'; +import { + CrudEntity, + CrudSchema, +} from './repositories/mongoose/entities/crud.entity'; import { CrudService } from './crud.service'; import { CrudRouter } from './crud.router'; import { CrudMongoRepository } from './repositories/mongoose/crud.mongo.repository'; diff --git a/apps/api/src/modules/crud/repositories/crud.repository.ts b/apps/api/src/modules/crud/repositories/crud.repository.ts index cc3ad56..2a7698d 100644 --- a/apps/api/src/modules/crud/repositories/crud.repository.ts +++ b/apps/api/src/modules/crud/repositories/crud.repository.ts @@ -1,114 +1,49 @@ -// import { Injectable } from '@nestjs/common'; -// import { -// InjectTransactionHost, -// TransactionHost, -// } from '@nestjs-cls/transactional'; -// import { -// CreateCrudDto, -// CrudEntity, -// UpdateCrudDto, -// } from '../schemas/crud.schema'; -// import { -// PrismaModule, -// PrismaTransactionAdapter, -// } from '../../prisma/prisma.module'; -// -// @Injectable() -// export class CrudRepository { -// // ====================== -// // Prisma Implementation -// // ====================== -// -// constructor( -// @InjectTransactionHost(PrismaModule.name) -// private readonly prismaTxHost: TransactionHost, -// ) {} -// -// async find(): Promise { -// return this.prismaTxHost.tx.crud.findMany({ -// orderBy: { createdAt: 'desc' }, -// }); -// } -// -// async findOne(id: string): Promise { -// return this.prismaTxHost.tx.crud.findUnique({ -// where: { id }, -// }); -// } -// -// async create(data: CreateCrudDto): Promise { -// return this.prismaTxHost.tx.crud.create({ data }); -// } -// -// async update(id: string, data: UpdateCrudDto): Promise { -// return this.prismaTxHost.tx.crud.update({ -// where: { id }, -// data, -// }); -// } -// -// async delete(id: string): Promise { -// return this.prismaTxHost.tx.crud.delete({ where: { id } }); -// } -// } +import { Injectable } from '@nestjs/common'; +import { + InjectTransactionHost, + TransactionHost, +} from '@nestjs-cls/transactional'; +import { CreateCrudDto, Crud, UpdateCrudDto } from '../schemas/crud.schema'; +import { + PrismaModule, + PrismaTransactionAdapter, +} from '../../prisma/prisma.module'; -// import { InjectModel, MongooseModule } from '@nestjs/mongoose'; -// import { Model } from 'mongoose'; -// import { CrudDocument, CrudEntity } from '../entities/crud.entity'; -// import { CreateCrudDto, Crud, UpdateCrudDto } from '../schemas/crud.schema'; -// import { -// InjectTransactionHost, -// TransactionHost, -// } from '@nestjs-cls/transactional'; -// import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter-mongoose'; -// -// export class CrudRepository { -// constructor( -// @InjectModel(CrudEntity.name) -// private readonly crudModel: Model, -// @InjectTransactionHost(MongooseModule.name) -// private readonly mongoTxHost: TransactionHost, -// ) {} -// -// async find(): Promise { -// const docs = await this.crudModel -// .find() -// .sort({ createdAt: -1 }) -// .session(this.mongoTxHost.tx); -// return docs.map((doc) => this.toEntity(doc)); -// } -// -// async findOne(id: string): Promise { -// const doc = await this.crudModel.findById(id).session(this.mongoTxHost.tx); -// return doc ? this.toEntity(doc) : null; -// } -// -// async create(data: CreateCrudDto): Promise { -// const doc = new this.crudModel(data); -// await doc.save({ session: this.mongoTxHost.tx }); -// return this.toEntity(doc); -// } -// -// async update(id: string, data: UpdateCrudDto): Promise { -// const doc = await this.crudModel -// .findByIdAndUpdate(id, data, { new: true }) -// .session(this.mongoTxHost.tx); -// return doc ? this.toEntity(doc) : null; -// } -// -// async delete(id: string): Promise { -// const doc = await this.crudModel -// .findByIdAndDelete(id) -// .session(this.mongoTxHost.tx); -// return doc ? this.toEntity(doc) : null; -// } -// -// private toEntity(doc: CrudDocument): Crud { -// return { -// id: doc._id.toString(), -// content: doc.content, -// createdAt: doc.createdAt, -// updatedAt: doc.updatedAt, -// }; -// } -// } +@Injectable() +export class CrudRepository { + constructor( + @InjectTransactionHost(PrismaModule.name) + private readonly prismaTxHost: TransactionHost, + ) {} + + get prisma() { + return this.prismaTxHost.tx.crud; + } + + async find(): Promise { + return this.prisma.findMany({ + orderBy: { createdAt: 'desc' }, + }); + } + + async findOne(id: string): Promise { + return this.prisma.findUnique({ + where: { id }, + }); + } + + async create(data: CreateCrudDto): Promise { + return this.prisma.create({ data }); + } + + async update(id: string, data: UpdateCrudDto): Promise { + return this.prisma.update({ + where: { id }, + data, + }); + } + + async delete(id: string): Promise { + return this.prisma.delete({ where: { id } }); + } +} diff --git a/apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts b/apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts index f62ce8d..e7f4011 100644 --- a/apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts +++ b/apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts @@ -1,14 +1,16 @@ +import { Injectable } from '@nestjs/common'; import { InjectModel, MongooseModule } from '@nestjs/mongoose'; -import { BaseRepositoryMongo } from '../../../../repositories/mongoose/interfaces/base.abstract.repository'; -import { CrudEntity } from '../../entities/crud.entity'; import { Model } from 'mongoose'; import { InjectTransactionHost, TransactionHost, } from '@nestjs-cls/transactional'; import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter-mongoose'; +import { BaseRepositoryMongo } from '../../../../repositories/mongoose/interfaces/base.abstract.repository'; +import { CrudEntity } from './entities/crud.entity'; import { Crud } from '../../schemas/crud.schema'; +@Injectable() export class CrudMongoRepository extends BaseRepositoryMongo { constructor( @InjectModel(CrudEntity.name) diff --git a/apps/api/src/modules/crud/entities/crud.entity.ts b/apps/api/src/modules/crud/repositories/mongoose/entities/crud.entity.ts similarity index 60% rename from apps/api/src/modules/crud/entities/crud.entity.ts rename to apps/api/src/modules/crud/repositories/mongoose/entities/crud.entity.ts index d8d9c04..5042596 100644 --- a/apps/api/src/modules/crud/entities/crud.entity.ts +++ b/apps/api/src/modules/crud/repositories/mongoose/entities/crud.entity.ts @@ -1,6 +1,5 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { MongooseEntity } from '../../../repositories/mongoose/base.mongo.entity'; -import { HydratedDocument } from 'mongoose'; +import { MongooseEntity } from '../../../../../repositories/mongoose/base.mongo.entity'; @Schema({ collection: 'crud', timestamps: true }) export class CrudEntity extends MongooseEntity { @@ -8,5 +7,4 @@ export class CrudEntity extends MongooseEntity { content: string; } -export type CrudDocument = HydratedDocument; export const CrudSchema = SchemaFactory.createForClass(CrudEntity); diff --git a/apps/api/src/modules/crud/repositories/prisma/crud.repository.abstract.ts b/apps/api/src/modules/crud/repositories/prisma/crud.repository.abstract.ts new file mode 100644 index 0000000..74202d0 --- /dev/null +++ b/apps/api/src/modules/crud/repositories/prisma/crud.repository.abstract.ts @@ -0,0 +1,76 @@ +import { TransactionHost } from '@nestjs-cls/transactional'; +import { Prisma } from '@repo/prisma-db'; +import { CrudRepositoryInterface } from './crud.repository.interface'; +import { PrismaTransactionAdapter } from '../../../prisma/prisma.module'; + +export abstract class CrudRepositoryAbstract + implements CrudRepositoryInterface +{ + protected readonly prismaTxHost: TransactionHost; + + protected constructor( + prismaTxHost: TransactionHost, + ) { + this.prismaTxHost = prismaTxHost; + } + + protected get delegate() { + return this.prismaTxHost.tx.crud; + } + + create(args: Prisma.CrudCreateArgs) { + return this.delegate.create(args); + } + + createMany(args: Prisma.CrudCreateManyArgs) { + return this.delegate.createMany(args); + } + + findFirst(args?: Prisma.CrudFindFirstArgs) { + return this.delegate.findFirst(args); + } + + findFirstOrThrow(args?: Prisma.CrudFindFirstArgs) { + return this.delegate.findFirstOrThrow(args); + } + + findUnique(args: Prisma.CrudFindUniqueArgs) { + return this.delegate.findUnique(args); + } + + findUniqueOrThrow(args: Prisma.CrudFindUniqueArgs) { + return this.delegate.findUniqueOrThrow(args); + } + + findMany(args?: Prisma.CrudFindManyArgs) { + return this.delegate.findMany(args); + } + + update(args: Prisma.CrudUpdateArgs) { + return this.delegate.update(args); + } + + updateMany(args: Prisma.CrudUpdateManyArgs) { + return this.delegate.updateMany(args); + } + + upsert(args: Prisma.CrudUpsertArgs) { + return this.delegate.upsert(args); + } + + delete(args: Prisma.CrudDeleteArgs) { + return this.delegate.delete(args); + } + + deleteMany(args?: Prisma.CrudDeleteManyArgs) { + return this.delegate.deleteMany(args); + } + + count(args?: Prisma.CrudCountArgs) { + return this.delegate.count(args); + } + + aggregate(args: Prisma.CrudAggregateArgs) { + return this.delegate.aggregate(args); + } +} diff --git a/apps/api/src/modules/crud/repositories/prisma/crud.repository.interface.ts b/apps/api/src/modules/crud/repositories/prisma/crud.repository.interface.ts new file mode 100644 index 0000000..5f90c82 --- /dev/null +++ b/apps/api/src/modules/crud/repositories/prisma/crud.repository.interface.ts @@ -0,0 +1,42 @@ +import { Prisma } from '@repo/prisma-db'; + +export interface CrudRepositoryInterface { + create( + args: Prisma.CrudCreateArgs, + ): Promise>; + createMany(args: Prisma.CrudCreateManyArgs): Promise; + + findFirst( + args?: Prisma.CrudFindFirstArgs, + ): Promise | null>; + findFirstOrThrow( + args?: Prisma.CrudFindFirstArgs, + ): Promise>; + findUnique( + args: Prisma.CrudFindUniqueArgs, + ): Promise | null>; + findUniqueOrThrow( + args: Prisma.CrudFindUniqueArgs, + ): Promise>; + findMany( + args?: Prisma.CrudFindManyArgs, + ): Promise[]>; + + update( + args: Prisma.CrudUpdateArgs, + ): Promise>; + updateMany(args: Prisma.CrudUpdateManyArgs): Promise; + upsert( + args: Prisma.CrudUpsertArgs, + ): Promise>; + + delete( + args: Prisma.CrudDeleteArgs, + ): Promise>; + deleteMany(args?: Prisma.CrudDeleteManyArgs): Promise; + + count(args?: Prisma.CrudCountArgs): Promise; + aggregate( + args: Prisma.CrudAggregateArgs, + ): Promise>; +} diff --git a/apps/api/src/modules/crud/repositories/prisma/crud.repository.ts b/apps/api/src/modules/crud/repositories/prisma/crud.repository.ts new file mode 100644 index 0000000..707789a --- /dev/null +++ b/apps/api/src/modules/crud/repositories/prisma/crud.repository.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { TransactionHost } from '@nestjs-cls/transactional'; +import { CrudRepositoryAbstract } from './crud.repository.abstract'; +import { PrismaTransactionAdapter } from '../../../prisma/prisma.module'; + +@Injectable() +export class CrudRepository extends CrudRepositoryAbstract { + constructor(prismaTxHost: TransactionHost) { + super(prismaTxHost); + } +} From 85d7a164d6001faf38c115ac41881f93792e2ffe Mon Sep 17 00:00:00 2001 From: Anns Shahbaz Date: Fri, 12 Dec 2025 10:18:40 +0500 Subject: [PATCH 14/37] generator improvements --- .../api/scripts/prisma/generate-repository.ts | 72 +++++++++---------- .../prisma/crud.repository.abstract.ts | 50 +++++++------ .../prisma/crud.repository.interface.ts | 6 -- 3 files changed, 62 insertions(+), 66 deletions(-) diff --git a/apps/api/scripts/prisma/generate-repository.ts b/apps/api/scripts/prisma/generate-repository.ts index 4341438..4dc181a 100644 --- a/apps/api/scripts/prisma/generate-repository.ts +++ b/apps/api/scripts/prisma/generate-repository.ts @@ -70,22 +70,14 @@ export interface ${this.entityNameCapitalized}RepositoryInterface { create( args: Prisma.${this.entityNameCapitalized}CreateArgs, ): Promise>; - createMany( - args: Prisma.${this.entityNameCapitalized}CreateManyArgs, - ): Promise; + createMany(args: Prisma.${this.entityNameCapitalized}CreateManyArgs): Promise; findFirst( args?: Prisma.${this.entityNameCapitalized}FindFirstArgs, ): Promise | null>; - findFirstOrThrow( - args?: Prisma.${this.entityNameCapitalized}FindFirstArgs, - ): Promise>; findUnique( args: Prisma.${this.entityNameCapitalized}FindUniqueArgs, ): Promise | null>; - findUniqueOrThrow( - args: Prisma.${this.entityNameCapitalized}FindUniqueArgs, - ): Promise>; findMany( args?: Prisma.${this.entityNameCapitalized}FindManyArgs, ): Promise[]>; @@ -93,9 +85,7 @@ export interface ${this.entityNameCapitalized}RepositoryInterface { update( args: Prisma.${this.entityNameCapitalized}UpdateArgs, ): Promise>; - updateMany( - args: Prisma.${this.entityNameCapitalized}UpdateManyArgs, - ): Promise; + updateMany(args: Prisma.${this.entityNameCapitalized}UpdateManyArgs): Promise; upsert( args: Prisma.${this.entityNameCapitalized}UpsertArgs, ): Promise>; @@ -103,13 +93,9 @@ export interface ${this.entityNameCapitalized}RepositoryInterface { delete( args: Prisma.${this.entityNameCapitalized}DeleteArgs, ): Promise>; - deleteMany( - args?: Prisma.${this.entityNameCapitalized}DeleteManyArgs, - ): Promise; + deleteMany(args?: Prisma.${this.entityNameCapitalized}DeleteManyArgs): Promise; - count( - args?: Prisma.${this.entityNameCapitalized}CountArgs, - ): Promise; + count(args?: Prisma.${this.entityNameCapitalized}CountArgs): Promise; aggregate( args: Prisma.${this.entityNameCapitalized}AggregateArgs, ): Promise>; @@ -134,63 +120,71 @@ export abstract class ${this.entityNameCapitalized}RepositoryAbstract this.prismaTxHost = prismaTxHost; } - protected get delegate() { + protected get delegate(): Prisma.${this.entityNameCapitalized}Delegate { return this.prismaTxHost.tx.${this.entityName}; } - create(args: Prisma.${this.entityNameCapitalized}CreateArgs) { + create( + args: Prisma.${this.entityNameCapitalized}CreateArgs, + ): Promise> { return this.delegate.create(args); } - createMany(args: Prisma.${this.entityNameCapitalized}CreateManyArgs) { + createMany(args: Prisma.${this.entityNameCapitalized}CreateManyArgs): Promise { return this.delegate.createMany(args); } - findFirst(args?: Prisma.${this.entityNameCapitalized}FindFirstArgs) { + findFirst( + args?: Prisma.${this.entityNameCapitalized}FindFirstArgs, + ): Promise | null> { return this.delegate.findFirst(args); } - findFirstOrThrow(args?: Prisma.${this.entityNameCapitalized}FindFirstArgs) { - return this.delegate.findFirstOrThrow(args); - } - - findUnique(args: Prisma.${this.entityNameCapitalized}FindUniqueArgs) { + findUnique( + args: Prisma.${this.entityNameCapitalized}FindUniqueArgs, + ): Promise | null> { return this.delegate.findUnique(args); } - findUniqueOrThrow(args: Prisma.${this.entityNameCapitalized}FindUniqueArgs) { - return this.delegate.findUniqueOrThrow(args); - } - - findMany(args?: Prisma.${this.entityNameCapitalized}FindManyArgs) { + findMany( + args?: Prisma.${this.entityNameCapitalized}FindManyArgs, + ): Promise[]> { return this.delegate.findMany(args); } - update(args: Prisma.${this.entityNameCapitalized}UpdateArgs) { + update( + args: Prisma.${this.entityNameCapitalized}UpdateArgs, + ): Promise> { return this.delegate.update(args); } - updateMany(args: Prisma.${this.entityNameCapitalized}UpdateManyArgs) { + updateMany(args: Prisma.${this.entityNameCapitalized}UpdateManyArgs): Promise { return this.delegate.updateMany(args); } - upsert(args: Prisma.${this.entityNameCapitalized}UpsertArgs) { + upsert( + args: Prisma.${this.entityNameCapitalized}UpsertArgs, + ): Promise> { return this.delegate.upsert(args); } - delete(args: Prisma.${this.entityNameCapitalized}DeleteArgs) { + delete( + args: Prisma.${this.entityNameCapitalized}DeleteArgs, + ): Promise> { return this.delegate.delete(args); } - deleteMany(args?: Prisma.${this.entityNameCapitalized}DeleteManyArgs) { + deleteMany(args?: Prisma.${this.entityNameCapitalized}DeleteManyArgs): Promise { return this.delegate.deleteMany(args); } - count(args?: Prisma.${this.entityNameCapitalized}CountArgs) { + count(args?: Prisma.${this.entityNameCapitalized}CountArgs): Promise { return this.delegate.count(args); } - aggregate(args: Prisma.${this.entityNameCapitalized}AggregateArgs) { + aggregate( + args: Prisma.${this.entityNameCapitalized}AggregateArgs, + ): Promise> { return this.delegate.aggregate(args); } } diff --git a/apps/api/src/modules/crud/repositories/prisma/crud.repository.abstract.ts b/apps/api/src/modules/crud/repositories/prisma/crud.repository.abstract.ts index 74202d0..f4aef15 100644 --- a/apps/api/src/modules/crud/repositories/prisma/crud.repository.abstract.ts +++ b/apps/api/src/modules/crud/repositories/prisma/crud.repository.abstract.ts @@ -14,63 +14,71 @@ export abstract class CrudRepositoryAbstract this.prismaTxHost = prismaTxHost; } - protected get delegate() { + protected get delegate(): Prisma.CrudDelegate { return this.prismaTxHost.tx.crud; } - create(args: Prisma.CrudCreateArgs) { + create( + args: Prisma.CrudCreateArgs, + ): Promise> { return this.delegate.create(args); } - createMany(args: Prisma.CrudCreateManyArgs) { + createMany(args: Prisma.CrudCreateManyArgs): Promise { return this.delegate.createMany(args); } - findFirst(args?: Prisma.CrudFindFirstArgs) { + findFirst( + args?: Prisma.CrudFindFirstArgs, + ): Promise | null> { return this.delegate.findFirst(args); } - findFirstOrThrow(args?: Prisma.CrudFindFirstArgs) { - return this.delegate.findFirstOrThrow(args); - } - - findUnique(args: Prisma.CrudFindUniqueArgs) { + findUnique( + args: Prisma.CrudFindUniqueArgs, + ): Promise | null> { return this.delegate.findUnique(args); } - findUniqueOrThrow(args: Prisma.CrudFindUniqueArgs) { - return this.delegate.findUniqueOrThrow(args); - } - - findMany(args?: Prisma.CrudFindManyArgs) { + findMany( + args?: Prisma.CrudFindManyArgs, + ): Promise[]> { return this.delegate.findMany(args); } - update(args: Prisma.CrudUpdateArgs) { + update( + args: Prisma.CrudUpdateArgs, + ): Promise> { return this.delegate.update(args); } - updateMany(args: Prisma.CrudUpdateManyArgs) { + updateMany(args: Prisma.CrudUpdateManyArgs): Promise { return this.delegate.updateMany(args); } - upsert(args: Prisma.CrudUpsertArgs) { + upsert( + args: Prisma.CrudUpsertArgs, + ): Promise> { return this.delegate.upsert(args); } - delete(args: Prisma.CrudDeleteArgs) { + delete( + args: Prisma.CrudDeleteArgs, + ): Promise> { return this.delegate.delete(args); } - deleteMany(args?: Prisma.CrudDeleteManyArgs) { + deleteMany(args?: Prisma.CrudDeleteManyArgs): Promise { return this.delegate.deleteMany(args); } - count(args?: Prisma.CrudCountArgs) { + count(args?: Prisma.CrudCountArgs): Promise { return this.delegate.count(args); } - aggregate(args: Prisma.CrudAggregateArgs) { + aggregate( + args: Prisma.CrudAggregateArgs, + ): Promise> { return this.delegate.aggregate(args); } } diff --git a/apps/api/src/modules/crud/repositories/prisma/crud.repository.interface.ts b/apps/api/src/modules/crud/repositories/prisma/crud.repository.interface.ts index 5f90c82..b3886ea 100644 --- a/apps/api/src/modules/crud/repositories/prisma/crud.repository.interface.ts +++ b/apps/api/src/modules/crud/repositories/prisma/crud.repository.interface.ts @@ -9,15 +9,9 @@ export interface CrudRepositoryInterface { findFirst( args?: Prisma.CrudFindFirstArgs, ): Promise | null>; - findFirstOrThrow( - args?: Prisma.CrudFindFirstArgs, - ): Promise>; findUnique( args: Prisma.CrudFindUniqueArgs, ): Promise | null>; - findUniqueOrThrow( - args: Prisma.CrudFindUniqueArgs, - ): Promise>; findMany( args?: Prisma.CrudFindManyArgs, ): Promise[]>; From 235a34666cca4abf78d1b90c68016d2f34f57fb6 Mon Sep 17 00:00:00 2001 From: Anns Shahbaz Date: Fri, 12 Dec 2025 11:15:01 +0500 Subject: [PATCH 15/37] ai --- .../scripts/mongoose/generate-repository.ts | 32 ++++------ .../api/scripts/prisma/generate-repository.ts | 59 +++++-------------- apps/api/src/modules/crud/crud.module.ts | 16 ++--- apps/api/src/modules/crud/crud.service.ts | 4 +- .../mongoose/crud.mongoose-entity.ts | 10 ++++ ...ository.ts => crud.mongoose-repository.ts} | 15 +++-- .../mongoose/entities/crud.entity.ts | 10 ---- ...ts => crud.prisma-repository.interface.ts} | 2 +- ....abstract.ts => crud.prisma-repository.ts} | 18 +++--- .../repositories/prisma/crud.repository.ts | 11 ---- ...ongo.entity.ts => mongoose.base-entity.ts} | 2 +- ...ository.ts => mongoose.base-repository.ts} | 12 ++-- ...ry.ts => mongoose.repository.interface.ts} | 8 +-- 13 files changed, 77 insertions(+), 122 deletions(-) create mode 100644 apps/api/src/modules/crud/repositories/mongoose/crud.mongoose-entity.ts rename apps/api/src/modules/crud/repositories/mongoose/{crud.mongo.repository.ts => crud.mongoose-repository.ts} (64%) delete mode 100644 apps/api/src/modules/crud/repositories/mongoose/entities/crud.entity.ts rename apps/api/src/modules/crud/repositories/prisma/{crud.repository.interface.ts => crud.prisma-repository.interface.ts} (96%) rename apps/api/src/modules/crud/repositories/prisma/{crud.repository.abstract.ts => crud.prisma-repository.ts} (84%) delete mode 100644 apps/api/src/modules/crud/repositories/prisma/crud.repository.ts rename apps/api/src/repositories/mongoose/{base.mongo.entity.ts => mongoose.base-entity.ts} (82%) rename apps/api/src/repositories/mongoose/{interfaces/base.abstract.repository.ts => mongoose.base-repository.ts} (93%) rename apps/api/src/repositories/mongoose/{interfaces/base.interface.repository.ts => mongoose.repository.interface.ts} (89%) diff --git a/apps/api/scripts/mongoose/generate-repository.ts b/apps/api/scripts/mongoose/generate-repository.ts index b92344e..e15607b 100644 --- a/apps/api/scripts/mongoose/generate-repository.ts +++ b/apps/api/scripts/mongoose/generate-repository.ts @@ -27,11 +27,6 @@ export class RepositoryGenerator { fs.mkdirSync(this.outputDir, { recursive: true }); } - const entitiesDir = path.join(this.outputDir, 'entities'); - if (!fs.existsSync(entitiesDir)) { - fs.mkdirSync(entitiesDir, { recursive: true }); - } - this.generateEntity(); this.generateRepository(); @@ -44,8 +39,7 @@ export class RepositoryGenerator { const content = this.getEntityTemplate(); const filePath = path.join( this.outputDir, - 'entities', - `${this.entityName}.entity.ts`, + `${this.entityName}.mongoose-entity.ts`, ); fs.writeFileSync(filePath, content, 'utf-8'); } @@ -54,19 +48,19 @@ export class RepositoryGenerator { const content = this.getRepositoryTemplate(); const filePath = path.join( this.outputDir, - `${this.entityName}.mongo.repository.ts`, + `${this.entityName}.mongoose-repository.ts`, ); fs.writeFileSync(filePath, content, 'utf-8'); } private getEntityTemplate(): string { return `import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { MongooseEntity } from '../../../../../repositories/mongoose/base.mongo.entity'; +import { MongooseBaseEntity } from '../../../../repositories/mongoose/mongoose.base-entity'; @Schema({ collection: '${this.entityName}', timestamps: true }) -export class ${this.entityNameCapitalized}Entity extends MongooseEntity {} +export class ${this.entityNameCapitalized}MongooseEntity extends MongooseBaseEntity {} -export const ${this.entityNameCapitalized}Schema = SchemaFactory.createForClass(${this.entityNameCapitalized}Entity); +export const ${this.entityNameCapitalized}MongooseSchema = SchemaFactory.createForClass(${this.entityNameCapitalized}MongooseEntity); `; } @@ -79,27 +73,27 @@ import { TransactionHost, } from '@nestjs-cls/transactional'; import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter-mongoose'; -import { BaseRepositoryMongo } from '../../../../repositories/mongoose/interfaces/base.abstract.repository'; -import { ${this.entityNameCapitalized}Entity } from './entities/${this.entityName}.entity'; +import { MongooseBaseRepository } from '../../../../repositories/mongoose/mongoose.base-repository'; +import { ${this.entityNameCapitalized}MongooseEntity } from './${this.entityName}.mongoose-entity'; import { ${this.entityNameCapitalized} } from '../../schemas/${this.entityName}.schema'; @Injectable() -export class ${this.entityNameCapitalized}MongoRepository extends BaseRepositoryMongo< +export class ${this.entityNameCapitalized}MongooseRepository extends MongooseBaseRepository< ${this.entityNameCapitalized}, - ${this.entityNameCapitalized}Entity + ${this.entityNameCapitalized}MongooseEntity > { constructor( - @InjectModel(${this.entityNameCapitalized}Entity.name) - ${this.entityName}Model: Model<${this.entityNameCapitalized}Entity>, + @InjectModel(${this.entityNameCapitalized}MongooseEntity.name) + ${this.entityName}Model: Model<${this.entityNameCapitalized}MongooseEntity>, @InjectTransactionHost(MongooseModule.name) mongoTxHost: TransactionHost, ) { super(${this.entityName}Model, mongoTxHost); } - protected toDomainEntity(dbEntity: ${this.entityNameCapitalized}Entity): ${this.entityNameCapitalized} { + protected toDomainEntity(dbEntity: ${this.entityNameCapitalized}MongooseEntity): ${this.entityNameCapitalized} { throw new Error('Method not implemented.'); - + // Complete Conversion Below // return { // id: dbEntity._id?.toString() ?? '', diff --git a/apps/api/scripts/prisma/generate-repository.ts b/apps/api/scripts/prisma/generate-repository.ts index 4dc181a..192941c 100644 --- a/apps/api/scripts/prisma/generate-repository.ts +++ b/apps/api/scripts/prisma/generate-repository.ts @@ -28,8 +28,7 @@ export class RepositoryGenerator { } this.generateInterface(); - this.generateAbstractClass(); - this.generateImplementation(); + this.generateRepository(); console.log( `✅ Generated repository for ${this.entityNameCapitalized} in ${this.outputDir}`, @@ -40,25 +39,16 @@ export class RepositoryGenerator { const content = this.getInterfaceTemplate(); const filePath = path.join( this.outputDir, - `${this.entityName}.repository.interface.ts`, + `${this.entityName}.prisma-repository.interface.ts`, ); fs.writeFileSync(filePath, content, 'utf-8'); } - private generateAbstractClass(): void { - const content = this.getAbstractClassTemplate(); + private generateRepository(): void { + const content = this.getRepositoryTemplate(); const filePath = path.join( this.outputDir, - `${this.entityName}.repository.abstract.ts`, - ); - fs.writeFileSync(filePath, content, 'utf-8'); - } - - private generateImplementation(): void { - const content = this.getImplementationTemplate(); - const filePath = path.join( - this.outputDir, - `${this.entityName}.repository.ts`, + `${this.entityName}.prisma-repository.ts`, ); fs.writeFileSync(filePath, content, 'utf-8'); } @@ -66,7 +56,7 @@ export class RepositoryGenerator { private getInterfaceTemplate(): string { return `import { Prisma } from '@repo/prisma-db'; -export interface ${this.entityNameCapitalized}RepositoryInterface { +export interface I${this.entityNameCapitalized}PrismaRepository { create( args: Prisma.${this.entityNameCapitalized}CreateArgs, ): Promise>; @@ -103,22 +93,18 @@ export interface ${this.entityNameCapitalized}RepositoryInterface { `; } - private getAbstractClassTemplate(): string { - return `import { TransactionHost } from '@nestjs-cls/transactional'; + private getRepositoryTemplate(): string { + return `import { Injectable } from '@nestjs/common'; +import { TransactionHost } from '@nestjs-cls/transactional'; import { Prisma } from '@repo/prisma-db'; -import { ${this.entityNameCapitalized}RepositoryInterface } from './${this.entityName}.repository.interface'; +import { I${this.entityNameCapitalized}PrismaRepository } from './${this.entityName}.prisma-repository.interface'; import { PrismaTransactionAdapter } from '../../../prisma/prisma.module'; -export abstract class ${this.entityNameCapitalized}RepositoryAbstract - implements ${this.entityNameCapitalized}RepositoryInterface -{ - protected readonly prismaTxHost: TransactionHost; - - protected constructor( - prismaTxHost: TransactionHost, - ) { - this.prismaTxHost = prismaTxHost; - } +@Injectable() +export class ${this.entityNameCapitalized}PrismaRepository implements I${this.entityNameCapitalized}PrismaRepository { + constructor( + protected readonly prismaTxHost: TransactionHost, + ) {} protected get delegate(): Prisma.${this.entityNameCapitalized}Delegate { return this.prismaTxHost.tx.${this.entityName}; @@ -188,21 +174,6 @@ export abstract class ${this.entityNameCapitalized}RepositoryAbstract return this.delegate.aggregate(args); } } -`; - } - - private getImplementationTemplate(): string { - return `import { Injectable } from '@nestjs/common'; -import { TransactionHost } from '@nestjs-cls/transactional'; -import { ${this.entityNameCapitalized}RepositoryAbstract } from './${this.entityName}.repository.abstract'; -import { PrismaTransactionAdapter } from '../../../prisma/prisma.module'; - -@Injectable() -export class ${this.entityNameCapitalized}Repository extends ${this.entityNameCapitalized}RepositoryAbstract { - constructor(prismaTxHost: TransactionHost) { - super(prismaTxHost); - } -} `; } } diff --git a/apps/api/src/modules/crud/crud.module.ts b/apps/api/src/modules/crud/crud.module.ts index 7717e6a..7ead688 100644 --- a/apps/api/src/modules/crud/crud.module.ts +++ b/apps/api/src/modules/crud/crud.module.ts @@ -2,19 +2,21 @@ import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; import { PrismaModule } from '../prisma/prisma.module'; import { - CrudEntity, - CrudSchema, -} from './repositories/mongoose/entities/crud.entity'; + CrudMongooseEntity, + CrudMongooseSchema, +} from './repositories/mongoose/crud.mongoose-entity'; import { CrudService } from './crud.service'; import { CrudRouter } from './crud.router'; -import { CrudMongoRepository } from './repositories/mongoose/crud.mongo.repository'; +import { CrudMongooseRepository } from './repositories/mongoose/crud.mongoose-repository'; @Module({ imports: [ PrismaModule, - MongooseModule.forFeature([{ name: CrudEntity.name, schema: CrudSchema }]), + MongooseModule.forFeature([ + { name: CrudMongooseEntity.name, schema: CrudMongooseSchema }, + ]), ], - providers: [CrudMongoRepository, CrudService, CrudRouter], - exports: [CrudMongoRepository, CrudService], + providers: [CrudMongooseRepository, CrudService, CrudRouter], + exports: [CrudMongooseRepository, CrudService], }) export class CrudModule {} diff --git a/apps/api/src/modules/crud/crud.service.ts b/apps/api/src/modules/crud/crud.service.ts index d051ebe..bb680de 100644 --- a/apps/api/src/modules/crud/crud.service.ts +++ b/apps/api/src/modules/crud/crud.service.ts @@ -3,12 +3,12 @@ import { Crud } from './schemas/crud.schema'; import { NoTransaction } from '../../decorators/method/no-transaction.decorator'; import { Transactional } from '../../decorators/class/transactional.decorator'; import { MongooseModule } from '@nestjs/mongoose'; -import { CrudMongoRepository } from './repositories/mongoose/crud.mongo.repository'; +import { CrudMongooseRepository } from './repositories/mongoose/crud.mongoose-repository'; @Injectable() @Transactional(MongooseModule.name) export class CrudService { - constructor(private readonly crudRepository: CrudMongoRepository) {} + constructor(private readonly crudRepository: CrudMongooseRepository) {} async createCrud(data: Partial): Promise { const created = await this.crudRepository.create(data); diff --git a/apps/api/src/modules/crud/repositories/mongoose/crud.mongoose-entity.ts b/apps/api/src/modules/crud/repositories/mongoose/crud.mongoose-entity.ts new file mode 100644 index 0000000..fb1a6a2 --- /dev/null +++ b/apps/api/src/modules/crud/repositories/mongoose/crud.mongoose-entity.ts @@ -0,0 +1,10 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { MongooseBaseEntity } from '../../../../repositories/mongoose/mongoose.base-entity'; + +@Schema({ collection: 'crud', timestamps: true }) +export class CrudMongooseEntity extends MongooseBaseEntity { + @Prop({ required: true }) + content: string; +} + +export const CrudMongooseSchema = SchemaFactory.createForClass(CrudMongooseEntity); diff --git a/apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts b/apps/api/src/modules/crud/repositories/mongoose/crud.mongoose-repository.ts similarity index 64% rename from apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts rename to apps/api/src/modules/crud/repositories/mongoose/crud.mongoose-repository.ts index e7f4011..3fe182c 100644 --- a/apps/api/src/modules/crud/repositories/mongoose/crud.mongo.repository.ts +++ b/apps/api/src/modules/crud/repositories/mongoose/crud.mongoose-repository.ts @@ -6,22 +6,25 @@ import { TransactionHost, } from '@nestjs-cls/transactional'; import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter-mongoose'; -import { BaseRepositoryMongo } from '../../../../repositories/mongoose/interfaces/base.abstract.repository'; -import { CrudEntity } from './entities/crud.entity'; +import { MongooseBaseRepository } from '../../../../repositories/mongoose/mongoose.base-repository'; +import { CrudMongooseEntity } from './crud.mongoose-entity'; import { Crud } from '../../schemas/crud.schema'; @Injectable() -export class CrudMongoRepository extends BaseRepositoryMongo { +export class CrudMongooseRepository extends MongooseBaseRepository< + Crud, + CrudMongooseEntity +> { constructor( - @InjectModel(CrudEntity.name) - crudModel: Model, + @InjectModel(CrudMongooseEntity.name) + crudModel: Model, @InjectTransactionHost(MongooseModule.name) mongoTxHost: TransactionHost, ) { super(crudModel, mongoTxHost); } - protected toDomainEntity(dbEntity: CrudEntity): Crud { + protected toDomainEntity(dbEntity: CrudMongooseEntity): Crud { return { id: dbEntity._id?.toString() ?? '', content: dbEntity.content, diff --git a/apps/api/src/modules/crud/repositories/mongoose/entities/crud.entity.ts b/apps/api/src/modules/crud/repositories/mongoose/entities/crud.entity.ts deleted file mode 100644 index 5042596..0000000 --- a/apps/api/src/modules/crud/repositories/mongoose/entities/crud.entity.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { MongooseEntity } from '../../../../../repositories/mongoose/base.mongo.entity'; - -@Schema({ collection: 'crud', timestamps: true }) -export class CrudEntity extends MongooseEntity { - @Prop({ required: true }) - content: string; -} - -export const CrudSchema = SchemaFactory.createForClass(CrudEntity); diff --git a/apps/api/src/modules/crud/repositories/prisma/crud.repository.interface.ts b/apps/api/src/modules/crud/repositories/prisma/crud.prisma-repository.interface.ts similarity index 96% rename from apps/api/src/modules/crud/repositories/prisma/crud.repository.interface.ts rename to apps/api/src/modules/crud/repositories/prisma/crud.prisma-repository.interface.ts index b3886ea..fd5b5b4 100644 --- a/apps/api/src/modules/crud/repositories/prisma/crud.repository.interface.ts +++ b/apps/api/src/modules/crud/repositories/prisma/crud.prisma-repository.interface.ts @@ -1,6 +1,6 @@ import { Prisma } from '@repo/prisma-db'; -export interface CrudRepositoryInterface { +export interface ICrudPrismaRepository { create( args: Prisma.CrudCreateArgs, ): Promise>; diff --git a/apps/api/src/modules/crud/repositories/prisma/crud.repository.abstract.ts b/apps/api/src/modules/crud/repositories/prisma/crud.prisma-repository.ts similarity index 84% rename from apps/api/src/modules/crud/repositories/prisma/crud.repository.abstract.ts rename to apps/api/src/modules/crud/repositories/prisma/crud.prisma-repository.ts index f4aef15..8ae4b50 100644 --- a/apps/api/src/modules/crud/repositories/prisma/crud.repository.abstract.ts +++ b/apps/api/src/modules/crud/repositories/prisma/crud.prisma-repository.ts @@ -1,18 +1,14 @@ +import { Injectable } from '@nestjs/common'; import { TransactionHost } from '@nestjs-cls/transactional'; import { Prisma } from '@repo/prisma-db'; -import { CrudRepositoryInterface } from './crud.repository.interface'; +import { ICrudPrismaRepository } from './crud.prisma-repository.interface'; import { PrismaTransactionAdapter } from '../../../prisma/prisma.module'; -export abstract class CrudRepositoryAbstract - implements CrudRepositoryInterface -{ - protected readonly prismaTxHost: TransactionHost; - - protected constructor( - prismaTxHost: TransactionHost, - ) { - this.prismaTxHost = prismaTxHost; - } +@Injectable() +export class CrudPrismaRepository implements ICrudPrismaRepository { + constructor( + protected readonly prismaTxHost: TransactionHost, + ) {} protected get delegate(): Prisma.CrudDelegate { return this.prismaTxHost.tx.crud; diff --git a/apps/api/src/modules/crud/repositories/prisma/crud.repository.ts b/apps/api/src/modules/crud/repositories/prisma/crud.repository.ts deleted file mode 100644 index 707789a..0000000 --- a/apps/api/src/modules/crud/repositories/prisma/crud.repository.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { TransactionHost } from '@nestjs-cls/transactional'; -import { CrudRepositoryAbstract } from './crud.repository.abstract'; -import { PrismaTransactionAdapter } from '../../../prisma/prisma.module'; - -@Injectable() -export class CrudRepository extends CrudRepositoryAbstract { - constructor(prismaTxHost: TransactionHost) { - super(prismaTxHost); - } -} diff --git a/apps/api/src/repositories/mongoose/base.mongo.entity.ts b/apps/api/src/repositories/mongoose/mongoose.base-entity.ts similarity index 82% rename from apps/api/src/repositories/mongoose/base.mongo.entity.ts rename to apps/api/src/repositories/mongoose/mongoose.base-entity.ts index 5f8e1b9..9762118 100644 --- a/apps/api/src/repositories/mongoose/base.mongo.entity.ts +++ b/apps/api/src/repositories/mongoose/mongoose.base-entity.ts @@ -1,7 +1,7 @@ import { Types } from 'mongoose'; import { Prop } from '@nestjs/mongoose'; -export class MongooseEntity { +export class MongooseBaseEntity { _id?: Types.ObjectId; @Prop() createdAt: Date; diff --git a/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts b/apps/api/src/repositories/mongoose/mongoose.base-repository.ts similarity index 93% rename from apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts rename to apps/api/src/repositories/mongoose/mongoose.base-repository.ts index afad389..431892a 100644 --- a/apps/api/src/repositories/mongoose/interfaces/base.abstract.repository.ts +++ b/apps/api/src/repositories/mongoose/mongoose.base-repository.ts @@ -1,4 +1,4 @@ -import { MongooseRepositoryInterface } from './base.interface.repository'; +import { IMongooseRepository } from './mongoose.repository.interface'; import { InsertManyOptions, Model, @@ -7,15 +7,15 @@ import { QueryOptions, UpdateQuery, } from 'mongoose'; -import { Entity } from '../../../schemas/base.schema'; +import { Entity } from '../../schemas/base.schema'; import { TransactionHost } from '@nestjs-cls/transactional'; import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter-mongoose'; -import { MongooseEntity } from '../base.mongo.entity'; +import { MongooseBaseEntity } from './mongoose.base-entity'; -export abstract class BaseRepositoryMongo< +export abstract class MongooseBaseRepository< TDomainEntity extends Entity, - TDbEntity extends MongooseEntity, -> implements MongooseRepositoryInterface + TDbEntity extends MongooseBaseEntity, +> implements IMongooseRepository { protected readonly model: Model; protected readonly mongoTxHost: TransactionHost; diff --git a/apps/api/src/repositories/mongoose/interfaces/base.interface.repository.ts b/apps/api/src/repositories/mongoose/mongoose.repository.interface.ts similarity index 89% rename from apps/api/src/repositories/mongoose/interfaces/base.interface.repository.ts rename to apps/api/src/repositories/mongoose/mongoose.repository.interface.ts index 571aa26..b9daa1c 100644 --- a/apps/api/src/repositories/mongoose/interfaces/base.interface.repository.ts +++ b/apps/api/src/repositories/mongoose/mongoose.repository.interface.ts @@ -5,12 +5,12 @@ import { QueryOptions, UpdateQuery, } from 'mongoose'; -import { MongooseEntity } from '../base.mongo.entity'; -import { Entity } from '../../../schemas/base.schema'; +import { MongooseBaseEntity } from './mongoose.base-entity'; +import { Entity } from '../../schemas/base.schema'; -export interface MongooseRepositoryInterface< +export interface IMongooseRepository< TDomainEntity extends Entity, - TDbEntity extends MongooseEntity, + TDbEntity extends MongooseBaseEntity, > { create(entity: Partial): Promise; createMany( From 75a4605bcc699656128547d71ea646ef3061c923 Mon Sep 17 00:00:00 2001 From: Anns Shahbaz Date: Fri, 12 Dec 2025 11:16:38 +0500 Subject: [PATCH 16/37] ok --- .../crud/repositories/crud.repository.ts | 49 ------------------- 1 file changed, 49 deletions(-) delete mode 100644 apps/api/src/modules/crud/repositories/crud.repository.ts diff --git a/apps/api/src/modules/crud/repositories/crud.repository.ts b/apps/api/src/modules/crud/repositories/crud.repository.ts deleted file mode 100644 index 2a7698d..0000000 --- a/apps/api/src/modules/crud/repositories/crud.repository.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { - InjectTransactionHost, - TransactionHost, -} from '@nestjs-cls/transactional'; -import { CreateCrudDto, Crud, UpdateCrudDto } from '../schemas/crud.schema'; -import { - PrismaModule, - PrismaTransactionAdapter, -} from '../../prisma/prisma.module'; - -@Injectable() -export class CrudRepository { - constructor( - @InjectTransactionHost(PrismaModule.name) - private readonly prismaTxHost: TransactionHost, - ) {} - - get prisma() { - return this.prismaTxHost.tx.crud; - } - - async find(): Promise { - return this.prisma.findMany({ - orderBy: { createdAt: 'desc' }, - }); - } - - async findOne(id: string): Promise { - return this.prisma.findUnique({ - where: { id }, - }); - } - - async create(data: CreateCrudDto): Promise { - return this.prisma.create({ data }); - } - - async update(id: string, data: UpdateCrudDto): Promise { - return this.prisma.update({ - where: { id }, - data, - }); - } - - async delete(id: string): Promise { - return this.prisma.delete({ where: { id } }); - } -} From aac589414095f6cc9b49f443b153aba26ced8bf4 Mon Sep 17 00:00:00 2001 From: Anns Shahbaz Date: Fri, 12 Dec 2025 12:01:15 +0500 Subject: [PATCH 17/37] gg --- .../scripts/mongoose/generate-repository.ts | 6 + .../api/scripts/prisma/generate-repository.ts | 2 + apps/api/scripts/seed/seeders/crud.seeder.ts | 15 +- apps/api/src/app.module.ts | 5 +- apps/api/src/constants/app.constants.ts | 11 + apps/api/src/modules/crud/crud.module.ts | 26 +- apps/api/src/modules/crud/crud.router.ts | 130 +++++++-- apps/api/src/modules/crud/crud.service.ts | 45 --- .../mongoose/crud.mongoose-repository.ts | 11 +- .../prisma/crud.prisma-repository.ts | 9 +- .../crud/services/crud.mongoose.service.ts | 50 ++++ .../crud/services/crud.prisma.service.ts | 83 ++++++ apps/web/app/crud-demo/page.tsx | 260 +++++++++++------- packages/trpc/src/server/server.ts | 97 ++++++- 14 files changed, 565 insertions(+), 185 deletions(-) create mode 100644 apps/api/src/constants/app.constants.ts delete mode 100644 apps/api/src/modules/crud/crud.service.ts create mode 100644 apps/api/src/modules/crud/services/crud.mongoose.service.ts create mode 100644 apps/api/src/modules/crud/services/crud.prisma.service.ts diff --git a/apps/api/scripts/mongoose/generate-repository.ts b/apps/api/scripts/mongoose/generate-repository.ts index e15607b..7819a93 100644 --- a/apps/api/scripts/mongoose/generate-repository.ts +++ b/apps/api/scripts/mongoose/generate-repository.ts @@ -74,6 +74,7 @@ import { } from '@nestjs-cls/transactional'; import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter-mongoose'; import { MongooseBaseRepository } from '../../../../repositories/mongoose/mongoose.base-repository'; +import { IMongooseRepository } from '../../../../repositories/mongoose/mongoose.repository.interface'; import { ${this.entityNameCapitalized}MongooseEntity } from './${this.entityName}.mongoose-entity'; import { ${this.entityNameCapitalized} } from '../../schemas/${this.entityName}.schema'; @@ -100,6 +101,11 @@ export class ${this.entityNameCapitalized}MongooseRepository extends MongooseBas // }; } } + +export type I${this.entityNameCapitalized}MongooseRepository = IMongooseRepository< + ${this.entityNameCapitalized}, + ${this.entityNameCapitalized}MongooseEntity +>; `; } } diff --git a/apps/api/scripts/prisma/generate-repository.ts b/apps/api/scripts/prisma/generate-repository.ts index 192941c..fdc20fb 100644 --- a/apps/api/scripts/prisma/generate-repository.ts +++ b/apps/api/scripts/prisma/generate-repository.ts @@ -174,6 +174,8 @@ export class ${this.entityNameCapitalized}PrismaRepository implements I${this.en return this.delegate.aggregate(args); } } + +export type { I${this.entityNameCapitalized}PrismaRepository }; `; } } diff --git a/apps/api/scripts/seed/seeders/crud.seeder.ts b/apps/api/scripts/seed/seeders/crud.seeder.ts index 9e9841f..247d006 100644 --- a/apps/api/scripts/seed/seeders/crud.seeder.ts +++ b/apps/api/scripts/seed/seeders/crud.seeder.ts @@ -1,18 +1,23 @@ import * as mongoose from 'mongoose'; import { - CrudEntity, - CrudSchema, -} from '../../../src/modules/crud/repositories/mongoose/entities/crud.entity'; + CrudMongooseEntity, + CrudMongooseSchema, +} from '../../../src/modules/crud/repositories/mongoose/crud.mongoose-entity'; import { MongooseSeeder } from './mongoose.seeder'; import { SeedLogger } from '@repo/db-seeder'; import { StringExtensions } from '@repo/utils-core'; -export class CrudSeeder extends MongooseSeeder> { +export class CrudSeeder extends MongooseSeeder> { readonly entityName = 'CRUD'; readonly seedDataFile = 'crud.json'; constructor() { - super(mongoose.model(CrudEntity.name, CrudSchema)); + super( + mongoose.model( + CrudMongooseEntity.name, + CrudMongooseSchema, + ), + ); } validate(): string[] { diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 6fc0c90..25cff2c 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -16,6 +16,7 @@ import { ClsModule } from 'nestjs-cls'; import { ClsPluginTransactional } from '@nestjs-cls/transactional'; import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter-mongoose'; import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma'; +import { AppConstants } from './constants/app.constants'; @Module({ imports: [ @@ -38,7 +39,7 @@ import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-pr }, plugins: [ new ClsPluginTransactional({ - connectionName: MongooseModule.name, + connectionName: AppConstants.DB_CONNECTIONS.MONGOOSE, imports: [ // module in which the Connection instance is provided MongooseModule, @@ -49,7 +50,7 @@ import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-pr }), }), new ClsPluginTransactional({ - connectionName: PrismaModule.name, + connectionName: AppConstants.DB_CONNECTIONS.PRISMA, imports: [PrismaModule], adapter: new TransactionalAdapterPrisma({ prismaInjectionToken: PrismaService, diff --git a/apps/api/src/constants/app.constants.ts b/apps/api/src/constants/app.constants.ts new file mode 100644 index 0000000..cb692c3 --- /dev/null +++ b/apps/api/src/constants/app.constants.ts @@ -0,0 +1,11 @@ +export class AppConstants { + static readonly DB_CONNECTIONS = { + MONGOOSE: 'MONGOOSE_CONNECTION', + PRISMA: 'PRISMA_CONNECTION', + } as const; + + static readonly REPOSITORIES = { + CRUD_MONGOOSE: Symbol('ICrudMongooseRepository'), + CRUD_PRISMA: Symbol('ICrudPrismaRepository'), + } as const; +} diff --git a/apps/api/src/modules/crud/crud.module.ts b/apps/api/src/modules/crud/crud.module.ts index 7ead688..83809bb 100644 --- a/apps/api/src/modules/crud/crud.module.ts +++ b/apps/api/src/modules/crud/crud.module.ts @@ -5,9 +5,12 @@ import { CrudMongooseEntity, CrudMongooseSchema, } from './repositories/mongoose/crud.mongoose-entity'; -import { CrudService } from './crud.service'; import { CrudRouter } from './crud.router'; import { CrudMongooseRepository } from './repositories/mongoose/crud.mongoose-repository'; +import { CrudPrismaRepository } from './repositories/prisma/crud.prisma-repository'; +import { CrudMongooseService } from './services/crud.mongoose.service'; +import { CrudPrismaService } from './services/crud.prisma.service'; +import { AppConstants } from '../../constants/app.constants'; @Module({ imports: [ @@ -16,7 +19,24 @@ import { CrudMongooseRepository } from './repositories/mongoose/crud.mongoose-re { name: CrudMongooseEntity.name, schema: CrudMongooseSchema }, ]), ], - providers: [CrudMongooseRepository, CrudService, CrudRouter], - exports: [CrudMongooseRepository, CrudService], + providers: [ + { + provide: AppConstants.REPOSITORIES.CRUD_MONGOOSE, + useClass: CrudMongooseRepository, + }, + { + provide: AppConstants.REPOSITORIES.CRUD_PRISMA, + useClass: CrudPrismaRepository, + }, + CrudMongooseService, + CrudPrismaService, + CrudRouter, + ], + exports: [ + AppConstants.REPOSITORIES.CRUD_MONGOOSE, + AppConstants.REPOSITORIES.CRUD_PRISMA, + CrudMongooseService, + CrudPrismaService, + ], }) export class CrudModule {} diff --git a/apps/api/src/modules/crud/crud.router.ts b/apps/api/src/modules/crud/crud.router.ts index 2d1d630..d85e1ea 100644 --- a/apps/api/src/modules/crud/crud.router.ts +++ b/apps/api/src/modules/crud/crud.router.ts @@ -1,5 +1,6 @@ import { Input, Mutation, Query, Router, UseMiddlewares } from 'nestjs-trpc'; -import { CrudService } from './crud.service'; +import { CrudMongooseService } from './services/crud.mongoose.service'; +import { CrudPrismaService } from './services/crud.prisma.service'; import * as CrudSchema from './schemas/crud.schema'; import { ZCrudCreateRequest, @@ -16,25 +17,116 @@ import { import { AuthMiddleware } from '../auth/auth.middleware'; -// Transactions handled at service level (not middleware) @Router({ alias: 'crud' }) export class CrudRouter { - constructor(private readonly crudService: CrudService) {} + constructor( + private readonly crudMongooseService: CrudMongooseService, + private readonly crudPrismaService: CrudPrismaService, + ) {} + + // ==================== MONGOOSE ENDPOINTS ==================== + + @UseMiddlewares(AuthMiddleware) + @Mutation({ + input: ZCrudCreateRequest, + output: ZCrudCreateResponse, + }) + async createCrudMongo( + @Input() req: CrudSchema.TCrudCreateRequest, + ): Promise { + const created = await this.crudMongooseService.createCrud(req); + return { + success: created != null, + id: created?.id, + message: created + ? '[Mongoose] Item created successfully' + : 'Failed to create item', + }; + } + + @Query({ + input: ZCrudFindAllRequest, + output: ZCrudFindAllResponse, + }) + async findAllMongo( + @Input() req?: CrudSchema.TCrudFindAllRequest, + ): Promise { + const limit = req?.limit ?? 10; + const offset = req?.offset ?? 0; + const data = await this.crudMongooseService.findAll(); + + return { + success: data != null, + cruds: data, + total: data.length, + limit, + offset, + }; + } + + @Query({ + input: ZCrudFindOneRequest, + output: ZCrudFindOneResponse, + }) + async findOneCrudMongo( + @Input() req: CrudSchema.TCrudFindOneRequest, + ): Promise { + const result = await this.crudMongooseService.findOne(req.id); + return result ?? null; + } + + @UseMiddlewares(AuthMiddleware) + @Mutation({ + input: ZCrudUpdateRequest, + output: ZCrudUpdateResponse, + }) + async updateCrudMongo( + @Input() req: CrudSchema.TCrudUpdateRequest, + ): Promise { + const updated = await this.crudMongooseService.update(req.id, req.data); + return { + success: updated != null, + data: updated ?? undefined, + message: updated + ? '[Mongoose] Item updated successfully' + : 'Failed to update item', + }; + } + + @UseMiddlewares(AuthMiddleware) + @Mutation({ + input: ZCrudDeleteRequest, + output: ZCrudDeleteResponse, + }) + async deleteCrudMongo( + @Input() req: CrudSchema.TCrudDeleteRequest, + ): Promise { + const deleted = await this.crudMongooseService.delete(req.id); + return { + success: deleted != null, + message: deleted + ? '[Mongoose] Item deleted successfully' + : 'Failed to delete item', + }; + } + + // ==================== PRISMA ENDPOINTS ==================== @UseMiddlewares(AuthMiddleware) @Mutation({ input: ZCrudCreateRequest, output: ZCrudCreateResponse, }) - async createCrud( + async createCrudPrisma( @Input() req: CrudSchema.TCrudCreateRequest, ): Promise { - // Transaction handled at service level - const created = await this.crudService.createCrud(req); + const created = await this.crudPrismaService.createCrud(req); return { success: created != null, id: created?.id, - message: created ? 'Item created successfully' : 'Failed to create item', + message: created + ? '[Prisma] Item created successfully' + : 'Failed to create item', }; } @@ -42,12 +134,12 @@ export class CrudRouter { input: ZCrudFindAllRequest, output: ZCrudFindAllResponse, }) - async findAll( + async findAllPrisma( @Input() req?: CrudSchema.TCrudFindAllRequest, ): Promise { const limit = req?.limit ?? 10; const offset = req?.offset ?? 0; - const data = await this.crudService.findAll(); + const data = await this.crudPrismaService.findAll(); return { success: data != null, @@ -62,10 +154,10 @@ export class CrudRouter { input: ZCrudFindOneRequest, output: ZCrudFindOneResponse, }) - async findOneCrud( + async findOneCrudPrisma( @Input() req: CrudSchema.TCrudFindOneRequest, ): Promise { - const result = await this.crudService.findOne(req.id); + const result = await this.crudPrismaService.findOne(req.id); return result ?? null; } @@ -74,14 +166,16 @@ export class CrudRouter { input: ZCrudUpdateRequest, output: ZCrudUpdateResponse, }) - async updateCrud( + async updateCrudPrisma( @Input() req: CrudSchema.TCrudUpdateRequest, ): Promise { - const updated = await this.crudService.update(req.id, req.data); + const updated = await this.crudPrismaService.update(req.id, req.data); return { success: updated != null, data: updated ?? undefined, - message: updated ? 'Item updated successfully' : 'Failed to update item', + message: updated + ? '[Prisma] Item updated successfully' + : 'Failed to update item', }; } @@ -90,13 +184,15 @@ export class CrudRouter { input: ZCrudDeleteRequest, output: ZCrudDeleteResponse, }) - async deleteCrud( + async deleteCrudPrisma( @Input() req: CrudSchema.TCrudDeleteRequest, ): Promise { - const deleted = await this.crudService.delete(req.id); + const deleted = await this.crudPrismaService.delete(req.id); return { success: deleted != null, - message: deleted ? 'Item deleted successfully' : 'Failed to delete item', + message: deleted + ? '[Prisma] Item deleted successfully' + : 'Failed to delete item', }; } } diff --git a/apps/api/src/modules/crud/crud.service.ts b/apps/api/src/modules/crud/crud.service.ts deleted file mode 100644 index bb680de..0000000 --- a/apps/api/src/modules/crud/crud.service.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { Crud } from './schemas/crud.schema'; -import { NoTransaction } from '../../decorators/method/no-transaction.decorator'; -import { Transactional } from '../../decorators/class/transactional.decorator'; -import { MongooseModule } from '@nestjs/mongoose'; -import { CrudMongooseRepository } from './repositories/mongoose/crud.mongoose-repository'; - -@Injectable() -@Transactional(MongooseModule.name) -export class CrudService { - constructor(private readonly crudRepository: CrudMongooseRepository) {} - - async createCrud(data: Partial): Promise { - const created = await this.crudRepository.create(data); - console.log(created); - // throw new Error('Simulated delete error to test transaction rollback'); - return created; - } - - @NoTransaction() - async findAll(): Promise { - return this.crudRepository.find(); - } - - @NoTransaction() - async findOne(id: string): Promise { - const crud = await this.crudRepository.findById(id); - if (!crud) throw new NotFoundException(`Crud with id ${id} not found`); - return crud; - } - - async update(id: string, data: Partial): Promise { - const updated = await this.crudRepository.findByIdAndUpdate(id, data); - if (!updated) throw new NotFoundException(`Crud with id ${id} not found`); - return updated; - } - - async delete(id: string): Promise { - const deleted = await this.crudRepository.findByIdAndDelete(id); - if (!deleted) throw new NotFoundException(`Crud with id ${id} not found`); - console.log(deleted); - // throw new Error('Simulated delete error to test transaction rollback'); - return deleted; - } -} diff --git a/apps/api/src/modules/crud/repositories/mongoose/crud.mongoose-repository.ts b/apps/api/src/modules/crud/repositories/mongoose/crud.mongoose-repository.ts index 3fe182c..4e6bbbf 100644 --- a/apps/api/src/modules/crud/repositories/mongoose/crud.mongoose-repository.ts +++ b/apps/api/src/modules/crud/repositories/mongoose/crud.mongoose-repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { InjectModel, MongooseModule } from '@nestjs/mongoose'; +import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; import { InjectTransactionHost, @@ -9,6 +9,8 @@ import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter- import { MongooseBaseRepository } from '../../../../repositories/mongoose/mongoose.base-repository'; import { CrudMongooseEntity } from './crud.mongoose-entity'; import { Crud } from '../../schemas/crud.schema'; +import { IMongooseRepository } from '../../../../repositories/mongoose/mongoose.repository.interface'; +import { AppConstants } from '../../../../constants/app.constants'; @Injectable() export class CrudMongooseRepository extends MongooseBaseRepository< @@ -18,7 +20,7 @@ export class CrudMongooseRepository extends MongooseBaseRepository< constructor( @InjectModel(CrudMongooseEntity.name) crudModel: Model, - @InjectTransactionHost(MongooseModule.name) + @InjectTransactionHost(AppConstants.DB_CONNECTIONS.MONGOOSE) mongoTxHost: TransactionHost, ) { super(crudModel, mongoTxHost); @@ -33,3 +35,8 @@ export class CrudMongooseRepository extends MongooseBaseRepository< }; } } + +export type ICrudMongooseRepository = IMongooseRepository< + Crud, + CrudMongooseEntity +>; diff --git a/apps/api/src/modules/crud/repositories/prisma/crud.prisma-repository.ts b/apps/api/src/modules/crud/repositories/prisma/crud.prisma-repository.ts index 8ae4b50..6c4d04b 100644 --- a/apps/api/src/modules/crud/repositories/prisma/crud.prisma-repository.ts +++ b/apps/api/src/modules/crud/repositories/prisma/crud.prisma-repository.ts @@ -1,12 +1,17 @@ import { Injectable } from '@nestjs/common'; -import { TransactionHost } from '@nestjs-cls/transactional'; +import { + InjectTransactionHost, + TransactionHost, +} from '@nestjs-cls/transactional'; import { Prisma } from '@repo/prisma-db'; import { ICrudPrismaRepository } from './crud.prisma-repository.interface'; import { PrismaTransactionAdapter } from '../../../prisma/prisma.module'; +import { AppConstants } from '../../../../constants/app.constants'; @Injectable() export class CrudPrismaRepository implements ICrudPrismaRepository { constructor( + @InjectTransactionHost(AppConstants.DB_CONNECTIONS.PRISMA) protected readonly prismaTxHost: TransactionHost, ) {} @@ -78,3 +83,5 @@ export class CrudPrismaRepository implements ICrudPrismaRepository { return this.delegate.aggregate(args); } } + +export type { ICrudPrismaRepository }; diff --git a/apps/api/src/modules/crud/services/crud.mongoose.service.ts b/apps/api/src/modules/crud/services/crud.mongoose.service.ts new file mode 100644 index 0000000..afeae8e --- /dev/null +++ b/apps/api/src/modules/crud/services/crud.mongoose.service.ts @@ -0,0 +1,50 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { Crud } from '../schemas/crud.schema'; +import { NoTransaction } from '../../../decorators/method/no-transaction.decorator'; +import { Transactional } from '../../../decorators/class/transactional.decorator'; +import { AppConstants } from '../../../constants/app.constants'; +import { ICrudMongooseRepository } from '../repositories/mongoose/crud.mongoose-repository'; + +@Injectable() +@Transactional(AppConstants.DB_CONNECTIONS.MONGOOSE) +export class CrudMongooseService { + constructor( + @Inject(AppConstants.REPOSITORIES.CRUD_MONGOOSE) + private readonly crudRepository: ICrudMongooseRepository, + ) {} + + async createCrud(data: Partial): Promise { + const created = await this.crudRepository.create({ + content: data.content!, + }); + console.log('[Mongoose] Created:', created); + return created; + } + + @NoTransaction() + async findAll(): Promise { + return this.crudRepository.find(); + } + + @NoTransaction() + async findOne(id: string): Promise { + const crud = await this.crudRepository.findById(id); + if (!crud) throw new NotFoundException(`Crud with id ${id} not found`); + return crud; + } + + async update(id: string, data: Partial): Promise { + const updated = await this.crudRepository.findByIdAndUpdate(id, { + $set: { content: data.content }, + }); + if (!updated) throw new NotFoundException(`Crud with id ${id} not found`); + return updated; + } + + async delete(id: string): Promise { + const deleted = await this.crudRepository.findByIdAndDelete(id); + if (!deleted) throw new NotFoundException(`Crud with id ${id} not found`); + console.log('[Mongoose] Deleted:', deleted); + return deleted; + } +} diff --git a/apps/api/src/modules/crud/services/crud.prisma.service.ts b/apps/api/src/modules/crud/services/crud.prisma.service.ts new file mode 100644 index 0000000..f85e770 --- /dev/null +++ b/apps/api/src/modules/crud/services/crud.prisma.service.ts @@ -0,0 +1,83 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { Crud } from '../schemas/crud.schema'; +import { NoTransaction } from '../../../decorators/method/no-transaction.decorator'; +import { Transactional } from '../../../decorators/class/transactional.decorator'; +import { AppConstants } from '../../../constants/app.constants'; +import { ICrudPrismaRepository } from '../repositories/prisma/crud.prisma-repository'; + +@Injectable() +@Transactional(AppConstants.DB_CONNECTIONS.PRISMA) +export class CrudPrismaService { + constructor( + @Inject(AppConstants.REPOSITORIES.CRUD_PRISMA) + private readonly crudRepository: ICrudPrismaRepository, + ) {} + + async createCrud(data: Partial): Promise { + const created = await this.crudRepository.create({ + data: { + content: data.content!, + }, + }); + console.log('[Prisma] Created:', created); + return { + id: created.id, + content: created.content, + createdAt: created.createdAt, + updatedAt: created.updatedAt, + }; + } + + @NoTransaction() + async findAll(): Promise { + const results = await this.crudRepository.findMany(); + return results.map((item) => ({ + id: item.id, + content: item.content, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + })); + } + + @NoTransaction() + async findOne(id: string): Promise { + const crud = await this.crudRepository.findUnique({ + where: { id }, + }); + if (!crud) throw new NotFoundException(`Crud with id ${id} not found`); + return { + id: crud.id, + content: crud.content, + createdAt: crud.createdAt, + updatedAt: crud.updatedAt, + }; + } + + async update(id: string, data: Partial): Promise { + const updated = await this.crudRepository.update({ + where: { id }, + data: { content: data.content }, + }); + if (!updated) throw new NotFoundException(`Crud with id ${id} not found`); + return { + id: updated.id, + content: updated.content, + createdAt: updated.createdAt, + updatedAt: updated.updatedAt, + }; + } + + async delete(id: string): Promise { + const deleted = await this.crudRepository.delete({ + where: { id }, + }); + if (!deleted) throw new NotFoundException(`Crud with id ${id} not found`); + console.log('[Prisma] Deleted:', deleted); + return { + id: deleted.id, + content: deleted.content, + createdAt: deleted.createdAt, + updatedAt: deleted.updatedAt, + }; + } +} diff --git a/apps/web/app/crud-demo/page.tsx b/apps/web/app/crud-demo/page.tsx index a66a59f..75108b6 100644 --- a/apps/web/app/crud-demo/page.tsx +++ b/apps/web/app/crud-demo/page.tsx @@ -4,14 +4,31 @@ import { useState } from "react"; import { trpc } from "@repo/trpc/client"; import Link from "next/link"; -export default function CrudDemo() { +type DbType = "mongoose" | "prisma"; + +interface CrudItem { + id: string; + content: string; +} + +function CrudPanel({ dbType }: { dbType: DbType }) { const utils = trpc.useUtils(); const [content, setContent] = useState(""); const [editingId, setEditingId] = useState(null); const [editingContent, setEditingContent] = useState(""); - // Queries - const crudList = trpc.crud.findAll.useQuery( + const isMongoose = dbType === "mongoose"; + const label = isMongoose ? "Mongoose (MongoDB)" : "Prisma (PostgreSQL)"; + const gradientColors = isMongoose + ? "from-green-400 to-emerald-400" + : "from-blue-400 to-cyan-400"; + const buttonColors = isMongoose + ? "from-green-500 to-green-600 hover:from-green-600 hover:to-green-700" + : "from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700"; + const hoverColor = isMongoose ? "hover:text-green-400" : "hover:text-blue-400"; + + // Queries - dynamically choose endpoint + const crudList = trpc.crud[isMongoose ? "findAllMongo" : "findAllPrisma"].useQuery( {}, { refetchOnWindowFocus: false, @@ -19,20 +36,20 @@ export default function CrudDemo() { ); // Mutations - const createCrud = trpc.crud.createCrud.useMutation({ + const createCrud = trpc.crud[isMongoose ? "createCrudMongo" : "createCrudPrisma"].useMutation({ onSuccess: () => { - void utils.crud.findAll.invalidate(); + void utils.crud[isMongoose ? "findAllMongo" : "findAllPrisma"].invalidate(); setContent(""); }, }); - const deleteCrud = trpc.crud.deleteCrud.useMutation({ - onSuccess: () => utils.crud.findAll.invalidate(), + const deleteCrud = trpc.crud[isMongoose ? "deleteCrudMongo" : "deleteCrudPrisma"].useMutation({ + onSuccess: () => utils.crud[isMongoose ? "findAllMongo" : "findAllPrisma"].invalidate(), }); - const updateCrud = trpc.crud.updateCrud?.useMutation({ + const updateCrud = trpc.crud[isMongoose ? "updateCrudMongo" : "updateCrudPrisma"].useMutation({ onSuccess: () => { - void utils.crud.findAll.invalidate(); + void utils.crud[isMongoose ? "findAllMongo" : "findAllPrisma"].invalidate(); setEditingId(null); setEditingContent(""); }, @@ -45,7 +62,7 @@ export default function CrudDemo() { const handleUpdate = (id: string) => { if (!editingContent.trim()) return; - updateCrud?.mutate({ id, data: { content: editingContent } }); + updateCrud.mutate({ id, data: { content: editingContent } }); }; const handleDelete = (id: string) => { @@ -53,7 +70,7 @@ export default function CrudDemo() { }; const handleRefresh = () => { - void utils.crud.findAll.invalidate(); + void utils.crud[isMongoose ? "findAllMongo" : "findAllPrisma"].invalidate(); }; const renderListContent = () => { @@ -75,60 +92,62 @@ export default function CrudDemo() {

    - {crudList.data.cruds.map( - (item: { id: string; content: string }) => ( -
  • - {editingId === item.id ? ( - setEditingContent(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && editingContent.trim()) { - handleUpdate(item.id); - } else if (e.key === "Escape") { - setEditingId(null); - } - }} - autoFocus - className="flex-1 bg-slate-600 border border-blue-400 rounded px-2 py-1 text-white focus:outline-none focus:ring-2 focus:ring-blue-400" - /> - ) : ( - - )} + {crudList.data.cruds.map((item: CrudItem) => ( +
  • + {editingId === item.id ? ( + setEditingContent(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && editingContent.trim()) { + handleUpdate(item.id); + } else if (e.key === "Escape") { + setEditingId(null); + } + }} + autoFocus + className={`flex-1 bg-slate-600 border rounded px-2 py-1 text-white focus:outline-none focus:ring-2 ${ + isMongoose + ? "border-green-400 focus:ring-green-400" + : "border-blue-400 focus:ring-blue-400" + }`} + /> + ) : ( -
  • - ), - )} + )} + + + ))}
); @@ -143,9 +162,71 @@ export default function CrudDemo() { ); }; + return ( +
+
+
+
+ + {isMongoose ? "M" : "P"} + +
+

+ {label} +

+
+
+ +
+
+ setContent(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleCreate()} + disabled={createCrud.isPending} + style={{ + focusRing: isMongoose + ? "focus:ring-green-400" + : "focus:ring-blue-400", + }} + /> + +
+
+ +
+ +
+ +
+ {renderListContent()} +
+
+ ); +} + +export default function CrudDemo() { return (
-
+
-
-
- -
-

- BE Tech Stack CRUD -

-
-

- NextJs (tailwindcss), NestJs, Expo, Trpc +

+ Dual Database CRUD Demo +

+

+ Side-by-side comparison of Mongoose (MongoDB) and Prisma (PostgreSQL) +

+

+ NextJs (TailwindCSS) • NestJs • TRPC • Transactions

-
-
- setContent(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleCreate()} - disabled={createCrud.isPending} - /> - -
-
- -
- -
- -
- {renderListContent()} +
+ +
diff --git a/packages/trpc/src/server/server.ts b/packages/trpc/src/server/server.ts index 3ba2522..77d4966 100644 --- a/packages/trpc/src/server/server.ts +++ b/packages/trpc/src/server/server.ts @@ -6,7 +6,7 @@ const publicProcedure = t.procedure; const appRouter = t.router({ crud: t.router({ - createCrud: publicProcedure.input(z.object({ + createCrudMongo: publicProcedure.input(z.object({ requestId: z.string().uuid().optional(), timestamp: z.number().optional(), }).merge(z.object({ @@ -23,7 +23,7 @@ const appRouter = t.router({ }).extend({ id: z.string(), })).mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), - findAll: publicProcedure.input(z.object({ + findAllMongo: publicProcedure.input(z.object({ requestId: z.string().uuid().optional(), timestamp: z.number().optional(), }).extend({ @@ -44,7 +44,7 @@ const appRouter = t.router({ limit: z.number().int().positive(), offset: z.number().int().nonnegative(), })).query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), - findOneCrud: publicProcedure.input(z.object({ + findOneCrudMongo: publicProcedure.input(z.object({ requestId: z.string().uuid().optional(), timestamp: z.number().optional(), }).extend({ @@ -56,7 +56,7 @@ const appRouter = t.router({ }).extend({ content: z.string().min(1).max(1000), }).nullable()).query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), - updateCrud: publicProcedure.input(z.object({ + updateCrudMongo: publicProcedure.input(z.object({ requestId: z.string().uuid().optional(), timestamp: z.number().optional(), }).extend({ @@ -84,7 +84,94 @@ const appRouter = t.router({ content: z.string().min(1).max(1000), }).optional(), })).mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), - deleteCrud: publicProcedure.input(z.object({ + deleteCrudMongo: publicProcedure.input(z.object({ + requestId: z.string().uuid().optional(), + timestamp: z.number().optional(), + }).extend({ + id: z.string(), + })).output(z.object({ + success: z.boolean(), + message: z.string().optional(), + })).mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + createCrudPrisma: publicProcedure.input(z.object({ + requestId: z.string().uuid().optional(), + timestamp: z.number().optional(), + }).merge(z.object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + }).extend({ + content: z.string().min(1).max(1000), + }).pick({ + content: true, + }))).output(z.object({ + success: z.boolean(), + message: z.string().optional(), + }).extend({ + id: z.string(), + })).mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + findAllPrisma: publicProcedure.input(z.object({ + requestId: z.string().uuid().optional(), + timestamp: z.number().optional(), + }).extend({ + limit: z.number().int().positive().max(100).default(10).optional(), + offset: z.number().int().nonnegative().default(0).optional(), + })).output(z.object({ + success: z.boolean(), + message: z.string().optional(), + }).extend({ + cruds: z.array(z.object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + }).extend({ + content: z.string().min(1).max(1000), + })), + total: z.number().int().nonnegative(), + limit: z.number().int().positive(), + offset: z.number().int().nonnegative(), + })).query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + findOneCrudPrisma: publicProcedure.input(z.object({ + requestId: z.string().uuid().optional(), + timestamp: z.number().optional(), + }).extend({ + id: z.string(), + })).output(z.object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + }).extend({ + content: z.string().min(1).max(1000), + }).nullable()).query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + updateCrudPrisma: publicProcedure.input(z.object({ + requestId: z.string().uuid().optional(), + timestamp: z.number().optional(), + }).extend({ + id: z.string(), + data: z.object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + }).extend({ + content: z.string().min(1).max(1000), + }).pick({ + content: true, + }).refine((data) => Object.keys(data).length > 0, { + message: 'At least one field must be provided for update', + }), + })).output(z.object({ + success: z.boolean(), + message: z.string().optional(), + }).extend({ + data: z.object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + }).extend({ + content: z.string().min(1).max(1000), + }).optional(), + })).mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + deleteCrudPrisma: publicProcedure.input(z.object({ requestId: z.string().uuid().optional(), timestamp: z.number().optional(), }).extend({ From c3dba071129e768686c5aa2972090027b873e212 Mon Sep 17 00:00:00 2001 From: Anns Shahbaz Date: Fri, 12 Dec 2025 12:05:34 +0500 Subject: [PATCH 18/37] ok --- apps/api/package.json | 4 ++-- .../mongoose/repositories}/generate-repository.ts | 7 ++++--- .../prisma/repositories}/generate-repository.ts | 6 ++++-- 3 files changed, 10 insertions(+), 7 deletions(-) rename apps/api/scripts/{mongoose => code-generation/mongoose/repositories}/generate-repository.ts (94%) rename apps/api/scripts/{prisma => code-generation/prisma/repositories}/generate-repository.ts (96%) diff --git a/apps/api/package.json b/apps/api/package.json index f3e4fb8..bd442ba 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -19,8 +19,8 @@ "db:seed:mongo": "dotenvx run -- ts-node -r tsconfig-paths/register scripts/seed/seed-mongoose.ts", "db:seed:prisma": "cd ../../packages/prisma-db && pnpm run db:seed", "db:seed:all": "pnpm run db:seed:prisma && pnpm run db:seed:mongo", - "generate:repo:prisma": "ts-node -r tsconfig-paths/register scripts/prisma/generate-repository.ts", - "generate:repo:mongo": "ts-node -r tsconfig-paths/register scripts/mongoose/generate-repository.ts" + "generate:repo:prisma": "ts-node -r tsconfig-paths/register scripts/code-generation/prisma/repositories/generate-repository.ts", + "generate:repo:mongo": "ts-node -r tsconfig-paths/register scripts/code-generation/mongoose/repositories/generate-repository.ts" }, "dependencies": { "@andeanwide/nestjs-rollbar": "^1.0.0", diff --git a/apps/api/scripts/mongoose/generate-repository.ts b/apps/api/scripts/code-generation/mongoose/repositories/generate-repository.ts similarity index 94% rename from apps/api/scripts/mongoose/generate-repository.ts rename to apps/api/scripts/code-generation/mongoose/repositories/generate-repository.ts index 7819a93..c2aa2f8 100644 --- a/apps/api/scripts/mongoose/generate-repository.ts +++ b/apps/api/scripts/code-generation/mongoose/repositories/generate-repository.ts @@ -16,7 +16,7 @@ export class RepositoryGenerator { this.entityName.charAt(0).toUpperCase() + this.entityName.slice(1); this.outputDir = path.join( __dirname, - '../../src/modules', + '../../../../src/modules', this.entityName, 'repositories/mongoose', ); @@ -66,7 +66,7 @@ export const ${this.entityNameCapitalized}MongooseSchema = SchemaFactory.createF private getRepositoryTemplate(): string { return `import { Injectable } from '@nestjs/common'; -import { InjectModel, MongooseModule } from '@nestjs/mongoose'; +import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; import { InjectTransactionHost, @@ -77,6 +77,7 @@ import { MongooseBaseRepository } from '../../../../repositories/mongoose/mongoo import { IMongooseRepository } from '../../../../repositories/mongoose/mongoose.repository.interface'; import { ${this.entityNameCapitalized}MongooseEntity } from './${this.entityName}.mongoose-entity'; import { ${this.entityNameCapitalized} } from '../../schemas/${this.entityName}.schema'; +import { AppConstants } from '../../../../constants/app.constants'; @Injectable() export class ${this.entityNameCapitalized}MongooseRepository extends MongooseBaseRepository< @@ -86,7 +87,7 @@ export class ${this.entityNameCapitalized}MongooseRepository extends MongooseBas constructor( @InjectModel(${this.entityNameCapitalized}MongooseEntity.name) ${this.entityName}Model: Model<${this.entityNameCapitalized}MongooseEntity>, - @InjectTransactionHost(MongooseModule.name) + @InjectTransactionHost(AppConstants.DB_CONNECTIONS.MONGOOSE) mongoTxHost: TransactionHost, ) { super(${this.entityName}Model, mongoTxHost); diff --git a/apps/api/scripts/prisma/generate-repository.ts b/apps/api/scripts/code-generation/prisma/repositories/generate-repository.ts similarity index 96% rename from apps/api/scripts/prisma/generate-repository.ts rename to apps/api/scripts/code-generation/prisma/repositories/generate-repository.ts index fdc20fb..838b03a 100644 --- a/apps/api/scripts/prisma/generate-repository.ts +++ b/apps/api/scripts/code-generation/prisma/repositories/generate-repository.ts @@ -16,7 +16,7 @@ export class RepositoryGenerator { this.entityName.charAt(0).toUpperCase() + this.entityName.slice(1); this.outputDir = path.join( __dirname, - '../../src/modules', + '../../../../src/modules', this.entityName, 'repositories/prisma', ); @@ -95,14 +95,16 @@ export interface I${this.entityNameCapitalized}PrismaRepository { private getRepositoryTemplate(): string { return `import { Injectable } from '@nestjs/common'; -import { TransactionHost } from '@nestjs-cls/transactional'; +import { TransactionHost, InjectTransactionHost } from '@nestjs-cls/transactional'; import { Prisma } from '@repo/prisma-db'; import { I${this.entityNameCapitalized}PrismaRepository } from './${this.entityName}.prisma-repository.interface'; import { PrismaTransactionAdapter } from '../../../prisma/prisma.module'; +import { AppConstants } from '../../../../constants/app.constants'; @Injectable() export class ${this.entityNameCapitalized}PrismaRepository implements I${this.entityNameCapitalized}PrismaRepository { constructor( + @InjectTransactionHost(AppConstants.DB_CONNECTIONS.PRISMA) protected readonly prismaTxHost: TransactionHost, ) {} From 1f869ecd1e86e8c64eeff8c515a32222ca8fda1e Mon Sep 17 00:00:00 2001 From: Anns Shahbaz Date: Fri, 12 Dec 2025 12:11:55 +0500 Subject: [PATCH 19/37] ok --- .../prisma/repositories/generate-repository.ts | 5 ++++- .../crud/repositories/prisma/crud.prisma-repository.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/api/scripts/code-generation/prisma/repositories/generate-repository.ts b/apps/api/scripts/code-generation/prisma/repositories/generate-repository.ts index 838b03a..6dc4df7 100644 --- a/apps/api/scripts/code-generation/prisma/repositories/generate-repository.ts +++ b/apps/api/scripts/code-generation/prisma/repositories/generate-repository.ts @@ -95,7 +95,10 @@ export interface I${this.entityNameCapitalized}PrismaRepository { private getRepositoryTemplate(): string { return `import { Injectable } from '@nestjs/common'; -import { TransactionHost, InjectTransactionHost } from '@nestjs-cls/transactional'; +import { + TransactionHost, + InjectTransactionHost, +} from '@nestjs-cls/transactional'; import { Prisma } from '@repo/prisma-db'; import { I${this.entityNameCapitalized}PrismaRepository } from './${this.entityName}.prisma-repository.interface'; import { PrismaTransactionAdapter } from '../../../prisma/prisma.module'; diff --git a/apps/api/src/modules/crud/repositories/prisma/crud.prisma-repository.ts b/apps/api/src/modules/crud/repositories/prisma/crud.prisma-repository.ts index 6c4d04b..792f606 100644 --- a/apps/api/src/modules/crud/repositories/prisma/crud.prisma-repository.ts +++ b/apps/api/src/modules/crud/repositories/prisma/crud.prisma-repository.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { - InjectTransactionHost, TransactionHost, + InjectTransactionHost, } from '@nestjs-cls/transactional'; import { Prisma } from '@repo/prisma-db'; import { ICrudPrismaRepository } from './crud.prisma-repository.interface'; From ee516a876ac5417ffba3bdbe2f73db7115addc67 Mon Sep 17 00:00:00 2001 From: Anns Shahbaz Date: Fri, 12 Dec 2025 12:16:46 +0500 Subject: [PATCH 20/37] gg --- .../repositories/generate-repository.ts | 69 ++------- .../templates/entity.template.txt | 7 + .../templates/repository.template.txt | 42 +++++ .../repositories/generate-repository.ts | 143 ++---------------- .../templates/interface.template.txt | 36 +++++ .../templates/repository.template.txt | 87 +++++++++++ 6 files changed, 200 insertions(+), 184 deletions(-) create mode 100644 apps/api/scripts/code-generation/mongoose/repositories/templates/entity.template.txt create mode 100644 apps/api/scripts/code-generation/mongoose/repositories/templates/repository.template.txt create mode 100644 apps/api/scripts/code-generation/prisma/repositories/templates/interface.template.txt create mode 100644 apps/api/scripts/code-generation/prisma/repositories/templates/repository.template.txt diff --git a/apps/api/scripts/code-generation/mongoose/repositories/generate-repository.ts b/apps/api/scripts/code-generation/mongoose/repositories/generate-repository.ts index c2aa2f8..0e22929 100644 --- a/apps/api/scripts/code-generation/mongoose/repositories/generate-repository.ts +++ b/apps/api/scripts/code-generation/mongoose/repositories/generate-repository.ts @@ -9,6 +9,7 @@ export class RepositoryGenerator { private readonly entityName: string; private readonly entityNameCapitalized: string; private readonly outputDir: string; + private readonly templatesDir: string; constructor(config: GeneratorConfig) { this.entityName = config.entityName.toLowerCase(); @@ -20,6 +21,7 @@ export class RepositoryGenerator { this.entityName, 'repositories/mongoose', ); + this.templatesDir = path.join(__dirname, 'templates'); } generate(): void { @@ -36,7 +38,7 @@ export class RepositoryGenerator { } private generateEntity(): void { - const content = this.getEntityTemplate(); + const content = this.loadTemplate('entity.template.txt'); const filePath = path.join( this.outputDir, `${this.entityName}.mongoose-entity.ts`, @@ -45,7 +47,7 @@ export class RepositoryGenerator { } private generateRepository(): void { - const content = this.getRepositoryTemplate(); + const content = this.loadTemplate('repository.template.txt'); const filePath = path.join( this.outputDir, `${this.entityName}.mongoose-repository.ts`, @@ -53,61 +55,18 @@ export class RepositoryGenerator { fs.writeFileSync(filePath, content, 'utf-8'); } - private getEntityTemplate(): string { - return `import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { MongooseBaseEntity } from '../../../../repositories/mongoose/mongoose.base-entity'; + private loadTemplate(templateName: string): string { + const templatePath = path.join(this.templatesDir, templateName); + let template = fs.readFileSync(templatePath, 'utf-8'); -@Schema({ collection: '${this.entityName}', timestamps: true }) -export class ${this.entityNameCapitalized}MongooseEntity extends MongooseBaseEntity {} - -export const ${this.entityNameCapitalized}MongooseSchema = SchemaFactory.createForClass(${this.entityNameCapitalized}MongooseEntity); -`; - } - - private getRepositoryTemplate(): string { - return `import { Injectable } from '@nestjs/common'; -import { InjectModel } from '@nestjs/mongoose'; -import { Model } from 'mongoose'; -import { - InjectTransactionHost, - TransactionHost, -} from '@nestjs-cls/transactional'; -import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter-mongoose'; -import { MongooseBaseRepository } from '../../../../repositories/mongoose/mongoose.base-repository'; -import { IMongooseRepository } from '../../../../repositories/mongoose/mongoose.repository.interface'; -import { ${this.entityNameCapitalized}MongooseEntity } from './${this.entityName}.mongoose-entity'; -import { ${this.entityNameCapitalized} } from '../../schemas/${this.entityName}.schema'; -import { AppConstants } from '../../../../constants/app.constants'; - -@Injectable() -export class ${this.entityNameCapitalized}MongooseRepository extends MongooseBaseRepository< - ${this.entityNameCapitalized}, - ${this.entityNameCapitalized}MongooseEntity -> { - constructor( - @InjectModel(${this.entityNameCapitalized}MongooseEntity.name) - ${this.entityName}Model: Model<${this.entityNameCapitalized}MongooseEntity>, - @InjectTransactionHost(AppConstants.DB_CONNECTIONS.MONGOOSE) - mongoTxHost: TransactionHost, - ) { - super(${this.entityName}Model, mongoTxHost); - } - - protected toDomainEntity(dbEntity: ${this.entityNameCapitalized}MongooseEntity): ${this.entityNameCapitalized} { - throw new Error('Method not implemented.'); - - // Complete Conversion Below - // return { - // id: dbEntity._id?.toString() ?? '', - // }; - } -} + // Replace placeholders + template = template.replace( + /{{ENTITY_NAME_CAPITALIZED}}/g, + this.entityNameCapitalized, + ); + template = template.replace(/{{ENTITY_NAME_LOWER}}/g, this.entityName); -export type I${this.entityNameCapitalized}MongooseRepository = IMongooseRepository< - ${this.entityNameCapitalized}, - ${this.entityNameCapitalized}MongooseEntity ->; -`; + return template; } } diff --git a/apps/api/scripts/code-generation/mongoose/repositories/templates/entity.template.txt b/apps/api/scripts/code-generation/mongoose/repositories/templates/entity.template.txt new file mode 100644 index 0000000..8722fa9 --- /dev/null +++ b/apps/api/scripts/code-generation/mongoose/repositories/templates/entity.template.txt @@ -0,0 +1,7 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { MongooseBaseEntity } from '../../../../repositories/mongoose/mongoose.base-entity'; + +@Schema({ collection: '{{ENTITY_NAME_LOWER}}', timestamps: true }) +export class {{ENTITY_NAME_CAPITALIZED}}MongooseEntity extends MongooseBaseEntity {} + +export const {{ENTITY_NAME_CAPITALIZED}}MongooseSchema = SchemaFactory.createForClass({{ENTITY_NAME_CAPITALIZED}}MongooseEntity); diff --git a/apps/api/scripts/code-generation/mongoose/repositories/templates/repository.template.txt b/apps/api/scripts/code-generation/mongoose/repositories/templates/repository.template.txt new file mode 100644 index 0000000..16f6220 --- /dev/null +++ b/apps/api/scripts/code-generation/mongoose/repositories/templates/repository.template.txt @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { + InjectTransactionHost, + TransactionHost, +} from '@nestjs-cls/transactional'; +import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter-mongoose'; +import { MongooseBaseRepository } from '../../../../repositories/mongoose/mongoose.base-repository'; +import { IMongooseRepository } from '../../../../repositories/mongoose/mongoose.repository.interface'; +import { {{ENTITY_NAME_CAPITALIZED}}MongooseEntity } from './{{ENTITY_NAME_LOWER}}.mongoose-entity'; +import { {{ENTITY_NAME_CAPITALIZED}} } from '../../schemas/{{ENTITY_NAME_LOWER}}.schema'; +import { AppConstants } from '../../../../constants/app.constants'; + +@Injectable() +export class {{ENTITY_NAME_CAPITALIZED}}MongooseRepository extends MongooseBaseRepository< + {{ENTITY_NAME_CAPITALIZED}}, + {{ENTITY_NAME_CAPITALIZED}}MongooseEntity +> { + constructor( + @InjectModel({{ENTITY_NAME_CAPITALIZED}}MongooseEntity.name) + {{ENTITY_NAME_LOWER}}Model: Model<{{ENTITY_NAME_CAPITALIZED}}MongooseEntity>, + @InjectTransactionHost(AppConstants.DB_CONNECTIONS.MONGOOSE) + mongoTxHost: TransactionHost, + ) { + super({{ENTITY_NAME_LOWER}}Model, mongoTxHost); + } + + protected toDomainEntity(dbEntity: {{ENTITY_NAME_CAPITALIZED}}MongooseEntity): {{ENTITY_NAME_CAPITALIZED}} { + throw new Error('Method not implemented.'); + + // Complete Conversion Below + // return { + // id: dbEntity._id?.toString() ?? '', + // }; + } +} + +export type I{{ENTITY_NAME_CAPITALIZED}}MongooseRepository = IMongooseRepository< + {{ENTITY_NAME_CAPITALIZED}}, + {{ENTITY_NAME_CAPITALIZED}}MongooseEntity +>; diff --git a/apps/api/scripts/code-generation/prisma/repositories/generate-repository.ts b/apps/api/scripts/code-generation/prisma/repositories/generate-repository.ts index 6dc4df7..03de629 100644 --- a/apps/api/scripts/code-generation/prisma/repositories/generate-repository.ts +++ b/apps/api/scripts/code-generation/prisma/repositories/generate-repository.ts @@ -9,6 +9,7 @@ export class RepositoryGenerator { private readonly entityName: string; private readonly entityNameCapitalized: string; private readonly outputDir: string; + private readonly templatesDir: string; constructor(config: GeneratorConfig) { this.entityName = config.entityName.toLowerCase(); @@ -20,6 +21,7 @@ export class RepositoryGenerator { this.entityName, 'repositories/prisma', ); + this.templatesDir = path.join(__dirname, 'templates'); } generate(): void { @@ -36,7 +38,7 @@ export class RepositoryGenerator { } private generateInterface(): void { - const content = this.getInterfaceTemplate(); + const content = this.loadTemplate('interface.template.txt'); const filePath = path.join( this.outputDir, `${this.entityName}.prisma-repository.interface.ts`, @@ -45,7 +47,7 @@ export class RepositoryGenerator { } private generateRepository(): void { - const content = this.getRepositoryTemplate(); + const content = this.loadTemplate('repository.template.txt'); const filePath = path.join( this.outputDir, `${this.entityName}.prisma-repository.ts`, @@ -53,135 +55,18 @@ export class RepositoryGenerator { fs.writeFileSync(filePath, content, 'utf-8'); } - private getInterfaceTemplate(): string { - return `import { Prisma } from '@repo/prisma-db'; + private loadTemplate(templateName: string): string { + const templatePath = path.join(this.templatesDir, templateName); + let template = fs.readFileSync(templatePath, 'utf-8'); -export interface I${this.entityNameCapitalized}PrismaRepository { - create( - args: Prisma.${this.entityNameCapitalized}CreateArgs, - ): Promise>; - createMany(args: Prisma.${this.entityNameCapitalized}CreateManyArgs): Promise; - - findFirst( - args?: Prisma.${this.entityNameCapitalized}FindFirstArgs, - ): Promise | null>; - findUnique( - args: Prisma.${this.entityNameCapitalized}FindUniqueArgs, - ): Promise | null>; - findMany( - args?: Prisma.${this.entityNameCapitalized}FindManyArgs, - ): Promise[]>; - - update( - args: Prisma.${this.entityNameCapitalized}UpdateArgs, - ): Promise>; - updateMany(args: Prisma.${this.entityNameCapitalized}UpdateManyArgs): Promise; - upsert( - args: Prisma.${this.entityNameCapitalized}UpsertArgs, - ): Promise>; - - delete( - args: Prisma.${this.entityNameCapitalized}DeleteArgs, - ): Promise>; - deleteMany(args?: Prisma.${this.entityNameCapitalized}DeleteManyArgs): Promise; - - count(args?: Prisma.${this.entityNameCapitalized}CountArgs): Promise; - aggregate( - args: Prisma.${this.entityNameCapitalized}AggregateArgs, - ): Promise>; -} -`; - } - - private getRepositoryTemplate(): string { - return `import { Injectable } from '@nestjs/common'; -import { - TransactionHost, - InjectTransactionHost, -} from '@nestjs-cls/transactional'; -import { Prisma } from '@repo/prisma-db'; -import { I${this.entityNameCapitalized}PrismaRepository } from './${this.entityName}.prisma-repository.interface'; -import { PrismaTransactionAdapter } from '../../../prisma/prisma.module'; -import { AppConstants } from '../../../../constants/app.constants'; - -@Injectable() -export class ${this.entityNameCapitalized}PrismaRepository implements I${this.entityNameCapitalized}PrismaRepository { - constructor( - @InjectTransactionHost(AppConstants.DB_CONNECTIONS.PRISMA) - protected readonly prismaTxHost: TransactionHost, - ) {} - - protected get delegate(): Prisma.${this.entityNameCapitalized}Delegate { - return this.prismaTxHost.tx.${this.entityName}; - } - - create( - args: Prisma.${this.entityNameCapitalized}CreateArgs, - ): Promise> { - return this.delegate.create(args); - } - - createMany(args: Prisma.${this.entityNameCapitalized}CreateManyArgs): Promise { - return this.delegate.createMany(args); - } - - findFirst( - args?: Prisma.${this.entityNameCapitalized}FindFirstArgs, - ): Promise | null> { - return this.delegate.findFirst(args); - } - - findUnique( - args: Prisma.${this.entityNameCapitalized}FindUniqueArgs, - ): Promise | null> { - return this.delegate.findUnique(args); - } - - findMany( - args?: Prisma.${this.entityNameCapitalized}FindManyArgs, - ): Promise[]> { - return this.delegate.findMany(args); - } - - update( - args: Prisma.${this.entityNameCapitalized}UpdateArgs, - ): Promise> { - return this.delegate.update(args); - } - - updateMany(args: Prisma.${this.entityNameCapitalized}UpdateManyArgs): Promise { - return this.delegate.updateMany(args); - } - - upsert( - args: Prisma.${this.entityNameCapitalized}UpsertArgs, - ): Promise> { - return this.delegate.upsert(args); - } - - delete( - args: Prisma.${this.entityNameCapitalized}DeleteArgs, - ): Promise> { - return this.delegate.delete(args); - } - - deleteMany(args?: Prisma.${this.entityNameCapitalized}DeleteManyArgs): Promise { - return this.delegate.deleteMany(args); - } - - count(args?: Prisma.${this.entityNameCapitalized}CountArgs): Promise { - return this.delegate.count(args); - } - - aggregate( - args: Prisma.${this.entityNameCapitalized}AggregateArgs, - ): Promise> { - return this.delegate.aggregate(args); - } -} + // Replace placeholders + template = template.replace( + /{{ENTITY_NAME_CAPITALIZED}}/g, + this.entityNameCapitalized, + ); + template = template.replace(/{{ENTITY_NAME_LOWER}}/g, this.entityName); -export type { I${this.entityNameCapitalized}PrismaRepository }; -`; + return template; } } diff --git a/apps/api/scripts/code-generation/prisma/repositories/templates/interface.template.txt b/apps/api/scripts/code-generation/prisma/repositories/templates/interface.template.txt new file mode 100644 index 0000000..39c892b --- /dev/null +++ b/apps/api/scripts/code-generation/prisma/repositories/templates/interface.template.txt @@ -0,0 +1,36 @@ +import { Prisma } from '@repo/prisma-db'; + +export interface I{{ENTITY_NAME_CAPITALIZED}}PrismaRepository { + create( + args: Prisma.{{ENTITY_NAME_CAPITALIZED}}CreateArgs, + ): Promise>; + createMany(args: Prisma.{{ENTITY_NAME_CAPITALIZED}}CreateManyArgs): Promise; + + findFirst( + args?: Prisma.{{ENTITY_NAME_CAPITALIZED}}FindFirstArgs, + ): Promise | null>; + findUnique( + args: Prisma.{{ENTITY_NAME_CAPITALIZED}}FindUniqueArgs, + ): Promise | null>; + findMany( + args?: Prisma.{{ENTITY_NAME_CAPITALIZED}}FindManyArgs, + ): Promise[]>; + + update( + args: Prisma.{{ENTITY_NAME_CAPITALIZED}}UpdateArgs, + ): Promise>; + updateMany(args: Prisma.{{ENTITY_NAME_CAPITALIZED}}UpdateManyArgs): Promise; + upsert( + args: Prisma.{{ENTITY_NAME_CAPITALIZED}}UpsertArgs, + ): Promise>; + + delete( + args: Prisma.{{ENTITY_NAME_CAPITALIZED}}DeleteArgs, + ): Promise>; + deleteMany(args?: Prisma.{{ENTITY_NAME_CAPITALIZED}}DeleteManyArgs): Promise; + + count(args?: Prisma.{{ENTITY_NAME_CAPITALIZED}}CountArgs): Promise; + aggregate( + args: Prisma.{{ENTITY_NAME_CAPITALIZED}}AggregateArgs, + ): Promise>; +} diff --git a/apps/api/scripts/code-generation/prisma/repositories/templates/repository.template.txt b/apps/api/scripts/code-generation/prisma/repositories/templates/repository.template.txt new file mode 100644 index 0000000..7741f12 --- /dev/null +++ b/apps/api/scripts/code-generation/prisma/repositories/templates/repository.template.txt @@ -0,0 +1,87 @@ +import { Injectable } from '@nestjs/common'; +import { + TransactionHost, + InjectTransactionHost, +} from '@nestjs-cls/transactional'; +import { Prisma } from '@repo/prisma-db'; +import { I{{ENTITY_NAME_CAPITALIZED}}PrismaRepository } from './{{ENTITY_NAME_LOWER}}.prisma-repository.interface'; +import { PrismaTransactionAdapter } from '../../../prisma/prisma.module'; +import { AppConstants } from '../../../../constants/app.constants'; + +@Injectable() +export class {{ENTITY_NAME_CAPITALIZED}}PrismaRepository implements I{{ENTITY_NAME_CAPITALIZED}}PrismaRepository { + constructor( + @InjectTransactionHost(AppConstants.DB_CONNECTIONS.PRISMA) + protected readonly prismaTxHost: TransactionHost, + ) {} + + protected get delegate(): Prisma.{{ENTITY_NAME_CAPITALIZED}}Delegate { + return this.prismaTxHost.tx.{{ENTITY_NAME_LOWER}}; + } + + create( + args: Prisma.{{ENTITY_NAME_CAPITALIZED}}CreateArgs, + ): Promise> { + return this.delegate.create(args); + } + + createMany(args: Prisma.{{ENTITY_NAME_CAPITALIZED}}CreateManyArgs): Promise { + return this.delegate.createMany(args); + } + + findFirst( + args?: Prisma.{{ENTITY_NAME_CAPITALIZED}}FindFirstArgs, + ): Promise | null> { + return this.delegate.findFirst(args); + } + + findUnique( + args: Prisma.{{ENTITY_NAME_CAPITALIZED}}FindUniqueArgs, + ): Promise | null> { + return this.delegate.findUnique(args); + } + + findMany( + args?: Prisma.{{ENTITY_NAME_CAPITALIZED}}FindManyArgs, + ): Promise[]> { + return this.delegate.findMany(args); + } + + update( + args: Prisma.{{ENTITY_NAME_CAPITALIZED}}UpdateArgs, + ): Promise> { + return this.delegate.update(args); + } + + updateMany(args: Prisma.{{ENTITY_NAME_CAPITALIZED}}UpdateManyArgs): Promise { + return this.delegate.updateMany(args); + } + + upsert( + args: Prisma.{{ENTITY_NAME_CAPITALIZED}}UpsertArgs, + ): Promise> { + return this.delegate.upsert(args); + } + + delete( + args: Prisma.{{ENTITY_NAME_CAPITALIZED}}DeleteArgs, + ): Promise> { + return this.delegate.delete(args); + } + + deleteMany(args?: Prisma.{{ENTITY_NAME_CAPITALIZED}}DeleteManyArgs): Promise { + return this.delegate.deleteMany(args); + } + + count(args?: Prisma.{{ENTITY_NAME_CAPITALIZED}}CountArgs): Promise { + return this.delegate.count(args); + } + + aggregate( + args: Prisma.{{ENTITY_NAME_CAPITALIZED}}AggregateArgs, + ): Promise> { + return this.delegate.aggregate(args); + } +} + +export type { I{{ENTITY_NAME_CAPITALIZED}}PrismaRepository }; From ec62133e9a006813ee26b14025dbb992ffc21bc1 Mon Sep 17 00:00:00 2001 From: Anns Shahbaz Date: Fri, 12 Dec 2025 12:31:00 +0500 Subject: [PATCH 21/37] ok --- .../crud/services/crud.mongoose.service.ts | 20 ++++++-- .../crud/services/crud.prisma.service.ts | 47 +++++-------------- 2 files changed, 27 insertions(+), 40 deletions(-) diff --git a/apps/api/src/modules/crud/services/crud.mongoose.service.ts b/apps/api/src/modules/crud/services/crud.mongoose.service.ts index afeae8e..8513dfb 100644 --- a/apps/api/src/modules/crud/services/crud.mongoose.service.ts +++ b/apps/api/src/modules/crud/services/crud.mongoose.service.ts @@ -1,9 +1,15 @@ -import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { Crud } from '../schemas/crud.schema'; import { NoTransaction } from '../../../decorators/method/no-transaction.decorator'; import { Transactional } from '../../../decorators/class/transactional.decorator'; import { AppConstants } from '../../../constants/app.constants'; import { ICrudMongooseRepository } from '../repositories/mongoose/crud.mongoose-repository'; +import { Logger, StringExtensions } from '@repo/utils-core'; @Injectable() @Transactional(AppConstants.DB_CONNECTIONS.MONGOOSE) @@ -14,10 +20,16 @@ export class CrudMongooseService { ) {} async createCrud(data: Partial): Promise { + if (StringExtensions.IsNullOrEmpty(data.content)) { + throw new BadRequestException('Content is Empty'); + } + const created = await this.crudRepository.create({ - content: data.content!, + content: data.content, }); - console.log('[Mongoose] Created:', created); + + Logger.instance.info('[Mongoose] Created:', created); + return created; } @@ -44,7 +56,7 @@ export class CrudMongooseService { async delete(id: string): Promise { const deleted = await this.crudRepository.findByIdAndDelete(id); if (!deleted) throw new NotFoundException(`Crud with id ${id} not found`); - console.log('[Mongoose] Deleted:', deleted); + Logger.instance.info('[Mongoose] Deleted:', deleted); return deleted; } } diff --git a/apps/api/src/modules/crud/services/crud.prisma.service.ts b/apps/api/src/modules/crud/services/crud.prisma.service.ts index f85e770..a4ed2c1 100644 --- a/apps/api/src/modules/crud/services/crud.prisma.service.ts +++ b/apps/api/src/modules/crud/services/crud.prisma.service.ts @@ -4,6 +4,7 @@ import { NoTransaction } from '../../../decorators/method/no-transaction.decorat import { Transactional } from '../../../decorators/class/transactional.decorator'; import { AppConstants } from '../../../constants/app.constants'; import { ICrudPrismaRepository } from '../repositories/prisma/crud.prisma-repository'; +import { Logger } from '@repo/utils-core'; @Injectable() @Transactional(AppConstants.DB_CONNECTIONS.PRISMA) @@ -19,38 +20,21 @@ export class CrudPrismaService { content: data.content!, }, }); - console.log('[Prisma] Created:', created); - return { - id: created.id, - content: created.content, - createdAt: created.createdAt, - updatedAt: created.updatedAt, - }; + + Logger.instance.info('[Prisma] Created:', created); + return created; } @NoTransaction() async findAll(): Promise { - const results = await this.crudRepository.findMany(); - return results.map((item) => ({ - id: item.id, - content: item.content, - createdAt: item.createdAt, - updatedAt: item.updatedAt, - })); + return await this.crudRepository.findMany(); } @NoTransaction() - async findOne(id: string): Promise { - const crud = await this.crudRepository.findUnique({ + async findOne(id: string): Promise { + return this.crudRepository.findUnique({ where: { id }, }); - if (!crud) throw new NotFoundException(`Crud with id ${id} not found`); - return { - id: crud.id, - content: crud.content, - createdAt: crud.createdAt, - updatedAt: crud.updatedAt, - }; } async update(id: string, data: Partial): Promise { @@ -59,25 +43,16 @@ export class CrudPrismaService { data: { content: data.content }, }); if (!updated) throw new NotFoundException(`Crud with id ${id} not found`); - return { - id: updated.id, - content: updated.content, - createdAt: updated.createdAt, - updatedAt: updated.updatedAt, - }; + return updated; } async delete(id: string): Promise { const deleted = await this.crudRepository.delete({ where: { id }, }); + if (!deleted) throw new NotFoundException(`Crud with id ${id} not found`); - console.log('[Prisma] Deleted:', deleted); - return { - id: deleted.id, - content: deleted.content, - createdAt: deleted.createdAt, - updatedAt: deleted.updatedAt, - }; + Logger.instance.info('[Prisma] Deleted:', deleted); + return deleted; } } From 93291a2ea2c33d531f7164cbc35ab43bfdc58752 Mon Sep 17 00:00:00 2001 From: Anns Shahbaz Date: Fri, 12 Dec 2025 15:35:50 +0500 Subject: [PATCH 22/37] self review 1 --- TRANSACTION_MANAGEMENT.md | 1777 ----------------- apps/api/DEFAULT_TRANSACTIONS_GUIDE.md | 599 ------ apps/api/PRISMA_TRANSACTION_SETUP.md | 324 --- apps/api/eslint-rules/plugin.mjs | 1 - .../eslint-rules/require-transactional.mjs | 1 - apps/api/scripts/README.md | 42 - .../templates/entity.template.txt | 3 +- apps/api/src/app.module.ts | 13 +- apps/api/src/constants/app.constants.ts | 11 - apps/api/src/constants/server.constants.ts | 11 + .../class/transactional.decorator.ts | 105 +- .../method/no-transaction.decorator.ts | 6 +- apps/api/src/modules/crud/crud.module.ts | 13 +- .../mongoose/crud.mongoose-entity.ts | 3 +- .../mongoose/crud.mongoose-repository.ts | 4 +- .../prisma/crud.prisma-repository.ts | 4 +- .../src/modules/crud/schemas/crud.schema.ts | 4 +- .../crud/services/crud.mongoose.service.ts | 14 +- .../crud/services/crud.prisma.service.ts | 14 +- .../mongoose/mongoose.base-repository.ts | 4 +- .../mongoose/mongoose.repository.interface.ts | 4 +- apps/api/src/schemas/base.schema.ts | 4 +- apps/web/app/crud-demo/page.tsx | 41 +- 23 files changed, 109 insertions(+), 2893 deletions(-) delete mode 100644 TRANSACTION_MANAGEMENT.md delete mode 100644 apps/api/DEFAULT_TRANSACTIONS_GUIDE.md delete mode 100644 apps/api/PRISMA_TRANSACTION_SETUP.md delete mode 100644 apps/api/scripts/README.md delete mode 100644 apps/api/src/constants/app.constants.ts create mode 100644 apps/api/src/constants/server.constants.ts diff --git a/TRANSACTION_MANAGEMENT.md b/TRANSACTION_MANAGEMENT.md deleted file mode 100644 index 531c055..0000000 --- a/TRANSACTION_MANAGEMENT.md +++ /dev/null @@ -1,1777 +0,0 @@ -# Database-Agnostic Transaction Management Guide - -## Overview - -This guide explains how to implement **declarative, database-agnostic transaction management** in this NestJS codebase. The solution allows you to automatically wrap entire request handlers in transactions using a simple decorator pattern, ensuring all database operations commit together or roll back on error—without manually writing transaction logic. - -## Table of Contents - -1. [Current State](#current-state) -2. [Solution Architecture](#solution-architecture) -3. [Implementation Steps](#implementation-steps) -4. [Usage Examples](#usage-examples) -5. [Advanced Patterns](#advanced-patterns) -6. [Testing Transactions](#testing-transactions) -7. [Troubleshooting](#troubleshooting) - ---- - -## Current State - -### Current Architecture - -``` -Controller → Service A → Repository A → Database (auto-commit) - → Service B → Repository B → Database (auto-commit) - → Service C → Repository C → Database (auto-commit) -``` - -**Problem:** Each database operation commits immediately. If Service C fails, Services A & B already committed, leaving the database in an inconsistent state. - -### Current Code Example - -```typescript -// apps/api/src/modules/crud/crud.service.ts -async createCrud(data: CreateCrudDto): Promise { - return this.crudRepository.create(data); // ✅ Commits immediately -} - -// If you call multiple services, there's no transaction: -async complexOperation(data: ComplexDto) { - await this.userService.create(data.user); // ✅ Commits - await this.profileService.create(data.profile); // ❌ Fails - user already saved! -} -``` - ---- - -## Solution Architecture - -### Desired Architecture - -``` -Controller (with @Transactional) - ↓ -Transaction Interceptor (starts transaction) - ↓ -Service A → Service B → Service C (all use same transaction) - ↓ -Transaction Interceptor (commits or rolls back) -``` - -### Technology Stack - -We'll use **`nestjs-cls`** (Continuation Local Storage) which provides: - -- ✅ **Database-agnostic** transaction management -- ✅ **Declarative** `@Transactional()` decorator -- ✅ **Automatic** commit/rollback -- ✅ **Context propagation** across service boundaries -- ✅ **Transaction isolation levels** (Prisma only) -- ✅ **Nested transaction** support with propagation strategies - -**Supported Adapters:** -- `@nestjs-cls/transactional-adapter-prisma` - For PostgreSQL (via Prisma) -- `@nestjs-cls/transactional-adapter-mongoose` - For MongoDB (via Mongoose) - ---- - -## Implementation Steps - -### Step 1: Install Dependencies - -```bash -pnpm add nestjs-cls @nestjs-cls/transactional @nestjs-cls/transactional-adapter-prisma - -# If using Mongoose transactions (requires replica set): -# pnpm add @nestjs-cls/transactional-adapter-mongoose -``` - -### Step 2: Configure ClsModule in AppModule - -Update `apps/api/src/app.module.ts`: - -```typescript -import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { ClsModule } from 'nestjs-cls'; -import { ClsPluginTransactional } from '@nestjs-cls/transactional'; -import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma'; -import { PrismaService } from './modules/prisma/prisma.service'; -// ... other imports - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - envFilePath: '.env', - }), - - // Add ClsModule with Transactional Plugin - ClsModule.forRoot({ - global: true, - middleware: { - mount: true, // Mount CLS middleware globally - generateId: true, // Generate request ID - idGenerator: (req: Request) => req.headers['x-request-id'] ?? crypto.randomUUID(), - }, - plugins: [ - new ClsPluginTransactional({ - imports: [PrismaModule], - adapter: new TransactionalAdapterPrisma({ - prismaInjectionToken: PrismaService, - }), - enableTransactionProxy: true, // Allows direct service injection - }), - ], - }), - - // ... rest of your imports - TRPCModule.forRoot({ - autoSchemaFile: '../../packages/trpc/src/server', - context: AppContext, - errorFormatter: trpcErrorFormatter, - }), - PrismaModule, - MongooseModule.forRoot(process.env.DATABASE_URL_MONGODB!), - AuthModule, - // ... other modules - ], - controllers: [], - providers: [AppContext], -}) -export class AppModule implements NestModule { - configure(consumer: MiddlewareConsumer) { - consumer.apply(LoggerMiddleware).forRoutes('*'); - } -} -``` - -### Step 3: Update PrismaService (Optional Enhancement) - -Update `apps/api/src/modules/prisma/prisma.service.ts`: - -```typescript -import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; -import { PrismaClient } from '@repo/prisma-db'; - -@Injectable() -export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { - constructor() { - super({ - // Add logging for debugging transactions - log: [ - { emit: 'event', level: 'query' }, - { emit: 'event', level: 'error' }, - ], - // Global transaction defaults - transactionOptions: { - maxWait: 5000, // 5 seconds - timeout: 10000, // 10 seconds - }, - }); - - // Optional: Log queries in development - if (process.env.NODE_ENV === 'development') { - this.$on('query', (e) => { - console.log('Query: ' + e.query); - console.log('Duration: ' + e.duration + 'ms'); - }); - } - } - - async onModuleInit() { - await this.$connect(); - } - - async onModuleDestroy() { - await this.$disconnect(); - } -} -``` - -### Step 4: (Optional) Configure Mongoose for Transactions - -**Important:** MongoDB transactions require a **replica set**. For local development: - -```bash -# Update docker-compose.mongo.yml to use replica set -# See MongoDB replica set configuration below -``` - -Add to `apps/api/src/app.module.ts`: - -```typescript -import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter-mongoose'; -import { getConnectionToken } from '@nestjs/mongoose'; - -// In ClsModule plugins array, add: -plugins: [ - // Prisma adapter (primary) - new ClsPluginTransactional({ - imports: [PrismaModule], - adapter: new TransactionalAdapterPrisma({ - prismaInjectionToken: PrismaService, - }), - }), - // Mongoose adapter (secondary - if using MongoDB transactions) - new ClsPluginTransactional({ - imports: [MongooseModule], - adapter: new TransactionalAdapterMongoose({ - mongooseConnectionToken: getConnectionToken(), - }), - }), -], -``` - ---- - -## Usage Examples - -### Example 1: Simple Transactional Service Method - -```typescript -// apps/api/src/modules/crud/crud.service.ts -import { Injectable, NotFoundException } from '@nestjs/common'; -import { Transactional } from '@nestjs-cls/transactional'; -import { CrudRepository } from './repositories/crud.repository'; - -@Injectable() -export class CrudService { - constructor(private readonly crudRepository: CrudRepository) {} - - // Simple operation - no transaction needed - async findAll(): Promise { - return this.crudRepository.find(); - } - - // Complex operation - wrap in transaction - @Transactional() - async createWithRelations(data: ComplexCrudDto): Promise { - // All these operations use the SAME transaction - const crud = await this.crudRepository.create(data.crud); - - // If this fails, crud.create will be rolled back automatically - const relation = await this.relationRepository.create({ - crudId: crud.id, - ...data.relation, - }); - - // If this fails, both operations above roll back - await this.auditRepository.log({ - action: 'CRUD_CREATED', - entityId: crud.id, - }); - - return crud; - } -} -``` - -**What happens:** -1. `@Transactional()` starts a transaction before the method executes -2. All repository calls use the same transaction context -3. If any operation throws an error, entire transaction rolls back -4. If all succeed, transaction commits automatically - -### Example 2: Cross-Service Transaction - -```typescript -// apps/api/src/modules/user/user.service.ts -import { Injectable } from '@nestjs/common'; -import { Transactional } from '@nestjs-cls/transactional'; -import { ProfileService } from '../profile/profile.service'; -import { EmailService } from '../email/email.service'; - -@Injectable() -export class UserService { - constructor( - private readonly userRepository: UserRepository, - private readonly profileService: ProfileService, - private readonly emailService: EmailService, - ) {} - - @Transactional() - async registerUser(data: RegisterDto): Promise { - // Step 1: Create user - const user = await this.userRepository.create({ - email: data.email, - name: data.name, - }); - - // Step 2: Create profile (different service, same transaction!) - const profile = await this.profileService.create({ - userId: user.id, - bio: data.bio, - avatar: data.avatar, - }); - - // Step 3: Send welcome email (non-transactional, runs outside transaction) - // Use @Transactional({ propagation: Propagation.NOT_SUPPORTED }) - // on emailService.sendWelcome() if it should run outside transaction - await this.emailService.sendWelcome(user.email); - - // If email fails, user & profile still roll back - // If you don't want email to affect transaction, handle separately - - return user; - } -} -``` - -### Example 3: Controller-Level Transaction (tRPC Router) - -```typescript -// apps/api/src/modules/crud/crud.router.ts -import { Injectable } from '@nestjs/common'; -import { Mutation, Router, UseMiddlewares, Input } from 'nestjs-trpc'; -import { Transactional } from '@nestjs-cls/transactional'; -import { AuthMiddleware } from '../auth/auth.middleware'; -import { CrudService } from './crud.service'; - -@Injectable() -@Router() -export class CrudRouter { - constructor(private readonly crudService: CrudService) {} - - @UseMiddlewares(AuthMiddleware) - @Mutation({ - input: ZComplexCrudRequest, - output: ZComplexCrudResponse, - }) - @Transactional() // Transaction starts here, covers entire request - async createComplexCrud( - @Input() req: ComplexCrudRequest, - ): Promise { - // Everything in this handler is transactional - const crud = await this.crudService.createCrud(req.crud); - const metadata = await this.crudService.createMetadata(req.metadata); - const tags = await this.crudService.addTags(crud.id, req.tags); - - // If ANY of these fail, ALL roll back - return { - success: true, - crud, - metadata, - tags, - }; - } -} -``` - -### Example 4: Transaction with Isolation Level - -```typescript -@Transactional({ - isolationLevel: 'Serializable', // Strictest isolation -}) -async transferBalance(fromId: string, toId: string, amount: number) { - const fromUser = await this.userRepository.findOne(fromId); - const toUser = await this.userRepository.findOne(toId); - - if (fromUser.balance < amount) { - throw new BadRequestException('Insufficient balance'); - } - - // Deduct from sender - await this.userRepository.update(fromId, { - balance: fromUser.balance - amount, - }); - - // Add to receiver - await this.userRepository.update(toId, { - balance: toUser.balance + amount, - }); -} -``` - -**Available Isolation Levels:** -- `ReadUncommitted` - Lowest isolation (fast, dirty reads possible) -- `ReadCommitted` - Default (good balance) -- `RepeatableRead` - Prevents non-repeatable reads -- `Serializable` - Highest isolation (slowest, prevents all anomalies) - -### Example 5: Manual Transaction Control (Advanced) - -```typescript -import { TransactionHost } from '@nestjs-cls/transactional'; -import { PrismaClient } from '@repo/prisma-db'; - -@Injectable() -export class AdvancedService { - constructor( - private readonly txHost: TransactionHost, - ) {} - - async manualTransaction() { - // Get the current transaction or regular client - const tx = this.txHost.tx as PrismaClient; - - // Use it directly - const user = await tx.user.create({ data: {...} }); - const profile = await tx.profile.create({ data: {...} }); - - return { user, profile }; - } -} -``` - ---- - -## Advanced Patterns - -### Transaction Propagation - -Control how nested `@Transactional()` methods behave: - -```typescript -import { Propagation } from '@nestjs-cls/transactional'; - -@Injectable() -export class OrderService { - constructor( - private readonly inventoryService: InventoryService, - private readonly paymentService: PaymentService, - ) {} - - @Transactional() - async createOrder(data: OrderDto) { - const order = await this.orderRepository.create(data); - - // Uses parent transaction (default) - await this.inventoryService.reserveItems(order.items); - - // Runs in separate transaction (independent) - await this.paymentService.processPayment(order.total); - - return order; - } -} - -@Injectable() -export class PaymentService { - // This runs in its OWN transaction, separate from parent - @Transactional({ propagation: Propagation.REQUIRES_NEW }) - async processPayment(amount: number) { - // Even if parent transaction rolls back, this commits independently - return this.paymentRepository.create({ amount }); - } -} -``` - -**Propagation Options:** - -| Value | Behavior | -|-------|----------| -| `REQUIRED` (default) | Use parent transaction, or create new if none exists | -| `REQUIRES_NEW` | Always create new transaction, suspend parent | -| `SUPPORTS` | Use parent transaction if exists, otherwise run without transaction | -| `NOT_SUPPORTED` | Run without transaction, suspend parent if exists | -| `MANDATORY` | Require parent transaction, throw error if none exists | -| `NEVER` | Run without transaction, throw error if parent exists | - -### Conditional Transactions - -```typescript -async conditionalCreate(data: CreateDto, useTransaction = true) { - if (useTransaction) { - return this.createWithTransaction(data); - } - return this.createWithoutTransaction(data); -} - -@Transactional() -private async createWithTransaction(data: CreateDto) { - // Transactional logic -} - -private async createWithoutTransaction(data: CreateDto) { - // Non-transactional logic -} -``` - -### Transaction Timeout - -```typescript -@Transactional({ - timeout: 5000, // 5 seconds max -}) -async longRunningOperation() { - // If this takes more than 5s, transaction rolls back with timeout error -} -``` - -### Read-Only Transactions (Optimization) - -```typescript -// PostgreSQL-specific optimization -@Transactional({ - isolationLevel: 'ReadCommitted', - // Add custom options via PrismaClient config -}) -async generateReport() { - // Multiple reads, no writes - // PostgreSQL can optimize read-only transactions -} -``` - ---- - -## Testing Transactions - -### Unit Tests - -```typescript -// crud.service.spec.ts -import { Test } from '@nestjs/testing'; -import { ClsModule } from 'nestjs-cls'; -import { ClsPluginTransactional } from '@nestjs-cls/transactional'; - -describe('CrudService', () => { - let service: CrudService; - let prisma: PrismaService; - - beforeEach(async () => { - const module = await Test.createTestingModule({ - imports: [ - ClsModule.forRoot({ - plugins: [ - new ClsPluginTransactional({ - imports: [PrismaModule], - adapter: new TransactionalAdapterPrisma({ - prismaInjectionToken: PrismaService, - }), - }), - ], - }), - PrismaModule, - ], - providers: [CrudService, CrudRepository], - }).compile(); - - service = module.get(CrudService); - prisma = module.get(PrismaService); - }); - - it('should rollback on error', async () => { - const spy = jest.spyOn(prisma.crud, 'create'); - - await expect( - service.createWithInvalidData(invalidData) - ).rejects.toThrow(); - - // Verify transaction was rolled back - const count = await prisma.crud.count(); - expect(count).toBe(0); - }); -}); -``` - -### Integration Tests with Transaction Rollback - -```typescript -// Automatically rollback after each test -beforeEach(async () => { - await prisma.$transaction(async (tx) => { - // Run test inside transaction - // After test, transaction automatically rolls back - }); -}); -``` - ---- - -## Troubleshooting - -### Issue 1: "No transaction found in context" - -**Cause:** CLS middleware not mounted or service called outside request context. - -**Solution:** -```typescript -// Ensure ClsModule middleware is mounted -ClsModule.forRoot({ - middleware: { mount: true }, // ← Must be true - plugins: [...] -}) -``` - -### Issue 2: MongoDB transactions fail - -**Cause:** MongoDB requires replica set for transactions. - -**Solution:** Update `docker-compose.mongo.yml`: - -```yaml -services: - mongodb: - image: mongo:latest - command: ["--replSet", "rs0", "--bind_ip_all"] - environment: - MONGO_INITDB_ROOT_USERNAME: admin - MONGO_INITDB_ROOT_PASSWORD: admin123 - ports: - - "27017:27017" - healthcheck: - test: | - mongosh --eval " - try { - rs.status(); - } catch(e) { - rs.initiate({ - _id: 'rs0', - members: [{ _id: 0, host: 'localhost:27017' }] - }); - } - " - interval: 10s - timeout: 5s - retries: 5 -``` - -### Issue 3: Better Auth creating separate PrismaClient - -**Problem:** `apps/api/src/modules/auth/auth.ts` creates separate `new PrismaClient()` instead of using injected `PrismaService`. - -**Solution:** Refactor Better Auth to accept PrismaService: - -```typescript -// auth.module.ts -import { PrismaService } from '../prisma/prisma.service'; - -@Module({ - imports: [ - BetterAuthModule.forRootAsync({ - imports: [EmailModule, PrismaModule], - inject: [EmailService, BetterAuthLogger, PrismaService], - useFactory: ( - emailService: EmailService, - logger: BetterAuthLogger, - prisma: PrismaService, // ← Inject PrismaService - ) => ({ - auth: createBetterAuth(emailService, logger, prisma), - }), - }), - ], -}) -export class AuthModule {} - -// auth.ts -export const createBetterAuth = ( - emailService: EmailService, - logger: BetterAuthLogger, - prisma: PrismaService, // ← Accept as parameter -) => { - return betterAuth({ - database: prismaAdapter(prisma, { provider: 'postgresql' }), - // ... rest of config - }); -}; -``` - -### Issue 4: Transaction timeout - -**Cause:** Long-running operations exceed default 5-second timeout. - -**Solution:** -```typescript -@Transactional({ - timeout: 30000, // 30 seconds -}) -async importLargeDataset(data: any[]) { - // Long operation -} -``` - -### Issue 5: Nested transactions not working - -**Cause:** Using `Propagation.REQUIRES_NEW` without proper adapter support. - -**Solution:** Ensure adapter supports nested transactions: -- ✅ Prisma: Supports `REQUIRES_NEW` with separate `$transaction()` calls -- ⚠️ Mongoose: Limited nested transaction support - ---- - -## Performance Considerations - -### 1. Connection Pool Sizing - -```env -# .env -DATABASE_URL="postgresql://user:pass@host:5432/db?connection_limit=20&pool_timeout=10" -``` - -**Rule of thumb:** `connection_limit = (num_cpus * 2) + effective_spindle_count` - -For most apps: **10-20 connections** - -### 2. Transaction Duration - -**Best practices:** -- ✅ Keep transactions SHORT (< 1 second ideal) -- ✅ Fetch data BEFORE transaction -- ✅ Perform external API calls OUTSIDE transactions -- ❌ Don't do heavy computation inside transactions -- ❌ Don't call external services inside transactions - -**Example:** -```typescript -@Transactional() -async createOrder(data: OrderDto) { - // ❌ BAD: External API call inside transaction - const paymentResult = await stripe.charges.create(...); - await this.orderRepository.create({ ...data, paymentId: paymentResult.id }); -} - -// ✅ GOOD: External call outside transaction -async createOrder(data: OrderDto) { - const paymentResult = await stripe.charges.create(...); - - return this.saveOrder(data, paymentResult.id); -} - -@Transactional() -private async saveOrder(data: OrderDto, paymentId: string) { - return this.orderRepository.create({ ...data, paymentId }); -} -``` - -### 3. Isolation Level Tradeoffs - -| Level | Performance | Safety | Use Case | -|-------|-------------|--------|----------| -| Read Uncommitted | ⚡⚡⚡ Fastest | ⚠️ Lowest | Analytics, approximations | -| Read Committed | ⚡⚡ Fast | ✅ Good | General use (default) | -| Repeatable Read | ⚡ Medium | ✅✅ Better | Financial operations | -| Serializable | 🐌 Slowest | ✅✅✅ Best | Critical operations | - ---- - -## Migration Strategy - -### Phase 1: Identify Critical Operations - -Audit your codebase for operations that need transactions: - -```bash -# Find multi-step operations -grep -r "await.*Repository\." apps/api/src --include="*.service.ts" | grep -A 5 -B 5 "async" -``` - -**Candidates for transactions:** -- User registration (user + profile + verification) -- Order creation (order + items + inventory update) -- Payment processing (payment + order status + invoice) -- Data imports (multiple creates) - -### Phase 2: Gradual Rollout - -1. **Week 1:** Install dependencies, configure ClsModule -2. **Week 2:** Add `@Transactional()` to 1-2 critical endpoints -3. **Week 3:** Monitor logs, adjust timeouts if needed -4. **Week 4:** Expand to all multi-step operations - -### Phase 3: Validation - -```typescript -// Add logging to verify transactions work -@Transactional() -async createUser(data: CreateUserDto) { - console.log('Transaction started'); - try { - const user = await this.userRepository.create(data); - console.log('User created:', user.id); - - const profile = await this.profileRepository.create({...}); - console.log('Profile created:', profile.id); - - console.log('Transaction will commit'); - return user; - } catch (error) { - console.log('Transaction will rollback'); - throw error; - } -} -``` - ---- - -## Summary - -### Benefits of This Approach - -✅ **Declarative:** Just add `@Transactional()`, no boilerplate -✅ **Database-agnostic:** Works with Prisma, Mongoose, TypeORM -✅ **Automatic:** Commit/rollback handled for you -✅ **Context-aware:** Transaction propagates across services -✅ **Configurable:** Timeouts, isolation levels, propagation strategies -✅ **Testable:** Easy to mock and test - -### When to Use Transactions - -| Scenario | Use Transaction? | -|----------|------------------| -| Single read operation | ❌ No | -| Single create/update/delete | ❌ No (usually) | -| Multiple related writes | ✅ Yes | -| Cross-service operations | ✅ Yes | -| Financial operations | ✅ Yes | -| Data imports | ✅ Yes | -| Read-only operations | ❌ No (unless you need consistent snapshot) | - -### Next Steps - -1. **Install packages** (see Step 1) -2. **Configure ClsModule** in `app.module.ts` (see Step 2) -3. **Add `@Transactional()` to complex operations** (see Usage Examples) -4. **Test rollback behavior** (see Testing section) -5. **Monitor performance** and adjust timeouts - ---- - -## End-to-End Transaction Examples - -This section provides **complete, production-ready examples** showing the entire flow from tRPC router to repository with transactions, based on this codebase's architecture. - -### Example 1: Simple CRUD with Transaction (Single Service) - -This example shows how to add transactions to the existing CRUD module. - -#### Step 1: Update Repository (No changes needed) - -```typescript -// apps/api/src/modules/crud/repositories/crud.repository.ts -import { Injectable } from '@nestjs/common'; -import { PrismaService } from '../../prisma/prisma.service'; - -@Injectable() -export class CrudRepository { - constructor(private readonly prisma: PrismaService) {} - - async create(data: CreateCrudDto): Promise { - // This automatically uses the transaction from CLS context if available - return this.prisma.crud.create({ data }); - } - - async update(id: string, data: UpdateCrudDto): Promise { - return this.prisma.crud.update({ where: { id }, data }); - } - - async delete(id: string): Promise { - return this.prisma.crud.delete({ where: { id } }); - } -} -``` - -**Key Point:** Repository code doesn't change! `PrismaService` automatically uses transaction context when available. - -#### Step 2: Add @Transactional to Service (Optional) - -```typescript -// apps/api/src/modules/crud/crud.service.ts -import { Injectable, NotFoundException } from '@nestjs/common'; -import { Transactional } from '@nestjs-cls/transactional'; -import { CrudRepository } from './repositories/crud.repository'; - -@Injectable() -export class CrudService { - constructor(private readonly crudRepository: CrudRepository) {} - - // Simple operation - no transaction decorator needed - async createCrud(data: CreateCrudDto): Promise { - return this.crudRepository.create(data); - } - - // Complex operation - add @Transactional - @Transactional() - async createCrudWithAudit(data: CreateCrudDto, userId: string): Promise { - // Step 1: Create the CRUD item - const crud = await this.crudRepository.create(data); - - // Step 2: Log audit trail (same transaction) - await this.auditRepository.log({ - action: 'CREATE', - entityType: 'CRUD', - entityId: crud.id, - userId, - timestamp: new Date(), - }); - - // If audit log fails, crud creation is also rolled back - return crud; - } -} -``` - -#### Step 3: Add @Transactional to tRPC Router - -```typescript -// apps/api/src/modules/crud/crud.router.ts -import { Input, Mutation, Router, UseMiddlewares } from 'nestjs-trpc'; -import { Transactional } from '@nestjs-cls/transactional'; -import { CrudService } from './crud.service'; -import { AuthMiddleware } from '../auth/auth.middleware'; -import * as CrudSchema from './schemas/crud.schema'; - -@Router({ alias: 'crud' }) -export class CrudRouter { - constructor(private readonly crudService: CrudService) {} - - // Option A: Transaction at router level (covers entire request) - @UseMiddlewares(AuthMiddleware) - @Mutation({ - input: ZCrudCreateRequest, - output: ZCrudCreateResponse, - }) - @Transactional() // ← Add this decorator - async createCrud( - @Input() req: CrudSchema.TCrudCreateRequest, - ): Promise { - const created = await this.crudService.createCrud(req); - return { - success: created != null, - id: created?.id, - message: created ? 'Item created successfully' : 'Failed to create item', - }; - } - - // Option B: Let service handle transaction (if service has @Transactional) - @UseMiddlewares(AuthMiddleware) - @Mutation({ - input: ZCrudUpdateRequest, - output: ZCrudUpdateResponse, - }) - async updateCrud( - @Input() req: CrudSchema.TCrudUpdateRequest, - ): Promise { - // No @Transactional here, service method handles it - const updated = await this.crudService.update(req.id, req.data); - return { - success: updated != null, - data: updated ?? undefined, - message: updated ? 'Item updated successfully' : 'Failed to update item', - }; - } -} -``` - -**Transaction Flow:** -``` -Client Request - ↓ -tRPC Router (@Transactional) - ↓ -[Transaction Starts] ← ClsModule intercepts - ↓ -CrudService.createCrud() - ↓ -CrudRepository.create() ← Uses transaction from CLS context - ↓ -PrismaService.crud.create() ← Executes in transaction - ↓ -[Transaction Commits] ← Automatic on success - ↓ -Response to Client -``` - ---- - -### Example 2: Multi-Service Transaction (User Registration) - -This example shows a complete user registration flow with profile creation across multiple services. - -#### Step 1: Create Schema/DTOs - -```typescript -// apps/api/src/modules/user/schemas/user.schema.ts -import { z } from 'zod'; - -export const ZRegisterUserRequest = z.object({ - email: z.string().email(), - name: z.string().min(2), - password: z.string().min(8), - profile: z.object({ - bio: z.string().optional(), - avatar: z.string().url().optional(), - phone: z.string().optional(), - }), -}); - -export type TRegisterUserRequest = z.infer; - -export const ZRegisterUserResponse = z.object({ - success: z.boolean(), - userId: z.string().optional(), - message: z.string(), -}); - -export type TRegisterUserResponse = z.infer; -``` - -#### Step 2: Create Repositories - -```typescript -// apps/api/src/modules/user/repositories/user.repository.ts -import { Injectable } from '@nestjs/common'; -import { PrismaService } from '../../prisma/prisma.service'; - -@Injectable() -export class UserRepository { - constructor(private readonly prisma: PrismaService) {} - - async create(data: CreateUserDto): Promise { - return this.prisma.user.create({ - data: { - email: data.email, - name: data.name, - // Note: In real app, hash password before storing - password: data.password, - }, - }); - } - - async findByEmail(email: string): Promise { - return this.prisma.user.findUnique({ where: { email } }); - } -} - -// apps/api/src/modules/profile/repositories/profile.repository.ts -import { Injectable } from '@nestjs/common'; -import { PrismaService } from '../../prisma/prisma.service'; - -@Injectable() -export class ProfileRepository { - constructor(private readonly prisma: PrismaService) {} - - async create(data: CreateProfileDto): Promise { - return this.prisma.profile.create({ - data: { - userId: data.userId, - bio: data.bio, - avatar: data.avatar, - phone: data.phone, - }, - }); - } -} -``` - -#### Step 3: Create Services - -```typescript -// apps/api/src/modules/user/user.service.ts -import { Injectable, BadRequestException } from '@nestjs/common'; -import { Transactional } from '@nestjs-cls/transactional'; -import { UserRepository } from './repositories/user.repository'; -import { ProfileService } from '../profile/profile.service'; -import * as bcrypt from 'bcrypt'; - -@Injectable() -export class UserService { - constructor( - private readonly userRepository: UserRepository, - private readonly profileService: ProfileService, - ) {} - - // This method orchestrates the entire registration with transaction - @Transactional() - async registerUser(data: RegisterUserDto): Promise { - // Step 1: Check if user already exists - const existingUser = await this.userRepository.findByEmail(data.email); - if (existingUser) { - throw new BadRequestException('User with this email already exists'); - } - - // Step 2: Hash password (outside DB operation, but inside transaction scope) - const hashedPassword = await bcrypt.hash(data.password, 10); - - // Step 3: Create user - const user = await this.userRepository.create({ - email: data.email, - name: data.name, - password: hashedPassword, - }); - - // Step 4: Create profile (different service, same transaction!) - await this.profileService.create({ - userId: user.id, - bio: data.profile.bio, - avatar: data.profile.avatar, - phone: data.profile.phone, - }); - - // If ANY of the above steps fail, EVERYTHING rolls back - return user; - } -} - -// apps/api/src/modules/profile/profile.service.ts -import { Injectable } from '@nestjs/common'; -import { ProfileRepository } from './repositories/profile.repository'; - -@Injectable() -export class ProfileService { - constructor(private readonly profileRepository: ProfileRepository) {} - - // No @Transactional needed here - it uses parent transaction - async create(data: CreateProfileDto): Promise { - return this.profileRepository.create(data); - } -} -``` - -#### Step 4: Create tRPC Router - -```typescript -// apps/api/src/modules/user/user.router.ts -import { Input, Mutation, Router } from 'nestjs-trpc'; -import { Transactional } from '@nestjs-cls/transactional'; -import { UserService } from './user.service'; -import { - ZRegisterUserRequest, - ZRegisterUserResponse, - TRegisterUserRequest, - TRegisterUserResponse, -} from './schemas/user.schema'; - -@Router({ alias: 'user' }) -export class UserRouter { - constructor(private readonly userService: UserService) {} - - @Mutation({ - input: ZRegisterUserRequest, - output: ZRegisterUserResponse, - }) - // Option A: Add @Transactional here (router level) - @Transactional() - async register( - @Input() req: TRegisterUserRequest, - ): Promise { - try { - const user = await this.userService.registerUser(req); - - return { - success: true, - userId: user.id, - message: 'User registered successfully', - }; - } catch (error) { - // Transaction automatically rolls back on error - return { - success: false, - message: error.message, - }; - } - } - - // Option B: Let service handle transaction (remove @Transactional from router) - // If UserService.registerUser has @Transactional, it will handle the transaction -} -``` - -**Complete Transaction Flow:** -``` -Client calls: trpc.user.register.mutate({ email, name, password, profile }) - ↓ -UserRouter.register() [@Transactional starts here] - ↓ -[TRANSACTION STARTS] ← CLS context created - ↓ -UserService.registerUser() - ├─→ UserRepository.findByEmail() ← Read in transaction - ├─→ bcrypt.hash() ← CPU work (outside DB) - ├─→ UserRepository.create() ← Write in transaction - └─→ ProfileService.create() - └─→ ProfileRepository.create() ← Write in SAME transaction - ↓ -[TRANSACTION COMMITS] ← All succeeded - ↓ -Return success response to client - -// If ProfileRepository.create() fails: - ↓ -[TRANSACTION ROLLS BACK] ← User creation also undone - ↓ -Return error response to client -``` - ---- - -### Example 3: Complex E-commerce Order Creation - -This demonstrates a real-world scenario with multiple related entities and validation. - -#### Complete File Structure: - -``` -apps/api/src/modules/ -├── order/ -│ ├── order.module.ts -│ ├── order.router.ts ← tRPC router with @Transactional -│ ├── order.service.ts ← Business logic -│ ├── repositories/ -│ │ ├── order.repository.ts -│ │ └── order-item.repository.ts -│ └── schemas/ -│ └── order.schema.ts -├── inventory/ -│ ├── inventory.service.ts ← Called by order service -│ └── repositories/ -│ └── inventory.repository.ts -└── notification/ - └── notification.service.ts ← Runs OUTSIDE transaction -``` - -#### Implementation: - -```typescript -// apps/api/src/modules/order/schemas/order.schema.ts -import { z } from 'zod'; - -export const ZCreateOrderRequest = z.object({ - customerId: z.string(), - items: z.array( - z.object({ - productId: z.string(), - quantity: z.number().min(1), - price: z.number().positive(), - }) - ).min(1), - shippingAddress: z.object({ - street: z.string(), - city: z.string(), - zipCode: z.string(), - country: z.string(), - }), -}); - -export type TCreateOrderRequest = z.infer; - -export const ZCreateOrderResponse = z.object({ - success: z.boolean(), - orderId: z.string().optional(), - orderNumber: z.string().optional(), - total: z.number().optional(), - message: z.string(), -}); - -export type TCreateOrderResponse = z.infer; -``` - -```typescript -// apps/api/src/modules/order/repositories/order.repository.ts -import { Injectable } from '@nestjs/common'; -import { PrismaService } from '../../prisma/prisma.service'; - -@Injectable() -export class OrderRepository { - constructor(private readonly prisma: PrismaService) {} - - async create(data: CreateOrderDto): Promise { - return this.prisma.order.create({ - data: { - customerId: data.customerId, - orderNumber: data.orderNumber, - total: data.total, - status: 'PENDING', - shippingAddress: data.shippingAddress, - }, - include: { - customer: true, - items: true, - }, - }); - } -} - -// apps/api/src/modules/order/repositories/order-item.repository.ts -import { Injectable } from '@nestjs/common'; -import { PrismaService } from '../../prisma/prisma.service'; - -@Injectable() -export class OrderItemRepository { - constructor(private readonly prisma: PrismaService) {} - - async createMany(orderId: string, items: CreateOrderItemDto[]): Promise { - const data = items.map((item) => ({ - orderId, - productId: item.productId, - quantity: item.quantity, - price: item.price, - })); - - await this.prisma.orderItem.createMany({ data }); - return this.prisma.orderItem.findMany({ where: { orderId } }); - } -} -``` - -```typescript -// apps/api/src/modules/inventory/inventory.service.ts -import { Injectable, BadRequestException } from '@nestjs/common'; -import { InventoryRepository } from './repositories/inventory.repository'; - -@Injectable() -export class InventoryService { - constructor(private readonly inventoryRepository: InventoryRepository) {} - - // This runs in parent transaction if called from @Transactional context - async reserveItems(items: { productId: string; quantity: number }[]): Promise { - for (const item of items) { - const inventory = await this.inventoryRepository.findByProductId(item.productId); - - if (!inventory || inventory.quantity < item.quantity) { - throw new BadRequestException( - `Insufficient stock for product ${item.productId}` - ); - } - - // Deduct inventory - await this.inventoryRepository.update(item.productId, { - quantity: inventory.quantity - item.quantity, - }); - } - } -} -``` - -```typescript -// apps/api/src/modules/notification/notification.service.ts -import { Injectable } from '@nestjs/common'; -import { Transactional, Propagation } from '@nestjs-cls/transactional'; - -@Injectable() -export class NotificationService { - // This runs OUTSIDE transaction (won't cause rollback if fails) - @Transactional({ propagation: Propagation.NOT_SUPPORTED }) - async sendOrderConfirmation(orderId: string, email: string): Promise { - // Send email via external service (Resend, SendGrid, etc.) - // Even if this fails, order is already committed - console.log(`Sending order confirmation for ${orderId} to ${email}`); - } -} -``` - -```typescript -// apps/api/src/modules/order/order.service.ts -import { Injectable } from '@nestjs/common'; -import { Transactional } from '@nestjs-cls/transactional'; -import { OrderRepository } from './repositories/order.repository'; -import { OrderItemRepository } from './repositories/order-item.repository'; -import { InventoryService } from '../inventory/inventory.service'; -import { NotificationService } from '../notification/notification.service'; - -@Injectable() -export class OrderService { - constructor( - private readonly orderRepository: OrderRepository, - private readonly orderItemRepository: OrderItemRepository, - private readonly inventoryService: InventoryService, - private readonly notificationService: NotificationService, - ) {} - - @Transactional() - async createOrder(data: CreateOrderDto): Promise { - // Step 1: Reserve inventory (validates stock availability) - // If any item is out of stock, this throws and transaction rolls back - await this.inventoryService.reserveItems(data.items); - - // Step 2: Calculate total - const total = data.items.reduce( - (sum, item) => sum + item.price * item.quantity, - 0 - ); - - // Step 3: Generate order number - const orderNumber = `ORD-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - - // Step 4: Create order - const order = await this.orderRepository.create({ - customerId: data.customerId, - orderNumber, - total, - shippingAddress: JSON.stringify(data.shippingAddress), - }); - - // Step 5: Create order items - await this.orderItemRepository.createMany(order.id, data.items); - - // Step 6: Send notification (runs outside transaction) - // This won't cause rollback if it fails - await this.notificationService.sendOrderConfirmation( - order.id, - data.customerEmail - ); - - return order; - } -} -``` - -```typescript -// apps/api/src/modules/order/order.router.ts -import { Input, Mutation, Router, UseMiddlewares } from 'nestjs-trpc'; -import { Transactional } from '@nestjs-cls/transactional'; -import { OrderService } from './order.service'; -import { AuthMiddleware } from '../auth/auth.middleware'; -import { - ZCreateOrderRequest, - ZCreateOrderResponse, - TCreateOrderRequest, - TCreateOrderResponse, -} from './schemas/order.schema'; - -@Router({ alias: 'order' }) -export class OrderRouter { - constructor(private readonly orderService: OrderService) {} - - @UseMiddlewares(AuthMiddleware) - @Mutation({ - input: ZCreateOrderRequest, - output: ZCreateOrderResponse, - }) - @Transactional({ - timeout: 10000, // 10 seconds for complex operation - isolationLevel: 'ReadCommitted', - }) - async createOrder( - @Input() req: TCreateOrderRequest, - ): Promise { - try { - const order = await this.orderService.createOrder(req); - - return { - success: true, - orderId: order.id, - orderNumber: order.orderNumber, - total: order.total, - message: 'Order created successfully', - }; - } catch (error) { - return { - success: false, - message: error.message || 'Failed to create order', - }; - } - } -} -``` - -**Complete Transaction Flow with Rollback Scenarios:** - -``` -Client: trpc.order.createOrder.mutate({ customerId, items, shippingAddress }) - ↓ -OrderRouter.createOrder() [@Transactional starts] - ↓ -[TRANSACTION STARTS - timeout: 10s, isolation: ReadCommitted] - ↓ -OrderService.createOrder() - ├─→ InventoryService.reserveItems() - │ ├─→ Check product 1 stock ← READ in transaction - │ ├─→ Deduct product 1 stock ← WRITE in transaction - │ ├─→ Check product 2 stock ← READ in transaction - │ └─→ Deduct product 2 stock ← WRITE in transaction - │ └─→ ❌ Out of stock! BadRequestException thrown - │ ↓ - │ [TRANSACTION ROLLS BACK] ← All inventory changes undone - │ ↓ - │ Return error: "Insufficient stock for product XYZ" - - // If inventory check passes: - ├─→ Calculate total (CPU work) - ├─→ Generate order number (CPU work) - ├─→ OrderRepository.create() ← WRITE in transaction - ├─→ OrderItemRepository.createMany() ← WRITE in transaction - │ └─→ ❌ Database constraint violation (e.g., invalid productId) - │ ↓ - │ [TRANSACTION ROLLS BACK] ← Order creation AND inventory deductions undone - │ ↓ - │ Return error: "Failed to create order items" - - // If all DB operations succeed: - └─→ NotificationService.sendOrderConfirmation() ← OUTSIDE transaction - └─→ ❌ Email service fails - ↓ - [TRANSACTION STILL COMMITS] ← Order is saved, email failed separately - ↓ - Log error, but return success (order created, notification failed) - ↓ -[TRANSACTION COMMITS] ← All succeeded - ↓ -Return: { success: true, orderId: "...", orderNumber: "ORD-...", total: 299.99 } -``` - ---- - -### Example 4: Accessing Transaction Directly in Repositories - -Sometimes you need direct access to the transaction client (e.g., for raw queries or complex operations). - -```typescript -// apps/api/src/modules/analytics/repositories/analytics.repository.ts -import { Injectable } from '@nestjs/common'; -import { TransactionHost } from '@nestjs-cls/transactional'; -import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma'; -import { PrismaClient } from '@repo/prisma-db'; - -@Injectable() -export class AnalyticsRepository { - constructor( - private readonly txHost: TransactionHost, - ) {} - - async generateReportWithRawSQL(startDate: Date, endDate: Date): Promise { - // Get transaction client or fallback to regular client - const prisma = (this.txHost.tx ?? this.txHost.prisma) as PrismaClient; - - // Execute raw SQL within transaction context - return prisma.$queryRaw` - SELECT - DATE(created_at) as date, - COUNT(*) as order_count, - SUM(total) as revenue - FROM orders - WHERE created_at BETWEEN ${startDate} AND ${endDate} - GROUP BY DATE(created_at) - ORDER BY date DESC - `; - } -} -``` - ---- - -### Example 5: Testing Transactions with Rollback - -```typescript -// apps/api/src/modules/order/order.service.spec.ts -import { Test } from '@nestjs/testing'; -import { ClsModule } from 'nestjs-cls'; -import { ClsPluginTransactional } from '@nestjs-cls/transactional'; -import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma'; -import { PrismaService } from '../prisma/prisma.service'; -import { OrderService } from './order.service'; - -describe('OrderService - Transaction Tests', () => { - let service: OrderService; - let prisma: PrismaService; - - beforeEach(async () => { - const module = await Test.createTestingModule({ - imports: [ - ClsModule.forRoot({ - global: true, - middleware: { mount: true }, - plugins: [ - new ClsPluginTransactional({ - imports: [PrismaModule], - adapter: new TransactionalAdapterPrisma({ - prismaInjectionToken: PrismaService, - }), - }), - ], - }), - PrismaModule, - OrderModule, - InventoryModule, - ], - providers: [OrderService, /* other providers */], - }).compile(); - - service = module.get(OrderService); - prisma = module.get(PrismaService); - }); - - it('should rollback entire order when inventory is insufficient', async () => { - // Setup: Create product with limited stock - const product = await prisma.product.create({ - data: { name: 'Test Product', price: 100 }, - }); - await prisma.inventory.create({ - data: { productId: product.id, quantity: 5 }, - }); - - // Test: Try to order more than available - await expect( - service.createOrder({ - customerId: 'test-customer', - items: [{ productId: product.id, quantity: 10, price: 100 }], - shippingAddress: { /* ... */ }, - }) - ).rejects.toThrow('Insufficient stock'); - - // Verify: No order created - const orderCount = await prisma.order.count(); - expect(orderCount).toBe(0); - - // Verify: Inventory unchanged - const inventory = await prisma.inventory.findUnique({ - where: { productId: product.id }, - }); - expect(inventory.quantity).toBe(5); // Still 5, not deducted - }); - - it('should commit order and deduct inventory on success', async () => { - const product = await prisma.product.create({ - data: { name: 'Test Product', price: 100 }, - }); - await prisma.inventory.create({ - data: { productId: product.id, quantity: 10 }, - }); - - const order = await service.createOrder({ - customerId: 'test-customer', - items: [{ productId: product.id, quantity: 3, price: 100 }], - shippingAddress: { /* ... */ }, - }); - - // Verify: Order created - expect(order.id).toBeDefined(); - expect(order.total).toBe(300); - - // Verify: Inventory deducted - const inventory = await prisma.inventory.findUnique({ - where: { productId: product.id }, - }); - expect(inventory.quantity).toBe(7); // 10 - 3 = 7 - }); -}); -``` - ---- - -### Where to Place @Transactional - -| Layer | When to Use | Example | -|-------|-------------|---------| -| **tRPC Router** | When you want to wrap the entire request handler | `@Transactional()` on `createOrder()` | -| **Service** | When the service orchestrates multiple operations | `@Transactional()` on `OrderService.createOrder()` | -| **Repository** | ❌ Never | Repositories use transaction from context automatically | - -**Best Practice:** Put `@Transactional()` at the **highest level** that needs atomicity: - -```typescript -// ✅ GOOD: Transaction at router level (covers entire request) -@Router() -export class OrderRouter { - @Mutation() - @Transactional() - async createOrder() { - await this.orderService.create(); // Uses transaction - await this.emailService.sendEmail(); // Uses transaction - } -} - -// ⚠️ OKAY: Transaction at service level (if router doesn't need transaction) -export class OrderService { - @Transactional() - async create() { - await this.orderRepo.create(); - await this.itemRepo.createMany(); - } -} - -// ❌ BAD: Multiple transactions (defeats the purpose) -export class OrderService { - @Transactional() - async createOrder() { /* ... */ } - - @Transactional() - async createItems() { /* ... */ } // Separate transaction! -} -``` - ---- - -### Common Pitfalls and Solutions - -#### Pitfall 1: External API Calls Inside Transactions - -```typescript -// ❌ BAD: Payment API inside transaction -@Transactional() -async createOrder(data: OrderDto) { - const order = await this.orderRepo.create(data); - const payment = await stripe.charges.create(...); // External API! - await this.orderRepo.update(order.id, { paymentId: payment.id }); -} - -// ✅ GOOD: External API outside transaction -async createOrder(data: OrderDto) { - // Step 1: Process payment first (outside transaction) - const payment = await stripe.charges.create(...); - - // Step 2: Save to DB (inside transaction) - return this.saveOrder(data, payment.id); -} - -@Transactional() -private async saveOrder(data: OrderDto, paymentId: string) { - return this.orderRepo.create({ ...data, paymentId }); -} -``` - -#### Pitfall 2: Forgetting to Throw Errors - -```typescript -// ❌ BAD: Swallowing errors prevents rollback -@Transactional() -async createOrder(data: OrderDto) { - try { - await this.inventoryService.reserve(data.items); - } catch (error) { - console.log('Inventory failed'); // Transaction won't rollback! - return null; - } -} - -// ✅ GOOD: Let errors propagate -@Transactional() -async createOrder(data: OrderDto) { - // Just let it throw - transaction will rollback automatically - await this.inventoryService.reserve(data.items); - return this.orderRepo.create(data); -} -``` - -#### Pitfall 3: Using Wrong Propagation - -```typescript -// ❌ BAD: Child service creates separate transaction -export class OrderService { - @Transactional() - async createOrder() { - await this.itemService.createItems(); // Separate transaction! - } -} - -export class ItemService { - @Transactional({ propagation: Propagation.REQUIRES_NEW }) // Wrong! - async createItems() { /* ... */ } -} - -// ✅ GOOD: Child uses parent transaction (default) -export class ItemService { - async createItems() { // No decorator needed - // Automatically uses parent transaction - } -} -``` - ---- - -## References - -- [nestjs-cls Documentation](https://papooch.github.io/nestjs-cls/) -- [Transactional Plugin](https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional) -- [Prisma Transactions](https://www.prisma.io/docs/orm/prisma-client/queries/transactions) -- [MongoDB Transactions](https://www.mongodb.com/docs/manual/core/transactions/) -- [NestJS Interceptors](https://docs.nestjs.com/interceptors) - ---- - -**Created:** 2025-12-08 -**Last Updated:** 2025-12-08 -**Maintainer:** Binary Exploits LLC diff --git a/apps/api/DEFAULT_TRANSACTIONS_GUIDE.md b/apps/api/DEFAULT_TRANSACTIONS_GUIDE.md deleted file mode 100644 index ecb82bc..0000000 --- a/apps/api/DEFAULT_TRANSACTIONS_GUIDE.md +++ /dev/null @@ -1,599 +0,0 @@ -# Making Transactions Default in NestJS + tRPC - -## ⚠️ Important Discovery - -**tRPC Router Middleware with `@Transactional()` DOES NOT WORK** because tRPC's middleware execution doesn't properly propagate the CLS (AsyncLocalStorage) context where transactions are stored. - -## ✅ Correct Approach - -**Put `@Transactional()` on SERVICE METHODS**, not on routers or middleware. - ---- - -## Table of Contents - -1. [Correct Approach: Service-Level Transactions](#correct-approach-service-level-transactions) -2. [Why Router Middleware Doesn't Work](#why-router-middleware-doesnt-work) -3. [Optional: Base Service Class](#optional-base-service-class) -4. [Best Practices](#best-practices) - ---- - -## Correct Approach: Service-Level Transactions - -### Implementation (Current & Working) - -Put `@Transactional()` directly on service methods that need transactions: - -```typescript -// crud.service.ts -import { Injectable } from '@nestjs/common'; -import { Transactional } from '@nestjs-cls/transactional'; - -@Injectable() -export class CrudService { - constructor(private readonly crudRepository: CrudRepository) {} - - // ✅ Add @Transactional to each write method - @Transactional() - async createCrud(data: CreateCrudDto): Promise { - const created = await this.crudRepository.create(data); - - // If any operation fails, entire transaction rolls back - await this.auditRepository.log({ action: 'CREATED', id: created.id }); - - return created; - } - - // ✅ Transactions on updates - @Transactional() - async update(id: string, data: UpdateCrudDto): Promise { - const updated = await this.crudRepository.update(id, data); - if (!updated) throw new NotFoundException(`Crud with id ${id} not found`); - return updated; - } - - // ✅ Transactions on deletes - @Transactional() - async delete(id: string): Promise { - const deleted = await this.crudRepository.delete(id); - if (!deleted) throw new NotFoundException(`Crud with id ${id} not found`); - return deleted; - } - - // ❌ No transaction needed for reads - async findAll(): Promise { - return this.crudRepository.find(); - } - - async findOne(id: string): Promise { - const crud = await this.crudRepository.findOne(id); - if (!crud) throw new NotFoundException(`Crud with id ${id} not found`); - return crud; - } -} -``` - -### Transaction Flow - -``` -Client Request - ↓ -tRPC Router Method - ↓ -Service Method [@Transactional starts here] - ↓ -[TRANSACTION STARTS] - ↓ -Repository Method (uses txHost.tx) - ↓ -Database Operations (in transaction) - ↓ -[TRANSACTION COMMITS or ROLLS BACK] - ↓ -Response to Client -``` - ---- - -## Why Router Middleware Doesn't Work - -### What We Tried (Didn't Work) - -Apply `TransactionMiddleware` at the **Router class level** to make all procedures in that router transactional by default. - -### Implementation - -#### 1. Transaction Middleware (`middleware/transaction.middleware.ts`) - -```typescript -import { Injectable } from '@nestjs/common'; -import { Transactional } from '@nestjs-cls/transactional'; -import { MiddlewareOptions, TRPCMiddleware } from 'nestjs-trpc'; - -/** - * Global transaction middleware for tRPC - * Automatically wraps all mutations in transactions - */ -@Injectable() -export class TransactionMiddleware implements TRPCMiddleware { - @Transactional() - async use(opts: MiddlewareOptions): Promise { - const { next } = opts; - - // The @Transactional decorator handles everything - return next(); - } -} -``` - -#### 2. Apply to Router (`crud.router.ts`) - -```typescript -import { TransactionMiddleware } from '../../middleware/transaction.middleware'; - -// Apply to ALL procedures in this router -@UseMiddlewares(TransactionMiddleware) -@Router({ alias: 'crud' }) -export class CrudRouter { - constructor(private readonly crudService: CrudService) {} - - // ✅ Automatically transactional (no decorator needed) - @Mutation({ input: ZCreateRequest, output: ZCreateResponse }) - async createCrud(@Input() req: CreateRequest) { - return this.crudService.createCrud(req); - } - - // ✅ Automatically transactional - @Mutation({ input: ZUpdateRequest, output: ZUpdateResponse }) - async updateCrud(@Input() req: UpdateRequest) { - return this.crudService.update(req.id, req.data); - } - - // ✅ Also transactional (but reads don't need transactions usually) - @Query({ input: ZFindAllRequest, output: ZFindAllResponse }) - async findAll(@Input() req: FindAllRequest) { - return this.crudService.findAll(); - } -} -``` - -#### 3. Register Middleware in Module (`crud.module.ts`) - -```typescript -@Module({ - imports: [PrismaModule], - providers: [ - CrudRepository, - CrudService, - CrudRouter, - TransactionMiddleware // ← Register here - ], - exports: [CrudRepository, CrudService], -}) -export class CrudModule {} -``` - -#### 4. Service (No Decorators Needed) - -```typescript -@Injectable() -export class CrudService { - constructor(private readonly crudRepository: CrudRepository) {} - - // ✅ Automatically transactional (router middleware handles it) - async createCrud(data: CreateCrudDto): Promise { - const created = await this.crudRepository.create(data); - - // If this fails, transaction rolls back automatically - await this.auditRepository.log({ action: 'CREATED', id: created.id }); - - return created; - } - - // ✅ Also transactional - async update(id: string, data: UpdateCrudDto): Promise { - return this.crudRepository.update(id, data); - } - - // Reads don't need transactions, but they're harmless - async findAll(): Promise { - return this.crudRepository.find(); - } -} -``` - -### Execution Flow - -``` -Client Request - ↓ -tRPC Router → TransactionMiddleware [@Transactional] - ↓ -[TRANSACTION STARTS] - ↓ -Router Method (createCrud) - ↓ -Service Method (no decorator needed) - ↓ -Repository Method (uses txHost.tx) - ↓ -Database Operations (in transaction) - ↓ -[TRANSACTION COMMITS or ROLLS BACK] - ↓ -Response to Client -``` - -### Pros & Cons - -✅ **Pros:** -- No decorators needed on services or methods -- Clean, DRY code -- Easy to apply to entire router at once -- Middleware executes BEFORE AuthMiddleware (if both are applied) - -❌ **Cons:** -- Applies to ALL procedures (including Queries that don't need transactions) -- One transaction per router procedure (can't have multiple independent transactions in same handler) - ---- - -## Approach 2: Custom Base Service Class - -Create a base service class that automatically wraps all methods in transactions. - -### Implementation - -#### 1. Base Service (`base/transactional-base.service.ts`) - -```typescript -import { Injectable } from '@nestjs/common'; -import { Transactional } from '@nestjs-cls/transactional'; - -/** - * Base service that makes all methods transactional by default - * Extend this class to get automatic transaction support - */ -@Injectable() -export abstract class TransactionalBaseService { - /** - * Override this method in subclasses to customize transaction behavior - */ - protected get transactionOptions() { - return { - timeout: 10000, // 10 seconds - isolationLevel: 'ReadCommitted' as const, - }; - } - - /** - * Wrap any method call in a transaction - */ - @Transactional() - protected async withTransaction(fn: () => Promise): Promise { - return fn(); - } -} -``` - -#### 2. Service Extends Base - -```typescript -@Injectable() -export class CrudService extends TransactionalBaseService { - constructor(private readonly crudRepository: CrudRepository) { - super(); - } - - // ❌ This approach doesn't work well - need manual wrapping - async createCrud(data: CreateCrudDto): Promise { - return this.withTransaction(async () => { - const created = await this.crudRepository.create(data); - await this.auditRepository.log({ action: 'CREATED' }); - return created; - }); - } -} -``` - -### Pros & Cons - -⚠️ **Not Recommended:** -- Still requires manual wrapping with `withTransaction()` -- More boilerplate than router middleware approach -- Doesn't truly make things "automatic" - ---- - -## Approach 3: Global Interceptor - -Use NestJS interceptor to wrap ALL requests in transactions (not tRPC-specific). - -### Implementation - -#### 1. Global Transaction Interceptor - -```typescript -import { - Injectable, - NestInterceptor, - ExecutionContext, - CallHandler, -} from '@nestjs/common'; -import { Observable } from 'rxjs'; -import { Transactional } from '@nestjs-cls/transactional'; - -@Injectable() -export class GlobalTransactionInterceptor implements NestInterceptor { - @Transactional() - intercept(context: ExecutionContext, next: CallHandler): Observable { - // Simply pass through - @Transactional handles the rest - return next.handle(); - } -} -``` - -#### 2. Register Globally in `main.ts` - -```typescript -import { GlobalTransactionInterceptor } from './interceptors/transaction.interceptor'; - -async function bootstrap() { - const app = await NestFactory.create(AppModule); - - // Apply globally to ALL requests - app.useGlobalInterceptors(new GlobalTransactionInterceptor()); - - await app.listen(4000); -} -``` - -### Pros & Cons - -✅ **Pros:** -- Truly global - applies to ALL requests (tRPC, REST, GraphQL) -- No need to modify routers or services - -❌ **Cons:** -- Too broad - wraps EVERYTHING (including health checks, static files) -- May cause performance issues for read-heavy operations -- Harder to opt-out for specific endpoints -- Doesn't work well with tRPC's middleware system - -⚠️ **Not Recommended for tRPC apps** - ---- - -## Comparison & Recommendations - -| Approach | Granularity | Ease of Use | Performance | Opt-Out | -|----------|-------------|-------------|-------------|---------| -| **Router Middleware** | Per Router | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | Easy | -| Base Service | Per Method | ⭐⭐ | ⭐⭐⭐⭐⭐ | Manual | -| Global Interceptor | All Requests | ⭐⭐⭐⭐ | ⭐⭐ | Hard | - -### 🏆 **Recommended: Router-Level Middleware** - -**Why?** -1. ✅ Clean code - no decorators on services -2. ✅ Granular control - apply per router -3. ✅ Easy opt-out - use `@UseMiddlewares()` selectively -4. ✅ Works naturally with tRPC -5. ✅ Good performance - only applies to tRPC procedures - ---- - -## Optimizing for Read vs Write - -If you want transactions **only for mutations** (not queries), create two middlewares: - -### Mutation-Only Transactions - -```typescript -// middleware/mutation-transaction.middleware.ts -@Injectable() -export class MutationTransactionMiddleware implements TRPCMiddleware { - @Transactional() - async use(opts: MiddlewareOptions): Promise { - return opts.next(); - } -} - -// Apply only to mutations -@Router({ alias: 'crud' }) -export class CrudRouter { - // ✅ Transactional - @UseMiddlewares(MutationTransactionMiddleware) - @Mutation({ input: ZCreateRequest, output: ZCreateResponse }) - async createCrud(@Input() req: CreateRequest) { - return this.crudService.createCrud(req); - } - - // ✅ No transaction (faster for reads) - @Query({ input: ZFindAllRequest, output: ZFindAllResponse }) - async findAll(@Input() req: FindAllRequest) { - return this.crudService.findAll(); - } -} -``` - -**But:** Router-level middleware already applies to all, so just use: - -```typescript -@UseMiddlewares(TransactionMiddleware) // ← All procedures -@Router({ alias: 'crud' }) -``` - -Queries in transactions are fine - they just use a read-only snapshot. - ---- - -## How to Opt-Out - -If you need a specific procedure to **NOT use transactions**: - -### Method 1: Don't Apply Middleware - -```typescript -@Router({ alias: 'crud' }) -export class CrudRouter { - // ✅ Transactional - @UseMiddlewares(TransactionMiddleware, AuthMiddleware) - @Mutation() - async create() { /* ... */ } - - // ❌ Not transactional (no TransactionMiddleware) - @UseMiddlewares(AuthMiddleware) - @Mutation() - async updateLargeFile() { - // Long-running operation, don't hold transaction open - } -} -``` - -### Method 2: Use Propagation.NOT_SUPPORTED - -```typescript -@Injectable() -export class FileService { - // This runs OUTSIDE any parent transaction - @Transactional({ propagation: Propagation.NOT_SUPPORTED }) - async uploadLargeFile(file: Buffer): Promise { - // External service call - don't need transaction - await s3.upload(file); - } -} -``` - ---- - -## Testing Default Transactions - -### Test 1: Verify Rollback Works - -```typescript -// crud.service.ts -async createCrud(data: CreateCrudDto): Promise { - const created = await this.crudRepository.create(data); - - throw new Error('Test rollback'); // ← Simulate error - - return created; -} -``` - -**Expected:** -- Error thrown ✅ -- Database has NO new record ✅ (rolled back) - -### Test 2: Verify Normal Operation - -```typescript -// Remove the error -async createCrud(data: CreateCrudDto): Promise { - return this.crudRepository.create(data); -} -``` - -**Expected:** -- Record created ✅ -- Database has new record ✅ - ---- - -## Performance Considerations - -### Transaction Overhead - -Each transaction has a small overhead (~1-5ms). For read-heavy apps, consider: - -1. **Skip transactions for Queries** (if using mutation-only approach) -2. **Connection pool sizing** - ensure `connection_limit` is adequate -3. **Transaction timeout** - keep short (5-10 seconds max) - -### Recommended Settings - -```typescript -// In TransactionMiddleware -@Transactional({ - timeout: 10000, // 10 seconds max - isolationLevel: 'ReadCommitted', // Default (fastest) -}) -async use(opts: MiddlewareOptions) { - return opts.next(); -} -``` - ---- - -## Migration Guide - -### Before (Manual Decorators) - -```typescript -@Injectable() -export class UserService { - @Transactional() // ← Remove this - async register(data: RegisterDto) { - const user = await this.userRepo.create(data.user); - const profile = await this.profileRepo.create(data.profile); - return { user, profile }; - } - - @Transactional() // ← Remove this - async update(id: string, data: UpdateDto) { - return this.userRepo.update(id, data); - } -} -``` - -### After (Router Middleware) - -```typescript -// 1. Add middleware to router -@UseMiddlewares(TransactionMiddleware) -@Router({ alias: 'user' }) -export class UserRouter { - // All mutations automatically transactional -} - -// 2. Remove decorators from service -@Injectable() -export class UserService { - // ✅ Automatic transaction (no decorator needed) - async register(data: RegisterDto) { - const user = await this.userRepo.create(data.user); - const profile = await this.profileRepo.create(data.profile); - return { user, profile }; - } - - // ✅ Automatic transaction - async update(id: string, data: UpdateDto) { - return this.userRepo.update(id, data); - } -} -``` - ---- - -## Summary - -### What You Get - -✅ **All mutations are transactional by default** -✅ **No need to add `@Transactional()` to services** -✅ **Automatic rollback on errors** -✅ **Clean, maintainable code** -✅ **Easy to opt-out when needed** - -### What to Remember - -1. Apply `@UseMiddlewares(TransactionMiddleware)` at **Router level** -2. Register `TransactionMiddleware` in **module providers** -3. Remove `@Transactional()` from **service methods** -4. Use `Propagation.NOT_SUPPORTED` to **opt-out** specific methods - ---- - -**Last Updated:** 2025-12-08 diff --git a/apps/api/PRISMA_TRANSACTION_SETUP.md b/apps/api/PRISMA_TRANSACTION_SETUP.md deleted file mode 100644 index 1b0b529..0000000 --- a/apps/api/PRISMA_TRANSACTION_SETUP.md +++ /dev/null @@ -1,324 +0,0 @@ -# Prisma Transaction Setup - Custom Client Type - -## Overview - -This project uses a **custom PrismaService** (which extends `PrismaClient`) instead of the default Prisma client. To make transactions work properly with `nestjs-cls`, we need to configure the adapter with our custom type. - -## Type Alias Pattern - -To avoid verbose type annotations, we've created a **type alias** for the transaction adapter. - -### Files Structure - -``` -apps/api/src/modules/prisma/ -├── prisma.service.ts # Custom PrismaService (extends PrismaClient) -├── prisma.module.ts # Module exports -├── prisma-transaction.types.ts # Type alias for transactions -``` - ---- - -## Implementation - -### 1. Type Alias (`prisma-transaction.types.ts`) - -```typescript -import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma'; -import { PrismaService } from './prisma.service'; - -/** - * Type alias for the Prisma transaction adapter with custom PrismaService - * This avoids verbose type annotations throughout the codebase - */ -export type PrismaTransactionAdapter = TransactionalAdapterPrisma; -``` - -**Why?** Without this, every repository would need: -```typescript -// ❌ VERBOSE (without alias) -constructor( - private readonly txHost: TransactionHost> -) {} - -// ✅ CLEAN (with alias) -constructor( - private readonly txHost: TransactionHost -) {} -``` - ---- - -### 2. PrismaModule Export (`prisma.module.ts`) - -```typescript -import { Module } from '@nestjs/common'; -import { PrismaService } from './prisma.service'; - -@Module({ - providers: [PrismaService], - exports: [PrismaService], -}) -export class PrismaModule {} - -// Re-export transaction types for convenience -export * from './prisma-transaction.types'; -``` - -This allows clean imports: -```typescript -// ✅ Import both from one place -import { PrismaService, PrismaTransactionAdapter } from '../../prisma/prisma.module'; -``` - ---- - -### 3. Repository Pattern (`crud.repository.ts`) - -```typescript -import { Injectable } from '@nestjs/common'; -import { TransactionHost } from '@nestjs-cls/transactional'; -import { PrismaService, PrismaTransactionAdapter } from '../../prisma/prisma.module'; - -@Injectable() -export class CrudRepository { - constructor( - private readonly txHost: TransactionHost, - ) {} - - // Helper to get the Prisma client (transactional or regular) - private get prisma(): PrismaService { - return this.txHost.tx as PrismaService; - } - - async create(data: CreateDto) { - // Automatically uses transaction context when available - return this.prisma.crud.create({ data }); - } -} -``` - -**Key Points:** -- ✅ Inject `TransactionHost` (not `PrismaService` directly) -- ✅ Use `this.txHost.tx` to get the Prisma client -- ✅ The getter provides type-safe access to `PrismaService` methods - ---- - -### 4. AppModule Configuration (`app.module.ts`) - -```typescript -ClsModule.forRoot({ - global: true, - middleware: { - mount: true, - generateId: true, - idGenerator: (req) => - (req.headers as any)['x-request-id'] ?? crypto.randomUUID(), - }, - plugins: [ - new ClsPluginTransactional({ - imports: [PrismaModule], - adapter: new TransactionalAdapterPrisma({ - prismaInjectionToken: PrismaService, // ← Use custom PrismaService - }), - enableTransactionProxy: true, - }), - ], -}), -``` - -**Critical:** `prismaInjectionToken: PrismaService` tells the adapter to use our custom service. - ---- - -## How It Works - -### Without Transaction (Normal Flow) - -```typescript -Repository → txHost.tx → PrismaService → Database - ↓ - (Regular client, auto-commit) -``` - -### With @Transactional (Transaction Flow) - -```typescript -Service [@Transactional] - ↓ -Repository → txHost.tx → Transactional PrismaClient → Database - ↓ ↓ - (Transaction proxy) (BEGIN → COMMIT/ROLLBACK) -``` - -**Magic:** `txHost.tx` returns: -- **Inside `@Transactional()`**: The transactional client (from CLS context) -- **Outside `@Transactional()`**: The regular PrismaService - ---- - -## Usage Example - -### Service with Transaction - -```typescript -import { Injectable } from '@nestjs/common'; -import { Transactional } from '@nestjs-cls/transactional'; - -@Injectable() -export class UserService { - constructor( - private readonly userRepo: UserRepository, - private readonly profileRepo: ProfileRepository, - ) {} - - @Transactional() - async registerUser(data: RegisterDto) { - // Both operations use the SAME transaction - const user = await this.userRepo.create(data.user); - const profile = await this.profileRepo.create({ - userId: user.id, - ...data.profile, - }); - - // If profile creation fails, user creation rolls back too - return { user, profile }; - } -} -``` - -### Repository (No Changes Needed) - -```typescript -@Injectable() -export class UserRepository { - constructor( - private readonly txHost: TransactionHost, - ) {} - - private get prisma() { - return this.txHost.tx as PrismaService; - } - - async create(data: CreateUserDto) { - // Automatically uses transaction if available - return this.prisma.user.create({ data }); - } -} -``` - ---- - -## Why Not Inject PrismaService Directly? - -### ❌ Wrong (Breaks Transactions) - -```typescript -@Injectable() -export class CrudRepository { - constructor(private readonly prisma: PrismaService) {} - - async create(data: CreateDto) { - // This bypasses the transaction proxy! - return this.prisma.crud.create({ data }); - } -} -``` - -**Problem:** Direct injection uses the global PrismaService instance, which doesn't know about the CLS transaction context. - -### ✅ Correct (Transactions Work) - -```typescript -@Injectable() -export class CrudRepository { - constructor( - private readonly txHost: TransactionHost - ) {} - - private get prisma() { - return this.txHost.tx as PrismaService; - } - - async create(data: CreateDto) { - // This uses the transaction from CLS context - return this.prisma.crud.create({ data }); - } -} -``` - -**Solution:** Use `TransactionHost` to get the correct client (transactional or regular). - ---- - -## Testing Transactions - -### Test Rollback - -1. Add error in service after database operation: -```typescript -@Transactional() -async createCrud(data: CreateDto) { - const created = await this.crudRepo.create(data); - throw new Error('Test rollback'); // Simulate error - return created; -} -``` - -2. Call the mutation and check database: - - ✅ Error is thrown - - ✅ Database has NO new record (rolled back) - -3. Remove the error and test normal operation: - - ✅ Record is created successfully - ---- - -## Common Issues - -### Issue 1: "Cannot read property 'tx' of undefined" - -**Cause:** ClsModule not properly configured or middleware not mounted. - -**Fix:** Ensure `app.module.ts` has: -```typescript -ClsModule.forRoot({ - middleware: { mount: true }, // ← Must be true - plugins: [/* ... */] -}) -``` - -### Issue 2: Transaction doesn't rollback - -**Cause:** Repository injects `PrismaService` directly instead of `TransactionHost`. - -**Fix:** Use the repository pattern shown above. - -### Issue 3: Type errors with `txHost.tx` - -**Cause:** Missing generic type parameter. - -**Fix:** Use `TransactionHost` with the type alias. - ---- - -## Benefits of This Approach - -1. ✅ **Type Safety**: Full TypeScript support for PrismaService methods -2. ✅ **Clean Code**: No verbose type annotations in repositories -3. ✅ **Automatic**: Transactions work transparently without manual `$transaction()` -4. ✅ **Flexible**: Works with Prisma Client Extensions and custom methods -5. ✅ **Testable**: Easy to mock `TransactionHost` in unit tests - ---- - -## References - -- [nestjs-cls Transactional Plugin](https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional) -- [Prisma Adapter Docs](https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional/prisma-adapter) -- [Custom Prisma Client Types](https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional/prisma-adapter#custom-client-type) - ---- - -**Last Updated:** 2025-12-08 diff --git a/apps/api/eslint-rules/plugin.mjs b/apps/api/eslint-rules/plugin.mjs index 415b173..9c2aad7 100644 --- a/apps/api/eslint-rules/plugin.mjs +++ b/apps/api/eslint-rules/plugin.mjs @@ -1,4 +1,3 @@ -// eslint-rules/plugin.js import { requireTransactional } from './require-transactional.mjs'; export const plugin = { diff --git a/apps/api/eslint-rules/require-transactional.mjs b/apps/api/eslint-rules/require-transactional.mjs index 7ae4611..82b5418 100644 --- a/apps/api/eslint-rules/require-transactional.mjs +++ b/apps/api/eslint-rules/require-transactional.mjs @@ -16,7 +16,6 @@ export const requireTransactional = { return { ClassDeclaration(node) { const className = node.id?.name || ''; - // Only check classes ending with 'Service' if (!className.endsWith('Service')) return; const decorators = node.decorators || []; diff --git a/apps/api/scripts/README.md b/apps/api/scripts/README.md deleted file mode 100644 index f1f5e07..0000000 --- a/apps/api/scripts/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# API Scripts - -## Repository Generators - -### Prisma Repository Generator - -Generates type-safe Prisma repositories. - -```bash -pnpm run generate:repo:prisma -``` - -Example: -```bash -pnpm run generate:repo:prisma crud -``` - -Output: `src/modules/{entity}/repositories/prisma/` -- `{entity}.repository.interface.ts` -- `{entity}.repository.abstract.ts` -- `{entity}.repository.ts` - -### Mongoose Repository Generator - -Generates Mongoose repositories with entity schema. - -```bash -pnpm run generate:repo:mongo -``` - -Example: -```bash -pnpm run generate:repo:mongo crud -``` - -Output: `src/modules/{entity}/repositories/mongoose/` -- `entities/{entity}.entity.ts` - Empty entity class (add @Prop decorators) -- `{entity}.mongo.repository.ts` - Repository implementation - -Note: -1. Add properties to the entity class using @Prop decorators -2. Implement the `toDomainEntity` method in the repository diff --git a/apps/api/scripts/code-generation/mongoose/repositories/templates/entity.template.txt b/apps/api/scripts/code-generation/mongoose/repositories/templates/entity.template.txt index 8722fa9..b790686 100644 --- a/apps/api/scripts/code-generation/mongoose/repositories/templates/entity.template.txt +++ b/apps/api/scripts/code-generation/mongoose/repositories/templates/entity.template.txt @@ -4,4 +4,5 @@ import { MongooseBaseEntity } from '../../../../repositories/mongoose/mongoose.b @Schema({ collection: '{{ENTITY_NAME_LOWER}}', timestamps: true }) export class {{ENTITY_NAME_CAPITALIZED}}MongooseEntity extends MongooseBaseEntity {} -export const {{ENTITY_NAME_CAPITALIZED}}MongooseSchema = SchemaFactory.createForClass({{ENTITY_NAME_CAPITALIZED}}MongooseEntity); +export const {{ENTITY_NAME_CAPITALIZED}}MongooseSchema = + SchemaFactory.createForClass({{ENTITY_NAME_CAPITALIZED}}MongooseEntity); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 25cff2c..4ed8a5f 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -16,7 +16,7 @@ import { ClsModule } from 'nestjs-cls'; import { ClsPluginTransactional } from '@nestjs-cls/transactional'; import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter-mongoose'; import { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma'; -import { AppConstants } from './constants/app.constants'; +import { ServerConstants } from './constants/server.constants'; @Module({ imports: [ @@ -31,7 +31,6 @@ import { AppConstants } from './constants/app.constants'; }), PrismaModule, MongooseModule.forRoot(process.env.DATABASE_URL_MONGODB!), - // Add ClsModule with Transactional Plugin ClsModule.forRoot({ global: true, middleware: { @@ -39,18 +38,14 @@ import { AppConstants } from './constants/app.constants'; }, plugins: [ new ClsPluginTransactional({ - connectionName: AppConstants.DB_CONNECTIONS.MONGOOSE, - imports: [ - // module in which the Connection instance is provided - MongooseModule, - ], + connectionName: ServerConstants.TransactionConnectionNames.Mongoose, + imports: [MongooseModule], adapter: new TransactionalAdapterMongoose({ - // the injection token of the mongoose Connection mongooseConnectionToken: getConnectionToken(), }), }), new ClsPluginTransactional({ - connectionName: AppConstants.DB_CONNECTIONS.PRISMA, + connectionName: ServerConstants.TransactionConnectionNames.Prisma, imports: [PrismaModule], adapter: new TransactionalAdapterPrisma({ prismaInjectionToken: PrismaService, diff --git a/apps/api/src/constants/app.constants.ts b/apps/api/src/constants/app.constants.ts deleted file mode 100644 index cb692c3..0000000 --- a/apps/api/src/constants/app.constants.ts +++ /dev/null @@ -1,11 +0,0 @@ -export class AppConstants { - static readonly DB_CONNECTIONS = { - MONGOOSE: 'MONGOOSE_CONNECTION', - PRISMA: 'PRISMA_CONNECTION', - } as const; - - static readonly REPOSITORIES = { - CRUD_MONGOOSE: Symbol('ICrudMongooseRepository'), - CRUD_PRISMA: Symbol('ICrudPrismaRepository'), - } as const; -} diff --git a/apps/api/src/constants/server.constants.ts b/apps/api/src/constants/server.constants.ts new file mode 100644 index 0000000..dd077d5 --- /dev/null +++ b/apps/api/src/constants/server.constants.ts @@ -0,0 +1,11 @@ +export class ServerConstants { + static readonly TransactionConnectionNames = { + Mongoose: 'MONGOOSE_CONNECTION', + Prisma: 'PRISMA_CONNECTION', + } as const; + + static readonly Repositories = { + MongooseCrudInterface: Symbol('ICrudMongooseRepository'), + PrismaCrudInterface: Symbol('ICrudPrismaRepository'), + } as const; +} diff --git a/apps/api/src/decorators/class/transactional.decorator.ts b/apps/api/src/decorators/class/transactional.decorator.ts index 0df2d74..0a86520 100644 --- a/apps/api/src/decorators/class/transactional.decorator.ts +++ b/apps/api/src/decorators/class/transactional.decorator.ts @@ -4,36 +4,30 @@ import { NO_TRANSACTION_KEY } from '../constants'; import { mkdirSync, writeFileSync } from 'fs'; import { join } from 'path'; -// Logger utility for transaction validation class TransactionalLogger { - private processDir: string; - private processId: string; + private readonly processDir: string; constructor() { const baseLogDir = join(process.cwd(), 'tmp', 'transaction'); - // Create unique process ID using timestamp + random string const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const random = Math.random().toString(36).substring(2, 8); - this.processId = `${timestamp}_${random}`; + const processId = `${timestamp}_${random}`; - // Create unique directory for this process - this.processDir = join(baseLogDir, this.processId); + this.processDir = join(baseLogDir, processId); mkdirSync(this.processDir, { recursive: true }); - this.log('SESSION', 'Transaction validation session started'); - this.log('SESSION', `Process ID: ${this.processId}`); - this.log('SESSION', `Log directory: ${this.processDir}`); + this.log('session', 'Transaction validation session started'); + this.log('session', `Process ID: ${processId}`); + this.log('session', `Log directory: ${this.processDir}`); } log(className: string, message: string): void { const timestamp = new Date().toISOString(); const logMessage = `[${timestamp}] ${message}`; - // Use a single log file per class (without timestamp in filename) const classLogFile = join(this.processDir, `${className}.log`); - // Append to class-specific file writeFileSync(classLogFile, logMessage + '\n', { flag: 'a', encoding: 'utf-8', @@ -45,10 +39,6 @@ class TransactionalLogger { getProcessDir(): string { return this.processDir; } - - getProcessId(): string { - return this.processId; - } } const logger = new TransactionalLogger(); @@ -64,24 +54,20 @@ function validateTransactionalApplication( originalDescriptor: PropertyDescriptor, mutatedDescriptor: PropertyDescriptor, ): PropertyDescriptor { - const descriptorToValidate = mutatedDescriptor; - - // Validate that the descriptor has required properties - if (!descriptorToValidate.value && !descriptorToValidate.get) { + if (!mutatedDescriptor.value && !mutatedDescriptor.get) { throw new Error( `@Transactional failed to apply on ${className}.${methodName}: ` + `Descriptor has no value or getter after decoration`, ); } - // Validate that the method is still callable if ( - descriptorToValidate.value && - typeof descriptorToValidate.value !== 'function' + mutatedDescriptor.value && + typeof mutatedDescriptor.value !== 'function' ) { throw new Error( `@Transactional failed to apply on ${className}.${methodName}: ` + - `Descriptor value is not a function (got ${typeof descriptorToValidate.value})`, + `Descriptor value is not a function (got ${typeof mutatedDescriptor.value})`, ); } @@ -91,64 +77,31 @@ function validateTransactionalApplication( const originalFunction = originalDescriptor.value as | ((...args: unknown[]) => unknown) | undefined; - const finalFunction = descriptorToValidate.value as + const finalFunction = mutatedDescriptor.value as | ((...args: unknown[]) => unknown) | undefined; - if (originalFunction && finalFunction) { - // Check if the function reference changed (indicates wrapping occurred) - const functionWasWrapped = originalFunction !== finalFunction; - - // Check if the function has metadata added by ClsTransactional - // The @nestjs-cls/transactional library may add metadata or wrap the function - const hasClsMetadata = - Reflect.hasMetadata('__cls_transactional__', finalFunction as object) || - Reflect.getMetadataKeys(finalFunction as object).some((key) => - String(key).includes('transactional'), - ); - - // Check if function name or properties suggest wrapping - const originalName = originalFunction.name || ''; - const finalName = finalFunction.name || ''; - const functionNameSuggestsWrapping = - finalName.includes('wrapped') || - finalName.includes('proxy') || - finalName === '' || // Arrow functions used in wrapping - finalName !== originalName; - - // At least ONE of these should be true if decoration was successful - if ( - !functionWasWrapped && - !hasClsMetadata && - !functionNameSuggestsWrapping - ) { - throw new Error( - `@Transactional may not have been applied on ${className}.${methodName}: ` + - `Function reference unchanged and no transactional metadata detected. ` + - `Original: ${originalName}, Final: ${finalName}`, - ); - } - - // Log what we detected for debugging - if (functionWasWrapped) { - logger.log( - className, - `✓ ${methodName}: Function reference changed (wrapped)`, - ); - } else if (hasClsMetadata) { - logger.log(className, `✓ ${methodName}: CLS metadata detected`); - } else if (functionNameSuggestsWrapping) { - logger.log( - className, - `✓ ${methodName}: Function name changed (${originalName} → ${finalName})`, - ); - } + if ( + !originalFunction || + !finalFunction || + originalFunction === finalFunction + ) { + throw new Error( + `@Transactional have not been been applied on ${className}.${methodName}: ` + + `Function reference unchanged or function(s) undefined.` + + `Original: ${originalFunction?.name}, Final: ${finalFunction?.name}`, + ); + } else { + logger.log( + className, + `✓ ${methodName}: Function reference changed (wrapped)`, + ); } // Validate that writable/configurable flags are appropriate if ( - descriptorToValidate.value && - descriptorToValidate.writable === false && + mutatedDescriptor.value && + mutatedDescriptor.writable === false && originalDescriptor.writable === true ) { throw new Error( @@ -157,7 +110,7 @@ function validateTransactionalApplication( ); } - return descriptorToValidate; + return mutatedDescriptor; } /** diff --git a/apps/api/src/decorators/method/no-transaction.decorator.ts b/apps/api/src/decorators/method/no-transaction.decorator.ts index defe48d..8816b8f 100644 --- a/apps/api/src/decorators/method/no-transaction.decorator.ts +++ b/apps/api/src/decorators/method/no-transaction.decorator.ts @@ -2,4 +2,8 @@ import { SetMetadata } from '@nestjs/common'; import { NO_TRANSACTION_KEY } from '../constants'; // Decorator to indicate that a method should not be wrapped in a database transaction -export const NoTransaction = () => SetMetadata(NO_TRANSACTION_KEY, true); +export const NoTransaction = (reasonToDisable: string) => + SetMetadata(NO_TRANSACTION_KEY, { + transactionDisabled: true, + disableReason: reasonToDisable, + }); diff --git a/apps/api/src/modules/crud/crud.module.ts b/apps/api/src/modules/crud/crud.module.ts index 83809bb..fc0b0e0 100644 --- a/apps/api/src/modules/crud/crud.module.ts +++ b/apps/api/src/modules/crud/crud.module.ts @@ -10,7 +10,7 @@ import { CrudMongooseRepository } from './repositories/mongoose/crud.mongoose-re import { CrudPrismaRepository } from './repositories/prisma/crud.prisma-repository'; import { CrudMongooseService } from './services/crud.mongoose.service'; import { CrudPrismaService } from './services/crud.prisma.service'; -import { AppConstants } from '../../constants/app.constants'; +import { ServerConstants } from '../../constants/server.constants'; @Module({ imports: [ @@ -21,22 +21,17 @@ import { AppConstants } from '../../constants/app.constants'; ], providers: [ { - provide: AppConstants.REPOSITORIES.CRUD_MONGOOSE, + provide: ServerConstants.Repositories.MongooseCrudInterface, useClass: CrudMongooseRepository, }, { - provide: AppConstants.REPOSITORIES.CRUD_PRISMA, + provide: ServerConstants.Repositories.PrismaCrudInterface, useClass: CrudPrismaRepository, }, CrudMongooseService, CrudPrismaService, CrudRouter, ], - exports: [ - AppConstants.REPOSITORIES.CRUD_MONGOOSE, - AppConstants.REPOSITORIES.CRUD_PRISMA, - CrudMongooseService, - CrudPrismaService, - ], + exports: [CrudMongooseService, CrudPrismaService], }) export class CrudModule {} diff --git a/apps/api/src/modules/crud/repositories/mongoose/crud.mongoose-entity.ts b/apps/api/src/modules/crud/repositories/mongoose/crud.mongoose-entity.ts index fb1a6a2..21f1247 100644 --- a/apps/api/src/modules/crud/repositories/mongoose/crud.mongoose-entity.ts +++ b/apps/api/src/modules/crud/repositories/mongoose/crud.mongoose-entity.ts @@ -7,4 +7,5 @@ export class CrudMongooseEntity extends MongooseBaseEntity { content: string; } -export const CrudMongooseSchema = SchemaFactory.createForClass(CrudMongooseEntity); +export const CrudMongooseSchema = + SchemaFactory.createForClass(CrudMongooseEntity); diff --git a/apps/api/src/modules/crud/repositories/mongoose/crud.mongoose-repository.ts b/apps/api/src/modules/crud/repositories/mongoose/crud.mongoose-repository.ts index 4e6bbbf..77b4bea 100644 --- a/apps/api/src/modules/crud/repositories/mongoose/crud.mongoose-repository.ts +++ b/apps/api/src/modules/crud/repositories/mongoose/crud.mongoose-repository.ts @@ -10,7 +10,7 @@ import { MongooseBaseRepository } from '../../../../repositories/mongoose/mongoo import { CrudMongooseEntity } from './crud.mongoose-entity'; import { Crud } from '../../schemas/crud.schema'; import { IMongooseRepository } from '../../../../repositories/mongoose/mongoose.repository.interface'; -import { AppConstants } from '../../../../constants/app.constants'; +import { ServerConstants } from '../../../../constants/server.constants'; @Injectable() export class CrudMongooseRepository extends MongooseBaseRepository< @@ -20,7 +20,7 @@ export class CrudMongooseRepository extends MongooseBaseRepository< constructor( @InjectModel(CrudMongooseEntity.name) crudModel: Model, - @InjectTransactionHost(AppConstants.DB_CONNECTIONS.MONGOOSE) + @InjectTransactionHost(ServerConstants.TransactionConnectionNames.Mongoose) mongoTxHost: TransactionHost, ) { super(crudModel, mongoTxHost); diff --git a/apps/api/src/modules/crud/repositories/prisma/crud.prisma-repository.ts b/apps/api/src/modules/crud/repositories/prisma/crud.prisma-repository.ts index 792f606..c6f0796 100644 --- a/apps/api/src/modules/crud/repositories/prisma/crud.prisma-repository.ts +++ b/apps/api/src/modules/crud/repositories/prisma/crud.prisma-repository.ts @@ -6,12 +6,12 @@ import { import { Prisma } from '@repo/prisma-db'; import { ICrudPrismaRepository } from './crud.prisma-repository.interface'; import { PrismaTransactionAdapter } from '../../../prisma/prisma.module'; -import { AppConstants } from '../../../../constants/app.constants'; +import { ServerConstants } from '../../../../constants/server.constants'; @Injectable() export class CrudPrismaRepository implements ICrudPrismaRepository { constructor( - @InjectTransactionHost(AppConstants.DB_CONNECTIONS.PRISMA) + @InjectTransactionHost(ServerConstants.TransactionConnectionNames.Prisma) protected readonly prismaTxHost: TransactionHost, ) {} diff --git a/apps/api/src/modules/crud/schemas/crud.schema.ts b/apps/api/src/modules/crud/schemas/crud.schema.ts index e2e7021..b5a7ab0 100644 --- a/apps/api/src/modules/crud/schemas/crud.schema.ts +++ b/apps/api/src/modules/crud/schemas/crud.schema.ts @@ -1,11 +1,11 @@ import { z } from 'zod'; import { - BaseEntity, + ZBaseEntity, ZBaseRequest, ZBaseResponse, } from '../../../schemas/base.schema'; -export const ZCrud = BaseEntity.extend({ +export const ZCrud = ZBaseEntity.extend({ content: z.string().min(1).max(1000), }); diff --git a/apps/api/src/modules/crud/services/crud.mongoose.service.ts b/apps/api/src/modules/crud/services/crud.mongoose.service.ts index 8513dfb..ddafad6 100644 --- a/apps/api/src/modules/crud/services/crud.mongoose.service.ts +++ b/apps/api/src/modules/crud/services/crud.mongoose.service.ts @@ -7,15 +7,15 @@ import { import { Crud } from '../schemas/crud.schema'; import { NoTransaction } from '../../../decorators/method/no-transaction.decorator'; import { Transactional } from '../../../decorators/class/transactional.decorator'; -import { AppConstants } from '../../../constants/app.constants'; +import { ServerConstants } from '../../../constants/server.constants'; import { ICrudMongooseRepository } from '../repositories/mongoose/crud.mongoose-repository'; import { Logger, StringExtensions } from '@repo/utils-core'; @Injectable() -@Transactional(AppConstants.DB_CONNECTIONS.MONGOOSE) +@Transactional(ServerConstants.TransactionConnectionNames.Mongoose) export class CrudMongooseService { constructor( - @Inject(AppConstants.REPOSITORIES.CRUD_MONGOOSE) + @Inject(ServerConstants.Repositories.MongooseCrudInterface) private readonly crudRepository: ICrudMongooseRepository, ) {} @@ -28,17 +28,17 @@ export class CrudMongooseService { content: data.content, }); - Logger.instance.info('[Mongoose] Created:', created); + Logger.instance.debug('[Mongoose] Created:', created); return created; } - @NoTransaction() + @NoTransaction('No Reason, Testing if skipping transaction works') async findAll(): Promise { return this.crudRepository.find(); } - @NoTransaction() + @NoTransaction('dont care if transaction is broken') async findOne(id: string): Promise { const crud = await this.crudRepository.findById(id); if (!crud) throw new NotFoundException(`Crud with id ${id} not found`); @@ -56,7 +56,7 @@ export class CrudMongooseService { async delete(id: string): Promise { const deleted = await this.crudRepository.findByIdAndDelete(id); if (!deleted) throw new NotFoundException(`Crud with id ${id} not found`); - Logger.instance.info('[Mongoose] Deleted:', deleted); + Logger.instance.debug('[Mongoose] Deleted:', deleted); return deleted; } } diff --git a/apps/api/src/modules/crud/services/crud.prisma.service.ts b/apps/api/src/modules/crud/services/crud.prisma.service.ts index a4ed2c1..ea4b02f 100644 --- a/apps/api/src/modules/crud/services/crud.prisma.service.ts +++ b/apps/api/src/modules/crud/services/crud.prisma.service.ts @@ -2,15 +2,15 @@ import { Inject, Injectable, NotFoundException } from '@nestjs/common'; import { Crud } from '../schemas/crud.schema'; import { NoTransaction } from '../../../decorators/method/no-transaction.decorator'; import { Transactional } from '../../../decorators/class/transactional.decorator'; -import { AppConstants } from '../../../constants/app.constants'; +import { ServerConstants } from '../../../constants/server.constants'; import { ICrudPrismaRepository } from '../repositories/prisma/crud.prisma-repository'; import { Logger } from '@repo/utils-core'; @Injectable() -@Transactional(AppConstants.DB_CONNECTIONS.PRISMA) +@Transactional(ServerConstants.TransactionConnectionNames.Prisma) export class CrudPrismaService { constructor( - @Inject(AppConstants.REPOSITORIES.CRUD_PRISMA) + @Inject(ServerConstants.Repositories.PrismaCrudInterface) private readonly crudRepository: ICrudPrismaRepository, ) {} @@ -21,16 +21,16 @@ export class CrudPrismaService { }, }); - Logger.instance.info('[Prisma] Created:', created); + Logger.instance.debug('[Prisma] Created:', created); return created; } - @NoTransaction() + @NoTransaction('No Reason, Testing if skipping transaction works') async findAll(): Promise { return await this.crudRepository.findMany(); } - @NoTransaction() + @NoTransaction('dont care if transaction is broken') async findOne(id: string): Promise { return this.crudRepository.findUnique({ where: { id }, @@ -52,7 +52,7 @@ export class CrudPrismaService { }); if (!deleted) throw new NotFoundException(`Crud with id ${id} not found`); - Logger.instance.info('[Prisma] Deleted:', deleted); + Logger.instance.debug('[Prisma] Deleted:', deleted); return deleted; } } diff --git a/apps/api/src/repositories/mongoose/mongoose.base-repository.ts b/apps/api/src/repositories/mongoose/mongoose.base-repository.ts index 431892a..119cbb3 100644 --- a/apps/api/src/repositories/mongoose/mongoose.base-repository.ts +++ b/apps/api/src/repositories/mongoose/mongoose.base-repository.ts @@ -7,13 +7,13 @@ import { QueryOptions, UpdateQuery, } from 'mongoose'; -import { Entity } from '../../schemas/base.schema'; +import { BaseEntity } from '../../schemas/base.schema'; import { TransactionHost } from '@nestjs-cls/transactional'; import { TransactionalAdapterMongoose } from '@nestjs-cls/transactional-adapter-mongoose'; import { MongooseBaseEntity } from './mongoose.base-entity'; export abstract class MongooseBaseRepository< - TDomainEntity extends Entity, + TDomainEntity extends BaseEntity, TDbEntity extends MongooseBaseEntity, > implements IMongooseRepository { diff --git a/apps/api/src/repositories/mongoose/mongoose.repository.interface.ts b/apps/api/src/repositories/mongoose/mongoose.repository.interface.ts index b9daa1c..2fbe61c 100644 --- a/apps/api/src/repositories/mongoose/mongoose.repository.interface.ts +++ b/apps/api/src/repositories/mongoose/mongoose.repository.interface.ts @@ -6,10 +6,10 @@ import { UpdateQuery, } from 'mongoose'; import { MongooseBaseEntity } from './mongoose.base-entity'; -import { Entity } from '../../schemas/base.schema'; +import { BaseEntity } from '../../schemas/base.schema'; export interface IMongooseRepository< - TDomainEntity extends Entity, + TDomainEntity extends BaseEntity, TDbEntity extends MongooseBaseEntity, > { create(entity: Partial): Promise; diff --git a/apps/api/src/schemas/base.schema.ts b/apps/api/src/schemas/base.schema.ts index 855f4fd..17d7797 100644 --- a/apps/api/src/schemas/base.schema.ts +++ b/apps/api/src/schemas/base.schema.ts @@ -10,7 +10,7 @@ export const ZBaseResponse = z.object({ message: z.string().optional(), }); -export const BaseEntity = z.object({ +export const ZBaseEntity = z.object({ id: z.string(), createdAt: z.date(), updatedAt: z.date(), @@ -18,4 +18,4 @@ export const BaseEntity = z.object({ export type TBaseRequest = z.infer; export type TBaseResponse = z.infer; -export type Entity = z.infer; +export type BaseEntity = z.infer; diff --git a/apps/web/app/crud-demo/page.tsx b/apps/web/app/crud-demo/page.tsx index 75108b6..c1cfa05 100644 --- a/apps/web/app/crud-demo/page.tsx +++ b/apps/web/app/crud-demo/page.tsx @@ -25,10 +25,14 @@ function CrudPanel({ dbType }: { dbType: DbType }) { const buttonColors = isMongoose ? "from-green-500 to-green-600 hover:from-green-600 hover:to-green-700" : "from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700"; - const hoverColor = isMongoose ? "hover:text-green-400" : "hover:text-blue-400"; + const hoverColor = isMongoose + ? "hover:text-green-400" + : "hover:text-blue-400"; // Queries - dynamically choose endpoint - const crudList = trpc.crud[isMongoose ? "findAllMongo" : "findAllPrisma"].useQuery( + const crudList = trpc.crud[ + isMongoose ? "findAllMongo" : "findAllPrisma" + ].useQuery( {}, { refetchOnWindowFocus: false, @@ -36,20 +40,31 @@ function CrudPanel({ dbType }: { dbType: DbType }) { ); // Mutations - const createCrud = trpc.crud[isMongoose ? "createCrudMongo" : "createCrudPrisma"].useMutation({ + const createCrud = trpc.crud[ + isMongoose ? "createCrudMongo" : "createCrudPrisma" + ].useMutation({ onSuccess: () => { - void utils.crud[isMongoose ? "findAllMongo" : "findAllPrisma"].invalidate(); + void utils.crud[ + isMongoose ? "findAllMongo" : "findAllPrisma" + ].invalidate(); setContent(""); }, }); - const deleteCrud = trpc.crud[isMongoose ? "deleteCrudMongo" : "deleteCrudPrisma"].useMutation({ - onSuccess: () => utils.crud[isMongoose ? "findAllMongo" : "findAllPrisma"].invalidate(), + const deleteCrud = trpc.crud[ + isMongoose ? "deleteCrudMongo" : "deleteCrudPrisma" + ].useMutation({ + onSuccess: () => + utils.crud[isMongoose ? "findAllMongo" : "findAllPrisma"].invalidate(), }); - const updateCrud = trpc.crud[isMongoose ? "updateCrudMongo" : "updateCrudPrisma"].useMutation({ + const updateCrud = trpc.crud[ + isMongoose ? "updateCrudMongo" : "updateCrudPrisma" + ].useMutation({ onSuccess: () => { - void utils.crud[isMongoose ? "findAllMongo" : "findAllPrisma"].invalidate(); + void utils.crud[ + isMongoose ? "findAllMongo" : "findAllPrisma" + ].invalidate(); setEditingId(null); setEditingContent(""); }, @@ -190,11 +205,6 @@ function CrudPanel({ dbType }: { dbType: DbType }) { onChange={(e) => setContent(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleCreate()} disabled={createCrud.isPending} - style={{ - focusRing: isMongoose - ? "focus:ring-green-400" - : "focus:ring-blue-400", - }} />