Responsible treasurers! Consul Caesar.js has a very important lesson for you. In the world of legionaries, safe transactions are paramount - whether it's exchanging tributes, transferring between forts, or dividing spoils. Today you'll learn how to ensure that every data operation either succeeds completely or is entirely rolled back!
Imagine this situation: legionary Marcus wants to pass his tribute to legionary Gaius. This must happen atomically - either the entire operation succeeds (Marcus loses the tribute, Gaius gains it, counters are updated), or nothing happens. It cannot be that Marcus loses the tribute but Gaius doesn't receive it!
Transactions ensure ACID properties:
1// tribute-transfer.service.ts
2import { Injectable } from '@nestjs/common';
3import { InjectRepository } from '@nestjs/typeorm';
4import { Repository, DataSource } from 'typeorm';
5import { Tribute } from './tribute.entity';
6import { Legionary } from './legionaries.entity';
7
8@Injectable()
9export class TributeTransferService {
10 constructor(
11 @InjectRepository(Tribute)
12 private tributeRepository: Repository<Tribute>,
13 @InjectRepository(Legionary)
14 private legionariesRepository: Repository<Legionary>,
15 private dataSource: DataSource
16 ) {}
17
18 // Basic tribute transfer
19 async transferTribute(
20 tributeId: number,
21 fromLegionaryId: number,
22 toLegionaryId: number
23 ): Promise<void> {
24 await this.dataSource.transaction(async manager => {
25 // 1. Find the tribute and check the owner
26 const tribute = await manager.findOne(Tribute, {
27 where: { id: tributeId, legionaries: { id: fromLegionaryId } }
28 });
29
30 if (!tribute) {
31 throw new Error('Tribute not found or not owned by sender');
32 }
33
34 // 2. Find the recipient
35 const recipient = await manager.findOne(Legionary, {
36 where: { id: toLegionaryId }
37 });
38
39 if (!recipient) {
40 throw new Error('Recipient legionaries not found');
41 }
42
43 // 3. Update tribute owner
44 tribute.legionaries = recipient;
45 await manager.save(tribute);
46
47 // 4. Update sender counters
48 await manager.decrement(
49 Legionary,
50 { id: fromLegionaryId },
51 'tributeCount',
52 1
53 );
54 await manager.decrement(
55 Legionary,
56 { id: fromLegionaryId },
57 'totalTributeValue',
58 tribute.value
59 );
60
61 // 5. Update recipient counters
62 await manager.increment(
63 Legionary,
64 { id: toLegionaryId },
65 'tributeCount',
66 1
67 );
68 await manager.increment(
69 Legionary,
70 { id: toLegionaryId },
71 'totalTributeValue',
72 tribute.value
73 );
74 });
75 }
76}1// Complex tribute exchange
2async tradeTributes(
3 legionaries1Id: number,
4 tribute1Id: number,
5 legionaries2Id: number,
6 tribute2Id: number
7): Promise<{ success: boolean; message: string }> {
8 return await this.dataSource.transaction(async manager => {
9 try {
10 // 1. Fetch both tributes with owners
11 const [tribute1, tribute2] = await Promise.all([
12 manager.findOne(Tribute, {
13 where: { id: tribute1Id },
14 relations: ['legionaries']
15 }),
16 manager.findOne(Tribute, {
17 where: { id: tribute2Id },
18 relations: ['legionaries']
19 })
20 ]);
21
22 // 2. Validation
23 if (!tribute1 || !tribute2) {
24 throw new Error('One or both tributes not found');
25 }
26
27 if (tribute1.legionaries.id !== legionaries1Id) {
28 throw new Error('Legionary 1 does not own tribute 1');
29 }
30
31 if (tribute2.legionaries.id !== legionaries2Id) {
32 throw new Error('Legionary 2 does not own tribute 2');
33 }
34
35 if (tribute1.isCursed || tribute2.isCursed) {
36 throw new Error('Cannot trade cursed tributes');
37 }
38
39 // 3. Check value equivalence (max 20% difference)
40 const valueDifference = Math.abs(tribute1.value - tribute2.value);
41 const maxDifference = Math.max(tribute1.value, tribute2.value) * 0.2;
42
43 if (valueDifference > maxDifference) {
44 throw new Error('Tribute values too different for fair trade');
45 }
46
47 // 4. Execute the exchange
48 const originalOwner1 = tribute1.legionaries;
49 const originalOwner2 = tribute2.legionaries;
50
51 tribute1.legionaries = originalOwner2;
52 tribute2.legionaries = originalOwner1;
53
54 await Promise.all([
55 manager.save(tribute1),
56 manager.save(tribute2)
57 ]);
58
59 // 5. Update statistics (values compensate, so just swap)
60 const valueDiff = tribute1.value - tribute2.value;
61
62 if (valueDiff !== 0) {
63 // If values are different, update total values
64 await manager.increment(
65 Legionary,
66 { id: legionaries1Id },
67 'totalTributeValue',
68 tribute2.value - tribute1.value
69 );
70 await manager.increment(
71 Legionary,
72 { id: legionaries2Id },
73 'totalTributeValue',
74 tribute1.value - tribute2.value
75 );
76 }
77
78 // 6. Save transaction log
79 await manager.save(TributeTransaction, {
80 type: 'TRADE',
81 fromLegionary: originalOwner1,
82 toLegionary: originalOwner2,
83 tribute1: tribute1,
84 tribute2: tribute2,
85 executedAt: new Date()
86 });
87
88 return {
89 success: true,
90 message: `Successfully traded ${tribute1.name} and ${tribute2.name}`
91 };
92 } catch (error) {
93 // Transaction will be automatically rolled back
94 return {
95 success: false,
96 message: error.message
97 };
98 }
99 });
100}1// Different isolation levels
2import { IsolationLevel } from 'typeorm';
3
4// READ UNCOMMITTED - fastest, but may read uncommitted data
5async quickTributeCount(): Promise<number> {
6 return await this.dataSource.transaction(
7 async manager => {
8 const result = await manager
9 .createQueryBuilder(Tribute, 'tribute')
10 .select('COUNT(*)', 'count')
11 .getRawOne();
12 return parseInt(result.count);
13 },
14 { isolationLevel: 'READ uncommitted' }
15 );
16}
17
18// READ COMMITTED - standard level
19async safeTransferWithAudit(
20 tributeId: number,
21 fromLegionaryId: number,
22 toLegionaryId: number
23): Promise<void> {
24 await this.dataSource.transaction(
25 async manager => {
26 // Transactional operations...
27 await this.performTransfer(manager, tributeId, fromLegionaryId, toLegionaryId);
28 await this.createAuditLog(manager, tributeId, fromLegionaryId, toLegionaryId);
29 },
30 { isolationLevel: 'read committed' }
31 );
32}
33
34// REPEATABLE READ - ensures repeated reads give the same results
35async calculateTributeStatistics(): Promise<any> {
36 return await this.dataSource.transaction(
37 async manager => {
38 // First read
39 const firstCount = await manager.count(Tribute);
40
41 // Some other operation...
42 await this.performSomeOtherOperation(manager);
43
44 // Second read - guarantees the same result as the first
45 const secondCount = await manager.count(Tribute);
46
47 return { firstCount, secondCount };
48 },
49 { isolationLevel: 'repeatable read' }
50 );
51}
52
53// SERIALIZABLE - strictest level
54async criticalTributeOperation(): Promise<void> {
55 await this.dataSource.transaction(
56 async manager => {
57 // Critical operations requiring full isolation
58 await this.performCriticalOperation(manager);
59 },
60 { isolationLevel: 'serializable' }
61 );
62}1// Complex operation with savepoints
2async complexLegionaryOperation(
3 legionariesId: number,
4 operations: any[]
5): Promise<{ completed: number; failed: number; errors: string[] }> {
6 let completed = 0;
7 let failed = 0;
8 const errors: string[] = [];
9
10 await this.dataSource.transaction(async manager => {
11 for (let i = 0; i < operations.length; i++) {
12 const savepoint = `operation_${i}`1
2 try {
3 // Create savepoint
4 await manager.query(`SAVEPOINT ${savepoint}`);
5
6 // Execute operation
7 await this.executeOperation(manager, legionariesId, operations[i]);
8
9 // If successful, release savepoint
10 await manager.query(`RELEASE SAVEPOINT ${savepoint}`);
11 completed++;
12
13 } catch (error) {
14 // If error, roll back to savepoint
15 await manager.query(`ROLLBACK TO SAVEPOINT ${savepoint}`);
16 failed++;
17 errors.push(`Operation ${i}: ${error.message}`);
18 }
19 }
20 });
21
22 return { completed, failed, errors };
23}
24
25// Helper method executing various operations
26private async executeOperation(
27 manager: EntityManager,
28 legionariesId: number,
29 operation: any
30): Promise<void> {
31 switch (operation.type) {
32 case 'ADD_TRIBUTE':
33 await this.addTributeToLegionary(manager, legionariesId, operation.tribute);
34 break;
35 case 'UPDATE_RANK':
36 await this.updateLegionaryRank(manager, legionariesId, operation.newRank);
37 break;
38 case 'JOIN_LEGION':
39 await this.assignLegionaryToLegion(manager, legionariesId, operation.centurionId);
40 break;
41 default:
42 throw new Error(`Unknown operation type: ${operation.type}`);
43 }
44}1// Transaction with time limit
2async timeoutTransaction<T>(
3 operation: (manager: EntityManager) => Promise<T>,
4 timeoutMs: number = 30000
5): Promise<T> {
6 return await Promise.race([
7 this.dataSource.transaction(operation),
8 new Promise<never>((_, reject) => {
9 setTimeout(() => {
10 reject(new Error(`Transaction timeout after ${timeoutMs}ms`));
11 }, timeoutMs);
12 })
13 ]);
14}
15
16// Usage with timeout
17async massiveTributeUpdate(): Promise<void> {
18 try {
19 await this.timeoutTransaction(async manager => {
20 // Operation that may take long
21 const tributes = await manager.find(Tribute);
22
23 for (const tribute of tributes) {
24 tribute.value = tribute.value * 1.1; // 10% value increase
25 await manager.save(tribute);
26 }
27 }, 60000); // 1 minute timeout
28 } catch (error) {
29 if (error.message.includes('timeout')) {
30 console.log('Operation was cancelled due to timeout');
31 } else {
32 console.log('Operation failed:', error.message);
33 }
34 }
35}1// Automatic retrying of failed transactions
2async retryableTransaction<T>(
3 operation: (manager: EntityManager) => Promise<T>,
4 maxRetries: number = 3,
5 delayMs: number = 1000
6): Promise<T> {
7 let lastError: Error;
8
9 for (let attempt = 1; attempt <= maxRetries; attempt++) {
10 try {
11 return await this.dataSource.transaction(operation);
12 } catch (error) {
13 lastError = error;
14
15 // Check if the error qualifies for retry
16 if (this.isRetryableError(error) && attempt < maxRetries) {
17 console.log(`Transaction attempt ${attempt} failed, retrying in ${delayMs}ms...`);
18 await this.delay(delayMs);
19 delayMs *= 2; // Exponential backoff
20 } else {
21 break;
22 }
23 }
24 }
25
26 throw lastError;
27}
28
29private isRetryableError(error: Error): boolean {
30 // Errors that can be retried
31 const retryableMessages = [
32 'deadlock detected',
33 'connection lost',
34 'timeout',
35 'serialization failure'
36 ];
37
38 return retryableMessages.some(msg =>
39 error.message.toLowerCase().includes(msg)
40 );
41}
42
43private delay(ms: number): Promise<void> {
44 return new Promise(resolve => setTimeout(resolve, ms));
45}
46
47// Using the retry mechanism
48async safeTributeTransfer(
49 tributeId: number,
50 fromLegionaryId: number,
51 toLegionaryId: number
52): Promise<void> {
53 await this.retryableTransaction(async manager => {
54 await this.performTransferOperation(manager, tributeId, fromLegionaryId, toLegionaryId);
55 });
56}1// Transaction monitoring service
2@Injectable()
3export class TransactionMonitoringService {
4 private activeTransactions = new Map<string, {
5 startTime: Date;
6 operation: string;
7 legionariesId?: number;
8 }>();
9
10 async monitoredTransaction<T>(
11 operation: (manager: EntityManager) => Promise<T>,
12 operationName: string,
13 legionariesId?: number
14 ): Promise<T> {
15 const transactionId = this.generateTransactionId();
16 const startTime = new Date();
17
18 // Register transaction start
19 this.activeTransactions.set(transactionId, {
20 startTime,
21 operation: operationName,
22 legionariesId
23 });
24
25 console.log(`[${transactionId}] Starting transaction: ${operationName}`);
26
27 try {
28 const result = await this.dataSource.transaction(async manager => {
29 // Add transaction ID to context
30 (manager as any).transactionId = transactionId;
31 return await operation(manager);
32 });
33
34 const duration = Date.now() - startTime.getTime();
35 console.log(`[${transactionId}] Transaction completed in ${duration}ms`);
36
37 return result;
38 } catch (error) {
39 const duration = Date.now() - startTime.getTime();
40 console.error(`[${transactionId}] Transaction failed after ${duration}ms:`, error.message);
41 throw error;
42 } finally {
43 this.activeTransactions.delete(transactionId);
44 }
45 }
46
47 private generateTransactionId(): string {
48 return `tx_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
49 }
50
51 // Check active transactions
52 getActiveTransactions() {
53 const now = new Date();
54 return Array.from(this.activeTransactions.entries()).map(([id, info]) => ({
55 id,
56 operation: info.operation,
57 legionariesId: info.legionariesId,
58 duration: now.getTime() - info.startTime.getTime(),
59 startTime: info.startTime
60 }));
61 }
62
63 // Find long-running transactions
64 getLongRunningTransactions(thresholdMs: number = 30000) {
65 return this.getActiveTransactions().filter(tx => tx.duration > thresholdMs);
66 }
67}1// tribute-transfer.service.spec.ts
2import { Test, TestingModule } from '@nestjs/testing';
3import { getRepositoryToken } from '@nestjs/typeorm';
4import { DataSource } from 'typeorm';
5import { TributeTransferService } from './tribute-transfer.service';
6
7describe('TributeTransferService', () => {
8 let service: TributeTransferService;
9 let mockDataSource: Partial<DataSource>;
10 let mockManager: any;
11
12 beforeEach(async () => {
13 mockManager = {
14 findOne: jest.fn(),
15 save: jest.fn(),
16 increment: jest.fn(),
17 decrement: jest.fn()
18 };
19
20 mockDataSource = {
21 transaction: jest.fn(async (callback) => {
22 return await callback(mockManager);
23 })
24 };
25
26 const module: TestingModule = await Test.createTestingModule({
27 providers: [
28 TributeTransferService,
29 { provide: DataSource, useValue: mockDataSource },
30 { provide: getRepositoryToken(Tribute), useValue: {} },
31 { provide: getRepositoryToken(Legionary), useValue: {} }
32 ]
33 }).compile();
34
35 service = module.get<TributeTransferService>(TributeTransferService);
36 });
37
38 describe('transferTribute', () => {
39 it('should transfer tribute successfully', async () => {
40 const mockTribute = {
41 id: 1,
42 value: 1000,
43 legionaries: { id: 1 }
44 };
45 const mockRecipient = { id: 2 };
46
47 mockManager.findOne
48 .mockResolvedValueOnce(mockTribute)
49 .mockResolvedValueOnce(mockRecipient);
50
51 await service.transferTribute(1, 1, 2);
52
53 expect(mockDataSource.transaction).toHaveBeenCalled();
54 expect(mockManager.save).toHaveBeenCalledWith({
55 ...mockTribute,
56 legionaries: mockRecipient
57 });
58 expect(mockManager.decrement).toHaveBeenCalledTimes(2);
59 expect(mockManager.increment).toHaveBeenCalledTimes(2);
60 });
61
62 it('should throw error if tribute not found', async () => {
63 mockManager.findOne.mockResolvedValue(null);
64
65 await expect(
66 service.transferTribute(1, 1, 2)
67 ).rejects.toThrow('Tribute not found or not owned by sender');
68 });
69 });
70});1// GOOD - short transaction
2async quickTransfer() {
3 await this.dataSource.transaction(async manager => {
4 // Only necessary operations
5 const tribute = await manager.findOne(Tribute, { where: { id: 1 } });
6 tribute.legionaries = newOwner;
7 await manager.save(tribute);
8 });
9}
10
11// BAD - long transaction
12async slowTransfer() {
13 await this.dataSource.transaction(async manager => {
14 // Long operations block the database
15 await this.sendEmail(); // NO!
16 await this.generateReport(); // NO!
17 await this.callExternalAPI(); // NO!
18 });
19}1// GOOD - validate first, then modify
2async properOrder() {
3 await this.dataSource.transaction(async manager => {
4 // 1. Check all conditions
5 const validationResult = await this.validateOperation(manager);
6 if (!validationResult.isValid) {
7 throw new Error(validationResult.error);
8 }
9
10 // 2. Execute operations
11 await this.performOperations(manager);
12 });
13}1// Always handle potential errors
2async safeOperation() {
3 try {
4 await this.dataSource.transaction(async manager => {
5 await this.riskyOperation(manager);
6 });
7 } catch (error) {
8 // Log error details
9 console.error('Transaction failed:', {
10 error: error.message,
11 stack: error.stack,
12 timestamp: new Date().toISOString()
13 });
14
15 // Throw appropriate exception for the client
16 throw new BadRequestException('Operation failed: ' + error.message);
17 }
18}Congratulations! Now you're a master of safe Roman transactions! You can:
✓ Implement atomic operations with TypeORM ✓ Use different isolation levels for transactions ✓ Handle nested transactions with savepoints ✓ Implement retry mechanisms for failed transactions ✓ Monitor and log transactions in the application
Now you can safely manage tributes without risk of losing them!