diff --git a/.changeset/transaction-system.md b/.changeset/transaction-system.md new file mode 100644 index 00000000..cb7dff56 --- /dev/null +++ b/.changeset/transaction-system.md @@ -0,0 +1,51 @@ +--- +"@esengine/transaction": minor +--- + +feat(transaction): 添加游戏事务系统 | add game transaction system + +**@esengine/transaction** - 游戏事务系统 | Game transaction system + +### 核心功能 | Core Features + +- **TransactionManager** - 事务生命周期管理 | Transaction lifecycle management + - begin()/run() 创建事务 | Create transactions + - 分布式锁支持 | Distributed lock support + - 自动恢复未完成事务 | Auto-recover pending transactions + +- **TransactionContext** - 事务上下文 | Transaction context + - 操作链式添加 | Chain operation additions + - 上下文数据共享 | Context data sharing + - 超时控制 | Timeout control + +- **Saga 模式** - 补偿式事务 | Compensating transactions + - execute/compensate 成对操作 | Paired execute/compensate + - 自动回滚失败事务 | Auto-rollback on failure + +### 存储实现 | Storage Implementations + +- **MemoryStorage** - 内存存储,用于开发测试 | In-memory for dev/testing +- **RedisStorage** - Redis 分布式锁和缓存 | Redis distributed lock & cache +- **MongoStorage** - MongoDB 持久化事务日志 | MongoDB persistent transaction logs + +### 内置操作 | Built-in Operations + +- **CurrencyOperation** - 货币增减操作 | Currency add/deduct +- **InventoryOperation** - 背包物品操作 | Inventory item operations +- **TradeOperation** - 玩家交易操作 | Player trade operations + +### 分布式事务 | Distributed Transactions + +- **SagaOrchestrator** - 跨服务器 Saga 编排 | Cross-server Saga orchestration +- 完整的 Saga 日志记录 | Complete Saga logging +- 未完成 Saga 恢复 | Incomplete Saga recovery + +### Room 集成 | Room Integration + +- **withTransactions()** - Room mixin 扩展 | Room mixin extension +- **TransactionRoom** - 预配置的事务 Room 基类 | Pre-configured transaction Room base + +### 文档 | Documentation + +- 完整的中英文文档 | Complete bilingual documentation +- 核心概念、存储层、操作、分布式事务 | Core concepts, storage, operations, distributed diff --git a/docs/src/content/docs/en/modules/index.md b/docs/src/content/docs/en/modules/index.md index 5a0523e4..2ac1c8b7 100644 --- a/docs/src/content/docs/en/modules/index.md +++ b/docs/src/content/docs/en/modules/index.md @@ -34,6 +34,7 @@ ESEngine provides a rich set of modules that can be imported as needed. | Module | Package | Description | |--------|---------|-------------| | [Network](/en/modules/network/) | `@esengine/network` | Multiplayer game networking | +| [Transaction](/en/modules/transaction/) | `@esengine/transaction` | Game transactions with distributed support | ## Installation diff --git a/docs/src/content/docs/en/modules/transaction/core.md b/docs/src/content/docs/en/modules/transaction/core.md new file mode 100644 index 00000000..a777d84d --- /dev/null +++ b/docs/src/content/docs/en/modules/transaction/core.md @@ -0,0 +1,261 @@ +--- +title: "Core Concepts" +description: "Transaction system core concepts: context, manager, and Saga pattern" +--- + +## Transaction State + +A transaction can be in the following states: + +```typescript +type TransactionState = + | 'pending' // Waiting to execute + | 'executing' // Executing + | 'committed' // Committed + | 'rolledback' // Rolled back + | 'failed' // Failed +``` + +## TransactionContext + +The transaction context encapsulates transaction state, operations, and execution logic. + +### Creating Transactions + +```typescript +import { TransactionManager } from '@esengine/transaction'; + +const manager = new TransactionManager(); + +// Method 1: Manual management with begin() +const tx = manager.begin({ timeout: 5000 }); +tx.addOperation(op1); +tx.addOperation(op2); +const result = await tx.execute(); + +// Method 2: Automatic management with run() +const result = await manager.run((tx) => { + tx.addOperation(op1); + tx.addOperation(op2); +}); +``` + +### Chaining Operations + +```typescript +const result = await manager.run((tx) => { + tx.addOperation(new CurrencyOperation({ ... })) + .addOperation(new InventoryOperation({ ... })) + .addOperation(new InventoryOperation({ ... })); +}); +``` + +### Context Data + +Operations can share data through the context: + +```typescript +class CustomOperation extends BaseOperation { + async execute(ctx: ITransactionContext): Promise> { + // Read data set by previous operations + const previousResult = ctx.get('previousValue'); + + // Set data for subsequent operations + ctx.set('myResult', { value: 123 }); + + return this.success({ ... }); + } +} +``` + +## TransactionManager + +The transaction manager is responsible for creating, executing, and recovering transactions. + +### Configuration Options + +```typescript +interface TransactionManagerConfig { + storage?: ITransactionStorage; // Storage instance + defaultTimeout?: number; // Default timeout (ms) + serverId?: string; // Server ID (for distributed) + autoRecover?: boolean; // Auto-recover pending transactions +} + +const manager = new TransactionManager({ + storage: new RedisStorage({ client: redis }), + defaultTimeout: 10000, + serverId: 'server-1', + autoRecover: true, +}); +``` + +### Distributed Locking + +```typescript +// Acquire lock +const token = await manager.acquireLock('player:123:inventory', 10000); + +if (token) { + try { + // Perform operations + await doSomething(); + } finally { + // Release lock + await manager.releaseLock('player:123:inventory', token); + } +} + +// Or use withLock for convenience +await manager.withLock('player:123:inventory', async () => { + await doSomething(); +}, 10000); +``` + +### Transaction Recovery + +Recover pending transactions after server restart: + +```typescript +const manager = new TransactionManager({ + storage: new RedisStorage({ client: redis }), + serverId: 'server-1', +}); + +// Recover pending transactions +const recoveredCount = await manager.recover(); +console.log(`Recovered ${recoveredCount} transactions`); +``` + +## Saga Pattern + +The transaction system uses the Saga pattern. Each operation must implement `execute` and `compensate` methods: + +```typescript +interface ITransactionOperation { + readonly name: string; + readonly data: TData; + + // Validate preconditions + validate(ctx: ITransactionContext): Promise; + + // Forward execution + execute(ctx: ITransactionContext): Promise>; + + // Compensate (rollback) + compensate(ctx: ITransactionContext): Promise; +} +``` + +### Execution Flow + +``` +Begin Transaction + │ + ▼ +┌─────────────────────┐ +│ validate(op1) │──fail──► Return failure +└─────────────────────┘ + │success + ▼ +┌─────────────────────┐ +│ execute(op1) │──fail──┐ +└─────────────────────┘ │ + │success │ + ▼ │ +┌─────────────────────┐ │ +│ validate(op2) │──fail──┤ +└─────────────────────┘ │ + │success │ + ▼ │ +┌─────────────────────┐ │ +│ execute(op2) │──fail──┤ +└─────────────────────┘ │ + │success ▼ + ▼ ┌─────────────────────┐ +Commit Transaction │ compensate(op1) │ + └─────────────────────┘ + │ + ▼ + Return failure (rolled back) +``` + +### Custom Operations + +```typescript +import { BaseOperation, ITransactionContext, OperationResult } from '@esengine/transaction'; + +interface UpgradeData { + playerId: string; + itemId: string; + targetLevel: number; +} + +interface UpgradeResult { + newLevel: number; +} + +class UpgradeOperation extends BaseOperation { + readonly name = 'upgrade'; + + private _previousLevel: number = 0; + + async validate(ctx: ITransactionContext): Promise { + // Validate item exists and can be upgraded + const item = await this.getItem(ctx); + return item !== null && item.level < this.data.targetLevel; + } + + async execute(ctx: ITransactionContext): Promise> { + const item = await this.getItem(ctx); + if (!item) { + return this.failure('Item not found', 'ITEM_NOT_FOUND'); + } + + this._previousLevel = item.level; + item.level = this.data.targetLevel; + await this.saveItem(ctx, item); + + return this.success({ newLevel: item.level }); + } + + async compensate(ctx: ITransactionContext): Promise { + const item = await this.getItem(ctx); + if (item) { + item.level = this._previousLevel; + await this.saveItem(ctx, item); + } + } + + private async getItem(ctx: ITransactionContext) { + // Get item from storage + } + + private async saveItem(ctx: ITransactionContext, item: any) { + // Save item to storage + } +} +``` + +## Transaction Result + +```typescript +interface TransactionResult { + success: boolean; // Whether succeeded + transactionId: string; // Transaction ID + results: OperationResult[]; // Operation results + data?: T; // Final data + error?: string; // Error message + duration: number; // Execution time (ms) +} + +const result = await manager.run((tx) => { ... }); + +console.log(`Transaction ${result.transactionId}`); +console.log(`Success: ${result.success}`); +console.log(`Duration: ${result.duration}ms`); + +if (!result.success) { + console.log(`Error: ${result.error}`); +} +``` diff --git a/docs/src/content/docs/en/modules/transaction/distributed.md b/docs/src/content/docs/en/modules/transaction/distributed.md new file mode 100644 index 00000000..f9308fa5 --- /dev/null +++ b/docs/src/content/docs/en/modules/transaction/distributed.md @@ -0,0 +1,355 @@ +--- +title: "Distributed Transactions" +description: "Saga orchestrator and cross-server transaction support" +--- + +## Saga Orchestrator + +`SagaOrchestrator` is used to orchestrate distributed transactions across servers. + +### Basic Usage + +```typescript +import { SagaOrchestrator, RedisStorage } from '@esengine/transaction'; + +const orchestrator = new SagaOrchestrator({ + storage: new RedisStorage({ client: redis }), + timeout: 30000, + serverId: 'orchestrator-1', +}); + +const result = await orchestrator.execute([ + { + name: 'deduct_currency', + serverId: 'game-server-1', + data: { playerId: 'player1', amount: 100 }, + execute: async (data) => { + // Call game server API to deduct currency + const response = await gameServerApi.deductCurrency(data); + return { success: response.ok }; + }, + compensate: async (data) => { + // Call game server API to restore currency + await gameServerApi.addCurrency(data); + }, + }, + { + name: 'add_item', + serverId: 'inventory-server-1', + data: { playerId: 'player1', itemId: 'sword' }, + execute: async (data) => { + const response = await inventoryServerApi.addItem(data); + return { success: response.ok }; + }, + compensate: async (data) => { + await inventoryServerApi.removeItem(data); + }, + }, +]); + +if (result.success) { + console.log('Saga completed successfully'); +} else { + console.log('Saga failed:', result.error); + console.log('Completed steps:', result.completedSteps); + console.log('Failed at:', result.failedStep); +} +``` + +### Configuration Options + +```typescript +interface SagaOrchestratorConfig { + storage?: ITransactionStorage; // Storage instance + timeout?: number; // Timeout in milliseconds + serverId?: string; // Orchestrator server ID +} +``` + +### Saga Step + +```typescript +interface SagaStep { + name: string; // Step name + serverId?: string; // Target server ID + data: T; // Step data + execute: (data: T) => Promise; // Execute function + compensate: (data: T) => Promise; // Compensate function +} +``` + +### Saga Result + +```typescript +interface SagaResult { + success: boolean; // Whether succeeded + sagaId: string; // Saga ID + completedSteps: string[]; // Completed steps + failedStep?: string; // Failed step + error?: string; // Error message + duration: number; // Execution time (ms) +} +``` + +## Execution Flow + +``` +Start Saga + │ + ▼ +┌─────────────────────┐ +│ Step 1: execute │──fail──┐ +└─────────────────────┘ │ + │success │ + ▼ │ +┌─────────────────────┐ │ +│ Step 2: execute │──fail──┤ +└─────────────────────┘ │ + │success │ + ▼ │ +┌─────────────────────┐ │ +│ Step 3: execute │──fail──┤ +└─────────────────────┘ │ + │success ▼ + ▼ ┌─────────────────────┐ +Saga Complete │ Step 2: compensate │ + └─────────────────────┘ + │ + ▼ + ┌─────────────────────┐ + │ Step 1: compensate │ + └─────────────────────┘ + │ + ▼ + Saga Failed (compensated) +``` + +## Saga Logs + +The orchestrator records detailed execution logs: + +```typescript +interface SagaLog { + id: string; // Saga ID + state: SagaLogState; // State + steps: SagaStepLog[]; // Step logs + createdAt: number; // Creation time + updatedAt: number; // Update time + metadata?: Record; +} + +type SagaLogState = + | 'pending' // Waiting to execute + | 'running' // Executing + | 'completed' // Completed + | 'compensating' // Compensating + | 'compensated' // Compensated + | 'failed' // Failed + +interface SagaStepLog { + name: string; // Step name + serverId?: string; // Server ID + state: SagaStepState; // State + startedAt?: number; // Start time + completedAt?: number; // Completion time + error?: string; // Error message +} + +type SagaStepState = + | 'pending' // Waiting to execute + | 'executing' // Executing + | 'completed' // Completed + | 'compensating' // Compensating + | 'compensated' // Compensated + | 'failed' // Failed +``` + +### Query Saga Logs + +```typescript +const log = await orchestrator.getSagaLog('saga_xxx'); + +if (log) { + console.log('Saga state:', log.state); + for (const step of log.steps) { + console.log(` ${step.name}: ${step.state}`); + } +} +``` + +## Cross-Server Transaction Examples + +### Scenario: Cross-Server Purchase + +A player purchases an item on a game server, with currency on an account server and items on an inventory server. + +```typescript +const orchestrator = new SagaOrchestrator({ + storage: redisStorage, + serverId: 'purchase-orchestrator', +}); + +async function crossServerPurchase( + playerId: string, + itemId: string, + price: number +): Promise { + return orchestrator.execute([ + // Step 1: Deduct balance on account server + { + name: 'deduct_balance', + serverId: 'account-server', + data: { playerId, amount: price }, + execute: async (data) => { + const result = await accountService.deduct(data.playerId, data.amount); + return { success: result.ok, error: result.error }; + }, + compensate: async (data) => { + await accountService.refund(data.playerId, data.amount); + }, + }, + + // Step 2: Add item on inventory server + { + name: 'add_item', + serverId: 'inventory-server', + data: { playerId, itemId }, + execute: async (data) => { + const result = await inventoryService.addItem(data.playerId, data.itemId); + return { success: result.ok, error: result.error }; + }, + compensate: async (data) => { + await inventoryService.removeItem(data.playerId, data.itemId); + }, + }, + + // Step 3: Record purchase log + { + name: 'log_purchase', + serverId: 'log-server', + data: { playerId, itemId, price, timestamp: Date.now() }, + execute: async (data) => { + await logService.recordPurchase(data); + return { success: true }; + }, + compensate: async (data) => { + await logService.cancelPurchase(data); + }, + }, + ]); +} +``` + +### Scenario: Cross-Server Trade + +Two players on different servers trade with each other. + +```typescript +async function crossServerTrade( + playerA: { id: string; server: string; items: string[] }, + playerB: { id: string; server: string; items: string[] } +): Promise { + const steps: SagaStep[] = []; + + // Remove items from player A + for (const itemId of playerA.items) { + steps.push({ + name: `remove_${playerA.id}_${itemId}`, + serverId: playerA.server, + data: { playerId: playerA.id, itemId }, + execute: async (data) => { + return await inventoryService.removeItem(data.playerId, data.itemId); + }, + compensate: async (data) => { + await inventoryService.addItem(data.playerId, data.itemId); + }, + }); + } + + // Add items to player B (from A) + for (const itemId of playerA.items) { + steps.push({ + name: `add_${playerB.id}_${itemId}`, + serverId: playerB.server, + data: { playerId: playerB.id, itemId }, + execute: async (data) => { + return await inventoryService.addItem(data.playerId, data.itemId); + }, + compensate: async (data) => { + await inventoryService.removeItem(data.playerId, data.itemId); + }, + }); + } + + // Similarly handle player B's items... + + return orchestrator.execute(steps); +} +``` + +## Recovering Incomplete Sagas + +Recover incomplete Sagas after server restart: + +```typescript +const orchestrator = new SagaOrchestrator({ + storage: redisStorage, + serverId: 'my-orchestrator', +}); + +// Recover incomplete Sagas (will execute compensation) +const recoveredCount = await orchestrator.recover(); +console.log(`Recovered ${recoveredCount} sagas`); +``` + +## Best Practices + +### 1. Idempotency + +Ensure all operations are idempotent: + +```typescript +{ + execute: async (data) => { + // Use unique ID to ensure idempotency + const result = await service.process(data.requestId, data); + return { success: result.ok }; + }, + compensate: async (data) => { + // Compensation must also be idempotent + await service.rollback(data.requestId); + }, +} +``` + +### 2. Timeout Handling + +Set appropriate timeout values: + +```typescript +const orchestrator = new SagaOrchestrator({ + timeout: 60000, // Cross-server operations need longer timeout +}); +``` + +### 3. Monitoring and Alerts + +Log Saga execution results: + +```typescript +const result = await orchestrator.execute(steps); + +if (!result.success) { + // Send alert + alertService.send({ + type: 'saga_failed', + sagaId: result.sagaId, + failedStep: result.failedStep, + error: result.error, + }); + + // Log details + const log = await orchestrator.getSagaLog(result.sagaId); + logger.error('Saga failed', { log }); +} +``` diff --git a/docs/src/content/docs/en/modules/transaction/index.md b/docs/src/content/docs/en/modules/transaction/index.md new file mode 100644 index 00000000..4b679e59 --- /dev/null +++ b/docs/src/content/docs/en/modules/transaction/index.md @@ -0,0 +1,238 @@ +--- +title: "Transaction System" +description: "Game transaction system with distributed support for shop purchases, player trading, and more" +--- + +`@esengine/transaction` provides comprehensive game transaction capabilities based on the Saga pattern, supporting shop purchases, player trading, multi-step tasks, and distributed transactions with Redis/MongoDB. + +## Overview + +The transaction system solves common data consistency problems in games: + +| Scenario | Problem | Solution | +|----------|---------|----------| +| Shop Purchase | Payment succeeded but item not delivered | Atomic transaction with auto-rollback | +| Player Trade | One party transferred items but other didn't receive | Saga compensation mechanism | +| Cross-Server | Data inconsistency across servers | Distributed lock + transaction log | + +## Installation + +```bash +npm install @esengine/transaction +``` + +Optional dependencies (install based on storage needs): +```bash +npm install ioredis # Redis storage +npm install mongodb # MongoDB storage +``` + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Transaction Layer │ +├─────────────────────────────────────────────────────────────────┤ +│ TransactionManager - Manages transaction lifecycle │ +│ TransactionContext - Encapsulates operations and state │ +│ SagaOrchestrator - Distributed Saga orchestrator │ +├─────────────────────────────────────────────────────────────────┤ +│ Storage Layer │ +├─────────────────────────────────────────────────────────────────┤ +│ MemoryStorage - In-memory (dev/test) │ +│ RedisStorage - Redis (distributed lock + cache) │ +│ MongoStorage - MongoDB (persistent log) │ +├─────────────────────────────────────────────────────────────────┤ +│ Operation Layer │ +├─────────────────────────────────────────────────────────────────┤ +│ CurrencyOperation - Currency operations │ +│ InventoryOperation - Inventory operations │ +│ TradeOperation - Trade operations │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Quick Start + +### Basic Usage + +```typescript +import { + TransactionManager, + MemoryStorage, + CurrencyOperation, + InventoryOperation, +} from '@esengine/transaction'; + +// Create transaction manager +const manager = new TransactionManager({ + storage: new MemoryStorage(), + defaultTimeout: 10000, +}); + +// Execute transaction +const result = await manager.run((tx) => { + // Deduct gold + tx.addOperation(new CurrencyOperation({ + type: 'deduct', + playerId: 'player1', + currency: 'gold', + amount: 100, + })); + + // Add item + tx.addOperation(new InventoryOperation({ + type: 'add', + playerId: 'player1', + itemId: 'sword_001', + quantity: 1, + })); +}); + +if (result.success) { + console.log('Purchase successful!'); +} else { + console.log('Purchase failed:', result.error); +} +``` + +### Player Trading + +```typescript +import { TradeOperation } from '@esengine/transaction'; + +const result = await manager.run((tx) => { + tx.addOperation(new TradeOperation({ + tradeId: 'trade_001', + partyA: { + playerId: 'player1', + items: [{ itemId: 'sword', quantity: 1 }], + }, + partyB: { + playerId: 'player2', + currencies: [{ currency: 'gold', amount: 1000 }], + }, + })); +}, { timeout: 30000 }); +``` + +### Using Redis Storage + +```typescript +import Redis from 'ioredis'; +import { TransactionManager, RedisStorage } from '@esengine/transaction'; + +const redis = new Redis('redis://localhost:6379'); +const storage = new RedisStorage({ client: redis }); + +const manager = new TransactionManager({ storage }); +``` + +### Using MongoDB Storage + +```typescript +import { MongoClient } from 'mongodb'; +import { TransactionManager, MongoStorage } from '@esengine/transaction'; + +const client = new MongoClient('mongodb://localhost:27017'); +await client.connect(); +const db = client.db('game'); + +const storage = new MongoStorage({ db }); +await storage.ensureIndexes(); + +const manager = new TransactionManager({ storage }); +``` + +## Room Integration + +```typescript +import { Room } from '@esengine/server'; +import { withTransactions, CurrencyOperation, RedisStorage } from '@esengine/transaction'; + +class GameRoom extends withTransactions(Room, { + storage: new RedisStorage({ client: redisClient }), +}) { + @onMessage('Buy') + async handleBuy(data: { itemId: string }, player: Player) { + const result = await this.runTransaction((tx) => { + tx.addOperation(new CurrencyOperation({ + type: 'deduct', + playerId: player.id, + currency: 'gold', + amount: getItemPrice(data.itemId), + })); + }); + + if (result.success) { + player.send('buy_success', { itemId: data.itemId }); + } else { + player.send('buy_failed', { error: result.error }); + } + } +} +``` + +## Documentation + +- [Core Concepts](/en/modules/transaction/core/) - Transaction context, manager, Saga pattern +- [Storage Layer](/en/modules/transaction/storage/) - MemoryStorage, RedisStorage, MongoStorage +- [Operations](/en/modules/transaction/operations/) - Currency, inventory, trade operations +- [Distributed Transactions](/en/modules/transaction/distributed/) - Saga orchestrator, cross-server transactions +- [API Reference](/en/modules/transaction/api/) - Complete API documentation + +## Service Tokens + +For dependency injection: + +```typescript +import { + TransactionManagerToken, + TransactionStorageToken, +} from '@esengine/transaction'; + +const manager = services.get(TransactionManagerToken); +``` + +## Best Practices + +### 1. Operation Granularity + +```typescript +// ✅ Good: Fine-grained operations, easy to rollback +tx.addOperation(new CurrencyOperation({ type: 'deduct', ... })); +tx.addOperation(new InventoryOperation({ type: 'add', ... })); + +// ❌ Bad: Coarse-grained operation, hard to partially rollback +tx.addOperation(new ComplexPurchaseOperation({ ... })); +``` + +### 2. Timeout Settings + +```typescript +// Simple operations: short timeout +await manager.run(tx => { ... }, { timeout: 5000 }); + +// Complex trades: longer timeout +await manager.run(tx => { ... }, { timeout: 30000 }); + +// Cross-server: even longer timeout +await manager.run(tx => { ... }, { timeout: 60000, distributed: true }); +``` + +### 3. Error Handling + +```typescript +const result = await manager.run((tx) => { ... }); + +if (!result.success) { + // Log the error + logger.error('Transaction failed', { + transactionId: result.transactionId, + error: result.error, + duration: result.duration, + }); + + // Notify user + player.send('error', { message: getErrorMessage(result.error) }); +} +``` diff --git a/docs/src/content/docs/en/modules/transaction/operations.md b/docs/src/content/docs/en/modules/transaction/operations.md new file mode 100644 index 00000000..60bf11dd --- /dev/null +++ b/docs/src/content/docs/en/modules/transaction/operations.md @@ -0,0 +1,313 @@ +--- +title: "Operations" +description: "Built-in transaction operations: currency, inventory, trade" +--- + +## BaseOperation + +Base class for all operations, providing a common implementation template. + +```typescript +import { BaseOperation, ITransactionContext, OperationResult } from '@esengine/transaction'; + +class MyOperation extends BaseOperation { + readonly name = 'myOperation'; + + async validate(ctx: ITransactionContext): Promise { + // Validate preconditions + return true; + } + + async execute(ctx: ITransactionContext): Promise> { + // Execute operation + return this.success({ result: 'ok' }); + // or + return this.failure('Something went wrong', 'ERROR_CODE'); + } + + async compensate(ctx: ITransactionContext): Promise { + // Rollback operation + } +} +``` + +## CurrencyOperation + +Handles currency addition and deduction. + +### Deduct Currency + +```typescript +import { CurrencyOperation } from '@esengine/transaction'; + +tx.addOperation(new CurrencyOperation({ + type: 'deduct', + playerId: 'player1', + currency: 'gold', + amount: 100, + reason: 'purchase_item', +})); +``` + +### Add Currency + +```typescript +tx.addOperation(new CurrencyOperation({ + type: 'add', + playerId: 'player1', + currency: 'diamond', + amount: 50, + reason: 'daily_reward', +})); +``` + +### Operation Data + +```typescript +interface CurrencyOperationData { + type: 'add' | 'deduct'; // Operation type + playerId: string; // Player ID + currency: string; // Currency type + amount: number; // Amount + reason?: string; // Reason/source +} +``` + +### Operation Result + +```typescript +interface CurrencyOperationResult { + beforeBalance: number; // Balance before operation + afterBalance: number; // Balance after operation +} +``` + +### Custom Data Provider + +```typescript +interface ICurrencyProvider { + getBalance(playerId: string, currency: string): Promise; + setBalance(playerId: string, currency: string, amount: number): Promise; +} + +class MyCurrencyProvider implements ICurrencyProvider { + async getBalance(playerId: string, currency: string): Promise { + // Get balance from database + return await db.getCurrency(playerId, currency); + } + + async setBalance(playerId: string, currency: string, amount: number): Promise { + // Save to database + await db.setCurrency(playerId, currency, amount); + } +} + +// Use custom provider +const op = new CurrencyOperation({ ... }); +op.setProvider(new MyCurrencyProvider()); +tx.addOperation(op); +``` + +## InventoryOperation + +Handles item addition, removal, and updates. + +### Add Item + +```typescript +import { InventoryOperation } from '@esengine/transaction'; + +tx.addOperation(new InventoryOperation({ + type: 'add', + playerId: 'player1', + itemId: 'sword_001', + quantity: 1, + properties: { enchant: 'fire' }, +})); +``` + +### Remove Item + +```typescript +tx.addOperation(new InventoryOperation({ + type: 'remove', + playerId: 'player1', + itemId: 'potion_hp', + quantity: 5, +})); +``` + +### Update Item + +```typescript +tx.addOperation(new InventoryOperation({ + type: 'update', + playerId: 'player1', + itemId: 'sword_001', + quantity: 1, // Optional, keeps original if not provided + properties: { enchant: 'lightning', level: 5 }, +})); +``` + +### Operation Data + +```typescript +interface InventoryOperationData { + type: 'add' | 'remove' | 'update'; // Operation type + playerId: string; // Player ID + itemId: string; // Item ID + quantity: number; // Quantity + properties?: Record; // Item properties + reason?: string; // Reason/source +} +``` + +### Operation Result + +```typescript +interface InventoryOperationResult { + beforeItem?: ItemData; // Item before operation + afterItem?: ItemData; // Item after operation +} + +interface ItemData { + itemId: string; + quantity: number; + properties?: Record; +} +``` + +### Custom Data Provider + +```typescript +interface IInventoryProvider { + getItem(playerId: string, itemId: string): Promise; + setItem(playerId: string, itemId: string, item: ItemData | null): Promise; + hasCapacity?(playerId: string, count: number): Promise; +} + +class MyInventoryProvider implements IInventoryProvider { + async getItem(playerId: string, itemId: string): Promise { + return await db.getItem(playerId, itemId); + } + + async setItem(playerId: string, itemId: string, item: ItemData | null): Promise { + if (item) { + await db.saveItem(playerId, itemId, item); + } else { + await db.deleteItem(playerId, itemId); + } + } + + async hasCapacity(playerId: string, count: number): Promise { + const current = await db.getItemCount(playerId); + const max = await db.getMaxCapacity(playerId); + return current + count <= max; + } +} +``` + +## TradeOperation + +Handles item and currency exchange between players. + +### Basic Usage + +```typescript +import { TradeOperation } from '@esengine/transaction'; + +tx.addOperation(new TradeOperation({ + tradeId: 'trade_001', + partyA: { + playerId: 'player1', + items: [{ itemId: 'sword', quantity: 1 }], + currencies: [{ currency: 'diamond', amount: 10 }], + }, + partyB: { + playerId: 'player2', + currencies: [{ currency: 'gold', amount: 1000 }], + }, + reason: 'player_trade', +})); +``` + +### Operation Data + +```typescript +interface TradeOperationData { + tradeId: string; // Trade ID + partyA: TradeParty; // Trade initiator + partyB: TradeParty; // Trade receiver + reason?: string; // Reason/note +} + +interface TradeParty { + playerId: string; // Player ID + items?: TradeItem[]; // Items to give + currencies?: TradeCurrency[]; // Currencies to give +} + +interface TradeItem { + itemId: string; + quantity: number; +} + +interface TradeCurrency { + currency: string; + amount: number; +} +``` + +### Execution Flow + +TradeOperation internally generates the following sub-operation sequence: + +``` +1. Remove partyA's items +2. Add items to partyB (from partyA) +3. Deduct partyA's currencies +4. Add currencies to partyB (from partyA) +5. Remove partyB's items +6. Add items to partyA (from partyB) +7. Deduct partyB's currencies +8. Add currencies to partyA (from partyB) +``` + +If any step fails, all previous operations are rolled back. + +### Using Custom Providers + +```typescript +const op = new TradeOperation({ ... }); +op.setProvider({ + currencyProvider: new MyCurrencyProvider(), + inventoryProvider: new MyInventoryProvider(), +}); +tx.addOperation(op); +``` + +## Factory Functions + +Each operation class provides a factory function: + +```typescript +import { + createCurrencyOperation, + createInventoryOperation, + createTradeOperation, +} from '@esengine/transaction'; + +tx.addOperation(createCurrencyOperation({ + type: 'deduct', + playerId: 'player1', + currency: 'gold', + amount: 100, +})); + +tx.addOperation(createInventoryOperation({ + type: 'add', + playerId: 'player1', + itemId: 'sword', + quantity: 1, +})); +``` diff --git a/docs/src/content/docs/en/modules/transaction/storage.md b/docs/src/content/docs/en/modules/transaction/storage.md new file mode 100644 index 00000000..10bb8229 --- /dev/null +++ b/docs/src/content/docs/en/modules/transaction/storage.md @@ -0,0 +1,215 @@ +--- +title: "Storage Layer" +description: "Transaction storage interface and implementations: MemoryStorage, RedisStorage, MongoStorage" +--- + +## Storage Interface + +All storage implementations must implement the `ITransactionStorage` interface: + +```typescript +interface ITransactionStorage { + // Distributed lock + acquireLock(key: string, ttl: number): Promise; + releaseLock(key: string, token: string): Promise; + + // Transaction log + saveTransaction(tx: TransactionLog): Promise; + getTransaction(id: string): Promise; + updateTransactionState(id: string, state: TransactionState): Promise; + updateOperationState(txId: string, opIndex: number, state: string, error?: string): Promise; + getPendingTransactions(serverId?: string): Promise; + deleteTransaction(id: string): Promise; + + // Data operations + get(key: string): Promise; + set(key: string, value: T, ttl?: number): Promise; + delete(key: string): Promise; +} +``` + +## MemoryStorage + +In-memory storage, suitable for development and testing. + +```typescript +import { MemoryStorage } from '@esengine/transaction'; + +const storage = new MemoryStorage({ + maxTransactions: 1000, // Maximum transaction log count +}); + +const manager = new TransactionManager({ storage }); +``` + +### Characteristics + +- ✅ No external dependencies +- ✅ Fast, good for debugging +- ❌ Data only stored in memory +- ❌ No true distributed locking +- ❌ Data lost on restart + +### Test Helpers + +```typescript +// Clear all data +storage.clear(); + +// Get transaction count +console.log(storage.transactionCount); +``` + +## RedisStorage + +Redis storage, suitable for production distributed systems. + +```typescript +import Redis from 'ioredis'; +import { RedisStorage } from '@esengine/transaction'; + +const redis = new Redis('redis://localhost:6379'); + +const storage = new RedisStorage({ + client: redis, + prefix: 'tx:', // Key prefix + transactionTTL: 86400, // Transaction log TTL (seconds) +}); + +const manager = new TransactionManager({ storage }); +``` + +### Characteristics + +- ✅ High-performance distributed locking +- ✅ Fast read/write +- ✅ Supports TTL auto-expiration +- ✅ Suitable for high concurrency +- ❌ Requires Redis server + +### Distributed Lock Implementation + +Uses Redis `SET NX EX` for distributed locking: + +```typescript +// Acquire lock (atomic operation) +SET tx:lock:player:123 NX EX 10 + +// Release lock (Lua script for atomicity) +if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) +else + return 0 +end +``` + +### Key Structure + +``` +tx:lock:{key} - Distributed locks +tx:tx:{id} - Transaction logs +tx:server:{id}:txs - Server transaction index +tx:data:{key} - Business data +``` + +## MongoStorage + +MongoDB storage, suitable for scenarios requiring persistence and complex queries. + +```typescript +import { MongoClient } from 'mongodb'; +import { MongoStorage } from '@esengine/transaction'; + +const client = new MongoClient('mongodb://localhost:27017'); +await client.connect(); +const db = client.db('game'); + +const storage = new MongoStorage({ + db, + transactionCollection: 'transactions', // Transaction log collection + dataCollection: 'transaction_data', // Business data collection + lockCollection: 'transaction_locks', // Lock collection +}); + +// Create indexes (run on first startup) +await storage.ensureIndexes(); + +const manager = new TransactionManager({ storage }); +``` + +### Characteristics + +- ✅ Persistent storage +- ✅ Supports complex queries +- ✅ Transaction logs are traceable +- ✅ Suitable for audit requirements +- ❌ Slightly lower performance than Redis +- ❌ Requires MongoDB server + +### Index Structure + +```javascript +// transactions collection +{ state: 1 } +{ 'metadata.serverId': 1 } +{ createdAt: 1 } + +// transaction_locks collection +{ expireAt: 1 } // TTL index + +// transaction_data collection +{ expireAt: 1 } // TTL index +``` + +### Distributed Lock Implementation + +Uses MongoDB unique index for distributed locking: + +```typescript +// Acquire lock +db.transaction_locks.insertOne({ + _id: 'player:123', + token: '', + expireAt: new Date(Date.now() + 10000) +}); + +// If key exists, check if expired +db.transaction_locks.updateOne( + { _id: 'player:123', expireAt: { $lt: new Date() } }, + { $set: { token: '', expireAt: new Date(Date.now() + 10000) } } +); +``` + +## Storage Selection Guide + +| Scenario | Recommended Storage | Reason | +|----------|---------------------|--------| +| Development/Testing | MemoryStorage | No dependencies, fast startup | +| Single-machine Production | RedisStorage | High performance, simple | +| Distributed System | RedisStorage | True distributed locking | +| Audit Required | MongoStorage | Persistent logs | +| Mixed Requirements | Redis + Mongo | Redis for locks, Mongo for logs | + +## Custom Storage + +Implement `ITransactionStorage` interface to create custom storage: + +```typescript +import { ITransactionStorage, TransactionLog, TransactionState } from '@esengine/transaction'; + +class MyCustomStorage implements ITransactionStorage { + async acquireLock(key: string, ttl: number): Promise { + // Implement distributed lock acquisition + } + + async releaseLock(key: string, token: string): Promise { + // Implement distributed lock release + } + + async saveTransaction(tx: TransactionLog): Promise { + // Save transaction log + } + + // ... implement other methods +} +``` diff --git a/docs/src/content/docs/modules/index.md b/docs/src/content/docs/modules/index.md index 6c0c8f1e..f4e0aedd 100644 --- a/docs/src/content/docs/modules/index.md +++ b/docs/src/content/docs/modules/index.md @@ -35,6 +35,7 @@ ESEngine 提供了丰富的功能模块,可以按需引入到你的项目中 | 模块 | 包名 | 描述 | |------|------|------| | [网络同步](/modules/network/) | `@esengine/network` | 多人游戏网络同步 | +| [事务系统](/modules/transaction/) | `@esengine/transaction` | 游戏事务处理,支持分布式事务 | ## 安装 diff --git a/docs/src/content/docs/modules/transaction/core.md b/docs/src/content/docs/modules/transaction/core.md new file mode 100644 index 00000000..39702469 --- /dev/null +++ b/docs/src/content/docs/modules/transaction/core.md @@ -0,0 +1,261 @@ +--- +title: "核心概念" +description: "事务系统的核心概念:事务上下文、事务管理器、Saga 模式" +--- + +## 事务状态 + +事务有以下几种状态: + +```typescript +type TransactionState = + | 'pending' // 等待执行 + | 'executing' // 执行中 + | 'committed' // 已提交 + | 'rolledback' // 已回滚 + | 'failed' // 失败 +``` + +## TransactionContext + +事务上下文封装了事务的状态、操作和执行逻辑。 + +### 创建事务 + +```typescript +import { TransactionManager } from '@esengine/transaction'; + +const manager = new TransactionManager(); + +// 方式 1:使用 begin() 手动管理 +const tx = manager.begin({ timeout: 5000 }); +tx.addOperation(op1); +tx.addOperation(op2); +const result = await tx.execute(); + +// 方式 2:使用 run() 自动管理 +const result = await manager.run((tx) => { + tx.addOperation(op1); + tx.addOperation(op2); +}); +``` + +### 链式添加操作 + +```typescript +const result = await manager.run((tx) => { + tx.addOperation(new CurrencyOperation({ ... })) + .addOperation(new InventoryOperation({ ... })) + .addOperation(new InventoryOperation({ ... })); +}); +``` + +### 上下文数据 + +操作之间可以通过上下文共享数据: + +```typescript +class CustomOperation extends BaseOperation { + async execute(ctx: ITransactionContext): Promise> { + // 读取之前操作设置的数据 + const previousResult = ctx.get('previousValue'); + + // 设置数据供后续操作使用 + ctx.set('myResult', { value: 123 }); + + return this.success({ ... }); + } +} +``` + +## TransactionManager + +事务管理器负责创建、执行和恢复事务。 + +### 配置选项 + +```typescript +interface TransactionManagerConfig { + storage?: ITransactionStorage; // 存储实例 + defaultTimeout?: number; // 默认超时(毫秒) + serverId?: string; // 服务器 ID(分布式用) + autoRecover?: boolean; // 自动恢复未完成事务 +} + +const manager = new TransactionManager({ + storage: new RedisStorage({ client: redis }), + defaultTimeout: 10000, + serverId: 'server-1', + autoRecover: true, +}); +``` + +### 分布式锁 + +```typescript +// 获取锁 +const token = await manager.acquireLock('player:123:inventory', 10000); + +if (token) { + try { + // 执行操作 + await doSomething(); + } finally { + // 释放锁 + await manager.releaseLock('player:123:inventory', token); + } +} + +// 或使用 withLock 简化 +await manager.withLock('player:123:inventory', async () => { + await doSomething(); +}, 10000); +``` + +### 事务恢复 + +服务器重启时恢复未完成的事务: + +```typescript +const manager = new TransactionManager({ + storage: new RedisStorage({ client: redis }), + serverId: 'server-1', +}); + +// 恢复未完成的事务 +const recoveredCount = await manager.recover(); +console.log(`Recovered ${recoveredCount} transactions`); +``` + +## Saga 模式 + +事务系统采用 Saga 模式,每个操作必须实现 `execute` 和 `compensate` 方法: + +```typescript +interface ITransactionOperation { + readonly name: string; + readonly data: TData; + + // 验证前置条件 + validate(ctx: ITransactionContext): Promise; + + // 正向执行 + execute(ctx: ITransactionContext): Promise>; + + // 补偿操作(回滚) + compensate(ctx: ITransactionContext): Promise; +} +``` + +### 执行流程 + +``` +开始事务 + │ + ▼ +┌─────────────────────┐ +│ validate(op1) │──失败──► 返回失败 +└─────────────────────┘ + │成功 + ▼ +┌─────────────────────┐ +│ execute(op1) │──失败──┐ +└─────────────────────┘ │ + │成功 │ + ▼ │ +┌─────────────────────┐ │ +│ validate(op2) │──失败──┤ +└─────────────────────┘ │ + │成功 │ + ▼ │ +┌─────────────────────┐ │ +│ execute(op2) │──失败──┤ +└─────────────────────┘ │ + │成功 ▼ + ▼ ┌─────────────────────┐ +提交事务 │ compensate(op1) │ + └─────────────────────┘ + │ + ▼ + 返回失败(已回滚) +``` + +### 自定义操作 + +```typescript +import { BaseOperation, ITransactionContext, OperationResult } from '@esengine/transaction'; + +interface UpgradeData { + playerId: string; + itemId: string; + targetLevel: number; +} + +interface UpgradeResult { + newLevel: number; +} + +class UpgradeOperation extends BaseOperation { + readonly name = 'upgrade'; + + private _previousLevel: number = 0; + + async validate(ctx: ITransactionContext): Promise { + // 验证物品存在且可升级 + const item = await this.getItem(ctx); + return item !== null && item.level < this.data.targetLevel; + } + + async execute(ctx: ITransactionContext): Promise> { + const item = await this.getItem(ctx); + if (!item) { + return this.failure('Item not found', 'ITEM_NOT_FOUND'); + } + + this._previousLevel = item.level; + item.level = this.data.targetLevel; + await this.saveItem(ctx, item); + + return this.success({ newLevel: item.level }); + } + + async compensate(ctx: ITransactionContext): Promise { + const item = await this.getItem(ctx); + if (item) { + item.level = this._previousLevel; + await this.saveItem(ctx, item); + } + } + + private async getItem(ctx: ITransactionContext) { + // 从存储获取物品 + } + + private async saveItem(ctx: ITransactionContext, item: any) { + // 保存物品到存储 + } +} +``` + +## 事务结果 + +```typescript +interface TransactionResult { + success: boolean; // 是否成功 + transactionId: string; // 事务 ID + results: OperationResult[]; // 各操作结果 + data?: T; // 最终数据 + error?: string; // 错误信息 + duration: number; // 执行时间(毫秒) +} + +const result = await manager.run((tx) => { ... }); + +console.log(`Transaction ${result.transactionId}`); +console.log(`Success: ${result.success}`); +console.log(`Duration: ${result.duration}ms`); + +if (!result.success) { + console.log(`Error: ${result.error}`); +} +``` diff --git a/docs/src/content/docs/modules/transaction/distributed.md b/docs/src/content/docs/modules/transaction/distributed.md new file mode 100644 index 00000000..f70e3ea0 --- /dev/null +++ b/docs/src/content/docs/modules/transaction/distributed.md @@ -0,0 +1,355 @@ +--- +title: "分布式事务" +description: "Saga 编排器和跨服务器事务支持" +--- + +## Saga 编排器 + +`SagaOrchestrator` 用于编排跨服务器的分布式事务。 + +### 基本用法 + +```typescript +import { SagaOrchestrator, RedisStorage } from '@esengine/transaction'; + +const orchestrator = new SagaOrchestrator({ + storage: new RedisStorage({ client: redis }), + timeout: 30000, + serverId: 'orchestrator-1', +}); + +const result = await orchestrator.execute([ + { + name: 'deduct_currency', + serverId: 'game-server-1', + data: { playerId: 'player1', amount: 100 }, + execute: async (data) => { + // 调用游戏服务器 API 扣除货币 + const response = await gameServerApi.deductCurrency(data); + return { success: response.ok }; + }, + compensate: async (data) => { + // 调用游戏服务器 API 恢复货币 + await gameServerApi.addCurrency(data); + }, + }, + { + name: 'add_item', + serverId: 'inventory-server-1', + data: { playerId: 'player1', itemId: 'sword' }, + execute: async (data) => { + const response = await inventoryServerApi.addItem(data); + return { success: response.ok }; + }, + compensate: async (data) => { + await inventoryServerApi.removeItem(data); + }, + }, +]); + +if (result.success) { + console.log('Saga completed successfully'); +} else { + console.log('Saga failed:', result.error); + console.log('Completed steps:', result.completedSteps); + console.log('Failed at:', result.failedStep); +} +``` + +### 配置选项 + +```typescript +interface SagaOrchestratorConfig { + storage?: ITransactionStorage; // 存储实例 + timeout?: number; // 超时时间(毫秒) + serverId?: string; // 编排器服务器 ID +} +``` + +### Saga 步骤 + +```typescript +interface SagaStep { + name: string; // 步骤名称 + serverId?: string; // 目标服务器 ID + data: T; // 步骤数据 + execute: (data: T) => Promise; // 执行函数 + compensate: (data: T) => Promise; // 补偿函数 +} +``` + +### Saga 结果 + +```typescript +interface SagaResult { + success: boolean; // 是否成功 + sagaId: string; // Saga ID + completedSteps: string[]; // 已完成的步骤 + failedStep?: string; // 失败的步骤 + error?: string; // 错误信息 + duration: number; // 执行时间(毫秒) +} +``` + +## 执行流程 + +``` +开始 Saga + │ + ▼ +┌─────────────────────┐ +│ Step 1: execute │──失败──┐ +└─────────────────────┘ │ + │成功 │ + ▼ │ +┌─────────────────────┐ │ +│ Step 2: execute │──失败──┤ +└─────────────────────┘ │ + │成功 │ + ▼ │ +┌─────────────────────┐ │ +│ Step 3: execute │──失败──┤ +└─────────────────────┘ │ + │成功 ▼ + ▼ ┌─────────────────────┐ +Saga 完成 │ Step 2: compensate │ + └─────────────────────┘ + │ + ▼ + ┌─────────────────────┐ + │ Step 1: compensate │ + └─────────────────────┘ + │ + ▼ + Saga 失败(已补偿) +``` + +## Saga 日志 + +编排器会记录详细的执行日志: + +```typescript +interface SagaLog { + id: string; // Saga ID + state: SagaLogState; // 状态 + steps: SagaStepLog[]; // 步骤日志 + createdAt: number; // 创建时间 + updatedAt: number; // 更新时间 + metadata?: Record; +} + +type SagaLogState = + | 'pending' // 等待执行 + | 'running' // 执行中 + | 'completed' // 已完成 + | 'compensating' // 补偿中 + | 'compensated' // 已补偿 + | 'failed' // 失败 + +interface SagaStepLog { + name: string; // 步骤名称 + serverId?: string; // 服务器 ID + state: SagaStepState; // 状态 + startedAt?: number; // 开始时间 + completedAt?: number; // 完成时间 + error?: string; // 错误信息 +} + +type SagaStepState = + | 'pending' // 等待执行 + | 'executing' // 执行中 + | 'completed' // 已完成 + | 'compensating' // 补偿中 + | 'compensated' // 已补偿 + | 'failed' // 失败 +``` + +### 查询 Saga 日志 + +```typescript +const log = await orchestrator.getSagaLog('saga_xxx'); + +if (log) { + console.log('Saga state:', log.state); + for (const step of log.steps) { + console.log(` ${step.name}: ${step.state}`); + } +} +``` + +## 跨服务器事务示例 + +### 场景:跨服购买 + +玩家在游戏服务器购买物品,货币在账户服务器,物品在背包服务器。 + +```typescript +const orchestrator = new SagaOrchestrator({ + storage: redisStorage, + serverId: 'purchase-orchestrator', +}); + +async function crossServerPurchase( + playerId: string, + itemId: string, + price: number +): Promise { + return orchestrator.execute([ + // 步骤 1:在账户服务器扣款 + { + name: 'deduct_balance', + serverId: 'account-server', + data: { playerId, amount: price }, + execute: async (data) => { + const result = await accountService.deduct(data.playerId, data.amount); + return { success: result.ok, error: result.error }; + }, + compensate: async (data) => { + await accountService.refund(data.playerId, data.amount); + }, + }, + + // 步骤 2:在背包服务器添加物品 + { + name: 'add_item', + serverId: 'inventory-server', + data: { playerId, itemId }, + execute: async (data) => { + const result = await inventoryService.addItem(data.playerId, data.itemId); + return { success: result.ok, error: result.error }; + }, + compensate: async (data) => { + await inventoryService.removeItem(data.playerId, data.itemId); + }, + }, + + // 步骤 3:记录购买日志 + { + name: 'log_purchase', + serverId: 'log-server', + data: { playerId, itemId, price, timestamp: Date.now() }, + execute: async (data) => { + await logService.recordPurchase(data); + return { success: true }; + }, + compensate: async (data) => { + await logService.cancelPurchase(data); + }, + }, + ]); +} +``` + +### 场景:跨服交易 + +两个玩家在不同服务器上进行交易。 + +```typescript +async function crossServerTrade( + playerA: { id: string; server: string; items: string[] }, + playerB: { id: string; server: string; items: string[] } +): Promise { + const steps: SagaStep[] = []; + + // 移除 A 的物品 + for (const itemId of playerA.items) { + steps.push({ + name: `remove_${playerA.id}_${itemId}`, + serverId: playerA.server, + data: { playerId: playerA.id, itemId }, + execute: async (data) => { + return await inventoryService.removeItem(data.playerId, data.itemId); + }, + compensate: async (data) => { + await inventoryService.addItem(data.playerId, data.itemId); + }, + }); + } + + // 添加物品到 B + for (const itemId of playerA.items) { + steps.push({ + name: `add_${playerB.id}_${itemId}`, + serverId: playerB.server, + data: { playerId: playerB.id, itemId }, + execute: async (data) => { + return await inventoryService.addItem(data.playerId, data.itemId); + }, + compensate: async (data) => { + await inventoryService.removeItem(data.playerId, data.itemId); + }, + }); + } + + // 类似地处理 B 的物品... + + return orchestrator.execute(steps); +} +``` + +## 恢复未完成的 Saga + +服务器重启后恢复未完成的 Saga: + +```typescript +const orchestrator = new SagaOrchestrator({ + storage: redisStorage, + serverId: 'my-orchestrator', +}); + +// 恢复未完成的 Saga(会执行补偿) +const recoveredCount = await orchestrator.recover(); +console.log(`Recovered ${recoveredCount} sagas`); +``` + +## 最佳实践 + +### 1. 幂等性 + +确保所有操作都是幂等的: + +```typescript +{ + execute: async (data) => { + // 使用唯一 ID 确保幂等 + const result = await service.process(data.requestId, data); + return { success: result.ok }; + }, + compensate: async (data) => { + // 补偿也要幂等 + await service.rollback(data.requestId); + }, +} +``` + +### 2. 超时处理 + +设置合适的超时时间: + +```typescript +const orchestrator = new SagaOrchestrator({ + timeout: 60000, // 跨服务器操作需要更长超时 +}); +``` + +### 3. 监控和告警 + +记录 Saga 执行结果: + +```typescript +const result = await orchestrator.execute(steps); + +if (!result.success) { + // 发送告警 + alertService.send({ + type: 'saga_failed', + sagaId: result.sagaId, + failedStep: result.failedStep, + error: result.error, + }); + + // 记录详细日志 + const log = await orchestrator.getSagaLog(result.sagaId); + logger.error('Saga failed', { log }); +} +``` diff --git a/docs/src/content/docs/modules/transaction/index.md b/docs/src/content/docs/modules/transaction/index.md new file mode 100644 index 00000000..d7371702 --- /dev/null +++ b/docs/src/content/docs/modules/transaction/index.md @@ -0,0 +1,238 @@ +--- +title: "事务系统 (Transaction)" +description: "游戏事务处理系统,支持商店购买、玩家交易、分布式事务" +--- + +`@esengine/transaction` 提供完整的游戏事务处理能力,基于 Saga 模式实现,支持商店购买、玩家交易、多步骤任务等场景,并提供 Redis/MongoDB 分布式事务支持。 + +## 概述 + +事务系统解决游戏中常见的数据一致性问题: + +| 场景 | 问题 | 解决方案 | +|------|------|----------| +| 商店购买 | 扣款成功但物品未发放 | 原子事务,失败自动回滚 | +| 玩家交易 | 一方物品转移另一方未收到 | Saga 补偿机制 | +| 跨服操作 | 多服务器数据不一致 | 分布式锁 + 事务日志 | + +## 安装 + +```bash +npm install @esengine/transaction +``` + +可选依赖(根据存储需求安装): +```bash +npm install ioredis # Redis 存储 +npm install mongodb # MongoDB 存储 +``` + +## 架构 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Transaction Layer │ +├─────────────────────────────────────────────────────────────────┤ +│ TransactionManager - 事务管理器,协调事务生命周期 │ +│ TransactionContext - 事务上下文,封装操作和状态 │ +│ SagaOrchestrator - 分布式 Saga 编排器 │ +├─────────────────────────────────────────────────────────────────┤ +│ Storage Layer │ +├─────────────────────────────────────────────────────────────────┤ +│ MemoryStorage - 内存存储(开发/测试) │ +│ RedisStorage - Redis(分布式锁 + 缓存) │ +│ MongoStorage - MongoDB(持久化日志) │ +├─────────────────────────────────────────────────────────────────┤ +│ Operation Layer │ +├─────────────────────────────────────────────────────────────────┤ +│ CurrencyOperation - 货币操作 │ +│ InventoryOperation - 背包操作 │ +│ TradeOperation - 交易操作 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## 快速开始 + +### 基础用法 + +```typescript +import { + TransactionManager, + MemoryStorage, + CurrencyOperation, + InventoryOperation, +} from '@esengine/transaction'; + +// 创建事务管理器 +const manager = new TransactionManager({ + storage: new MemoryStorage(), + defaultTimeout: 10000, +}); + +// 执行事务 +const result = await manager.run((tx) => { + // 扣除金币 + tx.addOperation(new CurrencyOperation({ + type: 'deduct', + playerId: 'player1', + currency: 'gold', + amount: 100, + })); + + // 添加物品 + tx.addOperation(new InventoryOperation({ + type: 'add', + playerId: 'player1', + itemId: 'sword_001', + quantity: 1, + })); +}); + +if (result.success) { + console.log('购买成功!'); +} else { + console.log('购买失败:', result.error); +} +``` + +### 玩家交易 + +```typescript +import { TradeOperation } from '@esengine/transaction'; + +const result = await manager.run((tx) => { + tx.addOperation(new TradeOperation({ + tradeId: 'trade_001', + partyA: { + playerId: 'player1', + items: [{ itemId: 'sword', quantity: 1 }], + }, + partyB: { + playerId: 'player2', + currencies: [{ currency: 'gold', amount: 1000 }], + }, + })); +}, { timeout: 30000 }); +``` + +### 使用 Redis 存储 + +```typescript +import Redis from 'ioredis'; +import { TransactionManager, RedisStorage } from '@esengine/transaction'; + +const redis = new Redis('redis://localhost:6379'); +const storage = new RedisStorage({ client: redis }); + +const manager = new TransactionManager({ storage }); +``` + +### 使用 MongoDB 存储 + +```typescript +import { MongoClient } from 'mongodb'; +import { TransactionManager, MongoStorage } from '@esengine/transaction'; + +const client = new MongoClient('mongodb://localhost:27017'); +await client.connect(); +const db = client.db('game'); + +const storage = new MongoStorage({ db }); +await storage.ensureIndexes(); + +const manager = new TransactionManager({ storage }); +``` + +## 与 Room 集成 + +```typescript +import { Room } from '@esengine/server'; +import { withTransactions, CurrencyOperation, RedisStorage } from '@esengine/transaction'; + +class GameRoom extends withTransactions(Room, { + storage: new RedisStorage({ client: redisClient }), +}) { + @onMessage('Buy') + async handleBuy(data: { itemId: string }, player: Player) { + const result = await this.runTransaction((tx) => { + tx.addOperation(new CurrencyOperation({ + type: 'deduct', + playerId: player.id, + currency: 'gold', + amount: getItemPrice(data.itemId), + })); + }); + + if (result.success) { + player.send('buy_success', { itemId: data.itemId }); + } else { + player.send('buy_failed', { error: result.error }); + } + } +} +``` + +## 文档导航 + +- [核心概念](/modules/transaction/core/) - 事务上下文、管理器、Saga 模式 +- [存储层](/modules/transaction/storage/) - MemoryStorage、RedisStorage、MongoStorage +- [操作类](/modules/transaction/operations/) - 货币、背包、交易操作 +- [分布式事务](/modules/transaction/distributed/) - Saga 编排器、跨服务器事务 +- [API 参考](/modules/transaction/api/) - 完整 API 文档 + +## 服务令牌 + +用于依赖注入: + +```typescript +import { + TransactionManagerToken, + TransactionStorageToken, +} from '@esengine/transaction'; + +const manager = services.get(TransactionManagerToken); +``` + +## 最佳实践 + +### 1. 操作粒度 + +```typescript +// ✅ 好:细粒度操作,便于回滚 +tx.addOperation(new CurrencyOperation({ type: 'deduct', ... })); +tx.addOperation(new InventoryOperation({ type: 'add', ... })); + +// ❌ 差:粗粒度操作,难以部分回滚 +tx.addOperation(new ComplexPurchaseOperation({ ... })); +``` + +### 2. 超时设置 + +```typescript +// 简单操作:短超时 +await manager.run(tx => { ... }, { timeout: 5000 }); + +// 复杂交易:长超时 +await manager.run(tx => { ... }, { timeout: 30000 }); + +// 跨服务器:更长超时 +await manager.run(tx => { ... }, { timeout: 60000, distributed: true }); +``` + +### 3. 错误处理 + +```typescript +const result = await manager.run((tx) => { ... }); + +if (!result.success) { + // 记录日志 + logger.error('Transaction failed', { + transactionId: result.transactionId, + error: result.error, + duration: result.duration, + }); + + // 通知用户 + player.send('error', { message: getErrorMessage(result.error) }); +} +``` diff --git a/docs/src/content/docs/modules/transaction/operations.md b/docs/src/content/docs/modules/transaction/operations.md new file mode 100644 index 00000000..fec8c1cc --- /dev/null +++ b/docs/src/content/docs/modules/transaction/operations.md @@ -0,0 +1,313 @@ +--- +title: "操作类" +description: "内置的事务操作:货币、背包、交易" +--- + +## BaseOperation + +所有操作类的基类,提供通用的实现模板。 + +```typescript +import { BaseOperation, ITransactionContext, OperationResult } from '@esengine/transaction'; + +class MyOperation extends BaseOperation { + readonly name = 'myOperation'; + + async validate(ctx: ITransactionContext): Promise { + // 验证前置条件 + return true; + } + + async execute(ctx: ITransactionContext): Promise> { + // 执行操作 + return this.success({ result: 'ok' }); + // 或 + return this.failure('Something went wrong', 'ERROR_CODE'); + } + + async compensate(ctx: ITransactionContext): Promise { + // 回滚操作 + } +} +``` + +## CurrencyOperation + +处理货币的增加和扣除。 + +### 扣除货币 + +```typescript +import { CurrencyOperation } from '@esengine/transaction'; + +tx.addOperation(new CurrencyOperation({ + type: 'deduct', + playerId: 'player1', + currency: 'gold', + amount: 100, + reason: 'purchase_item', +})); +``` + +### 增加货币 + +```typescript +tx.addOperation(new CurrencyOperation({ + type: 'add', + playerId: 'player1', + currency: 'diamond', + amount: 50, + reason: 'daily_reward', +})); +``` + +### 操作数据 + +```typescript +interface CurrencyOperationData { + type: 'add' | 'deduct'; // 操作类型 + playerId: string; // 玩家 ID + currency: string; // 货币类型 + amount: number; // 数量 + reason?: string; // 原因/来源 +} +``` + +### 操作结果 + +```typescript +interface CurrencyOperationResult { + beforeBalance: number; // 操作前余额 + afterBalance: number; // 操作后余额 +} +``` + +### 自定义数据提供者 + +```typescript +interface ICurrencyProvider { + getBalance(playerId: string, currency: string): Promise; + setBalance(playerId: string, currency: string, amount: number): Promise; +} + +class MyCurrencyProvider implements ICurrencyProvider { + async getBalance(playerId: string, currency: string): Promise { + // 从数据库获取余额 + return await db.getCurrency(playerId, currency); + } + + async setBalance(playerId: string, currency: string, amount: number): Promise { + // 保存到数据库 + await db.setCurrency(playerId, currency, amount); + } +} + +// 使用自定义提供者 +const op = new CurrencyOperation({ ... }); +op.setProvider(new MyCurrencyProvider()); +tx.addOperation(op); +``` + +## InventoryOperation + +处理物品的添加、移除和更新。 + +### 添加物品 + +```typescript +import { InventoryOperation } from '@esengine/transaction'; + +tx.addOperation(new InventoryOperation({ + type: 'add', + playerId: 'player1', + itemId: 'sword_001', + quantity: 1, + properties: { enchant: 'fire' }, +})); +``` + +### 移除物品 + +```typescript +tx.addOperation(new InventoryOperation({ + type: 'remove', + playerId: 'player1', + itemId: 'potion_hp', + quantity: 5, +})); +``` + +### 更新物品 + +```typescript +tx.addOperation(new InventoryOperation({ + type: 'update', + playerId: 'player1', + itemId: 'sword_001', + quantity: 1, // 可选,不传则保持原数量 + properties: { enchant: 'lightning', level: 5 }, +})); +``` + +### 操作数据 + +```typescript +interface InventoryOperationData { + type: 'add' | 'remove' | 'update'; // 操作类型 + playerId: string; // 玩家 ID + itemId: string; // 物品 ID + quantity: number; // 数量 + properties?: Record; // 物品属性 + reason?: string; // 原因/来源 +} +``` + +### 操作结果 + +```typescript +interface InventoryOperationResult { + beforeItem?: ItemData; // 操作前物品 + afterItem?: ItemData; // 操作后物品 +} + +interface ItemData { + itemId: string; + quantity: number; + properties?: Record; +} +``` + +### 自定义数据提供者 + +```typescript +interface IInventoryProvider { + getItem(playerId: string, itemId: string): Promise; + setItem(playerId: string, itemId: string, item: ItemData | null): Promise; + hasCapacity?(playerId: string, count: number): Promise; +} + +class MyInventoryProvider implements IInventoryProvider { + async getItem(playerId: string, itemId: string): Promise { + return await db.getItem(playerId, itemId); + } + + async setItem(playerId: string, itemId: string, item: ItemData | null): Promise { + if (item) { + await db.saveItem(playerId, itemId, item); + } else { + await db.deleteItem(playerId, itemId); + } + } + + async hasCapacity(playerId: string, count: number): Promise { + const current = await db.getItemCount(playerId); + const max = await db.getMaxCapacity(playerId); + return current + count <= max; + } +} +``` + +## TradeOperation + +处理玩家之间的物品和货币交换。 + +### 基本用法 + +```typescript +import { TradeOperation } from '@esengine/transaction'; + +tx.addOperation(new TradeOperation({ + tradeId: 'trade_001', + partyA: { + playerId: 'player1', + items: [{ itemId: 'sword', quantity: 1 }], + currencies: [{ currency: 'diamond', amount: 10 }], + }, + partyB: { + playerId: 'player2', + currencies: [{ currency: 'gold', amount: 1000 }], + }, + reason: 'player_trade', +})); +``` + +### 操作数据 + +```typescript +interface TradeOperationData { + tradeId: string; // 交易 ID + partyA: TradeParty; // 交易发起方 + partyB: TradeParty; // 交易接收方 + reason?: string; // 原因/备注 +} + +interface TradeParty { + playerId: string; // 玩家 ID + items?: TradeItem[]; // 给出的物品 + currencies?: TradeCurrency[]; // 给出的货币 +} + +interface TradeItem { + itemId: string; + quantity: number; +} + +interface TradeCurrency { + currency: string; + amount: number; +} +``` + +### 执行流程 + +TradeOperation 内部会生成以下子操作序列: + +``` +1. 移除 partyA 的物品 +2. 添加 partyB 的物品(来自 partyA) +3. 扣除 partyA 的货币 +4. 增加 partyB 的货币(来自 partyA) +5. 移除 partyB 的物品 +6. 添加 partyA 的物品(来自 partyB) +7. 扣除 partyB 的货币 +8. 增加 partyA 的货币(来自 partyB) +``` + +任何一步失败都会回滚之前的所有操作。 + +### 使用自定义提供者 + +```typescript +const op = new TradeOperation({ ... }); +op.setProvider({ + currencyProvider: new MyCurrencyProvider(), + inventoryProvider: new MyInventoryProvider(), +}); +tx.addOperation(op); +``` + +## 创建工厂函数 + +每个操作类都提供工厂函数: + +```typescript +import { + createCurrencyOperation, + createInventoryOperation, + createTradeOperation, +} from '@esengine/transaction'; + +tx.addOperation(createCurrencyOperation({ + type: 'deduct', + playerId: 'player1', + currency: 'gold', + amount: 100, +})); + +tx.addOperation(createInventoryOperation({ + type: 'add', + playerId: 'player1', + itemId: 'sword', + quantity: 1, +})); +``` diff --git a/docs/src/content/docs/modules/transaction/storage.md b/docs/src/content/docs/modules/transaction/storage.md new file mode 100644 index 00000000..4a0c9e67 --- /dev/null +++ b/docs/src/content/docs/modules/transaction/storage.md @@ -0,0 +1,215 @@ +--- +title: "存储层" +description: "事务存储接口和实现:MemoryStorage、RedisStorage、MongoStorage" +--- + +## 存储接口 + +所有存储实现都需要实现 `ITransactionStorage` 接口: + +```typescript +interface ITransactionStorage { + // 分布式锁 + acquireLock(key: string, ttl: number): Promise; + releaseLock(key: string, token: string): Promise; + + // 事务日志 + saveTransaction(tx: TransactionLog): Promise; + getTransaction(id: string): Promise; + updateTransactionState(id: string, state: TransactionState): Promise; + updateOperationState(txId: string, opIndex: number, state: string, error?: string): Promise; + getPendingTransactions(serverId?: string): Promise; + deleteTransaction(id: string): Promise; + + // 数据操作 + get(key: string): Promise; + set(key: string, value: T, ttl?: number): Promise; + delete(key: string): Promise; +} +``` + +## MemoryStorage + +内存存储,适用于开发和测试环境。 + +```typescript +import { MemoryStorage } from '@esengine/transaction'; + +const storage = new MemoryStorage({ + maxTransactions: 1000, // 最大事务日志数量 +}); + +const manager = new TransactionManager({ storage }); +``` + +### 特点 + +- ✅ 无需外部依赖 +- ✅ 快速,适合开发调试 +- ❌ 数据仅保存在内存中 +- ❌ 不支持真正的分布式锁 +- ❌ 服务重启后数据丢失 + +### 测试辅助 + +```typescript +// 清空所有数据 +storage.clear(); + +// 获取事务数量 +console.log(storage.transactionCount); +``` + +## RedisStorage + +Redis 存储,适用于生产环境的分布式系统。 + +```typescript +import Redis from 'ioredis'; +import { RedisStorage } from '@esengine/transaction'; + +const redis = new Redis('redis://localhost:6379'); + +const storage = new RedisStorage({ + client: redis, + prefix: 'tx:', // 键前缀 + transactionTTL: 86400, // 事务日志过期时间(秒) +}); + +const manager = new TransactionManager({ storage }); +``` + +### 特点 + +- ✅ 高性能分布式锁 +- ✅ 快速读写 +- ✅ 支持 TTL 自动过期 +- ✅ 适合高并发场景 +- ❌ 需要 Redis 服务器 + +### 分布式锁实现 + +使用 Redis `SET NX EX` 实现分布式锁: + +```typescript +// 获取锁(原子操作) +SET tx:lock:player:123 NX EX 10 + +// 释放锁(Lua 脚本保证原子性) +if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) +else + return 0 +end +``` + +### 键结构 + +``` +tx:lock:{key} - 分布式锁 +tx:tx:{id} - 事务日志 +tx:server:{id}:txs - 服务器事务索引 +tx:data:{key} - 业务数据 +``` + +## MongoStorage + +MongoDB 存储,适用于需要持久化和复杂查询的场景。 + +```typescript +import { MongoClient } from 'mongodb'; +import { MongoStorage } from '@esengine/transaction'; + +const client = new MongoClient('mongodb://localhost:27017'); +await client.connect(); +const db = client.db('game'); + +const storage = new MongoStorage({ + db, + transactionCollection: 'transactions', // 事务日志集合 + dataCollection: 'transaction_data', // 业务数据集合 + lockCollection: 'transaction_locks', // 锁集合 +}); + +// 创建索引(首次运行时执行) +await storage.ensureIndexes(); + +const manager = new TransactionManager({ storage }); +``` + +### 特点 + +- ✅ 持久化存储 +- ✅ 支持复杂查询 +- ✅ 事务日志可追溯 +- ✅ 适合需要审计的场景 +- ❌ 相比 Redis 性能略低 +- ❌ 需要 MongoDB 服务器 + +### 索引结构 + +```javascript +// transactions 集合 +{ state: 1 } +{ 'metadata.serverId': 1 } +{ createdAt: 1 } + +// transaction_locks 集合 +{ expireAt: 1 } // TTL 索引 + +// transaction_data 集合 +{ expireAt: 1 } // TTL 索引 +``` + +### 分布式锁实现 + +使用 MongoDB 唯一索引实现分布式锁: + +```typescript +// 获取锁 +db.transaction_locks.insertOne({ + _id: 'player:123', + token: '', + expireAt: new Date(Date.now() + 10000) +}); + +// 如果键已存在,检查是否过期 +db.transaction_locks.updateOne( + { _id: 'player:123', expireAt: { $lt: new Date() } }, + { $set: { token: '', expireAt: new Date(Date.now() + 10000) } } +); +``` + +## 存储选择指南 + +| 场景 | 推荐存储 | 理由 | +|------|----------|------| +| 开发/测试 | MemoryStorage | 无依赖,快速启动 | +| 单机生产 | RedisStorage | 高性能,简单 | +| 分布式系统 | RedisStorage | 真正的分布式锁 | +| 需要审计 | MongoStorage | 持久化日志 | +| 混合需求 | Redis + Mongo | Redis 做锁,Mongo 做日志 | + +## 自定义存储 + +实现 `ITransactionStorage` 接口创建自定义存储: + +```typescript +import { ITransactionStorage, TransactionLog, TransactionState } from '@esengine/transaction'; + +class MyCustomStorage implements ITransactionStorage { + async acquireLock(key: string, ttl: number): Promise { + // 实现分布式锁获取逻辑 + } + + async releaseLock(key: string, token: string): Promise { + // 实现分布式锁释放逻辑 + } + + async saveTransaction(tx: TransactionLog): Promise { + // 保存事务日志 + } + + // ... 实现其他方法 +} +``` diff --git a/packages/framework/transaction/module.json b/packages/framework/transaction/module.json new file mode 100644 index 00000000..0a4f6f72 --- /dev/null +++ b/packages/framework/transaction/module.json @@ -0,0 +1,23 @@ +{ + "id": "transaction", + "name": "@esengine/transaction", + "globalKey": "transaction", + "displayName": "Transaction System", + "description": "游戏事务系统,支持商店购买、玩家交易、分布式事务 | Game transaction system for shop, trading, distributed transactions", + "version": "1.0.0", + "category": "Other", + "icon": "Receipt", + "tags": ["transaction", "distributed", "saga", "database"], + "isCore": false, + "defaultEnabled": false, + "isEngineModule": true, + "canContainContent": false, + "platforms": ["nodejs"], + "dependencies": ["core", "server"], + "exports": { + "services": ["TransactionManager"], + "interfaces": ["ITransactionStorage", "ITransactionOperation"] + }, + "requiresWasm": false, + "outputPath": "dist/index.js" +} diff --git a/packages/framework/transaction/package.json b/packages/framework/transaction/package.json new file mode 100644 index 00000000..151dae9f --- /dev/null +++ b/packages/framework/transaction/package.json @@ -0,0 +1,50 @@ +{ + "name": "@esengine/transaction", + "version": "1.0.0", + "description": "Game transaction system with distributed support | 游戏事务系统,支持分布式事务", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist", + "module.json" + ], + "scripts": { + "build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly", + "build:watch": "tsup --watch", + "type-check": "tsc --noEmit", + "clean": "rimraf dist" + }, + "dependencies": { + "@esengine/server": "workspace:*" + }, + "peerDependencies": { + "ioredis": "^5.3.0", + "mongodb": "^6.0.0" + }, + "peerDependenciesMeta": { + "ioredis": { + "optional": true + }, + "mongodb": { + "optional": true + } + }, + "devDependencies": { + "@esengine/ecs-framework": "workspace:*", + "@esengine/build-config": "workspace:*", + "tsup": "^8.0.0", + "typescript": "^5.8.0", + "rimraf": "^5.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/framework/transaction/src/core/TransactionContext.ts b/packages/framework/transaction/src/core/TransactionContext.ts new file mode 100644 index 00000000..41b281a3 --- /dev/null +++ b/packages/framework/transaction/src/core/TransactionContext.ts @@ -0,0 +1,286 @@ +/** + * @zh 事务上下文实现 + * @en Transaction context implementation + */ + +import type { + ITransactionContext, + ITransactionOperation, + ITransactionStorage, + TransactionState, + TransactionResult, + TransactionOptions, + TransactionLog, + OperationLog, + OperationResult, +} from './types.js' + +/** + * @zh 生成唯一 ID + * @en Generate unique ID + */ +function generateId(): string { + return `tx_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 11)}` +} + +/** + * @zh 事务上下文 + * @en Transaction context + * + * @zh 封装事务的状态、操作和执行逻辑 + * @en Encapsulates transaction state, operations, and execution logic + * + * @example + * ```typescript + * const ctx = new TransactionContext({ timeout: 5000 }) + * ctx.addOperation(new DeductCurrency({ playerId: '1', amount: 100 })) + * ctx.addOperation(new AddItem({ playerId: '1', itemId: 'sword' })) + * const result = await ctx.execute() + * ``` + */ +export class TransactionContext implements ITransactionContext { + private _id: string + private _state: TransactionState = 'pending' + private _timeout: number + private _operations: ITransactionOperation[] = [] + private _storage: ITransactionStorage | null + private _metadata: Record + private _contextData: Map = new Map() + private _startTime: number = 0 + private _distributed: boolean + + constructor(options: TransactionOptions & { storage?: ITransactionStorage } = {}) { + this._id = generateId() + this._timeout = options.timeout ?? 30000 + this._storage = options.storage ?? null + this._metadata = options.metadata ?? {} + this._distributed = options.distributed ?? false + } + + // ========================================================================= + // 只读属性 | Readonly properties + // ========================================================================= + + get id(): string { + return this._id + } + + get state(): TransactionState { + return this._state + } + + get timeout(): number { + return this._timeout + } + + get operations(): ReadonlyArray { + return this._operations + } + + get storage(): ITransactionStorage | null { + return this._storage + } + + get metadata(): Record { + return this._metadata + } + + // ========================================================================= + // 公共方法 | Public methods + // ========================================================================= + + /** + * @zh 添加操作 + * @en Add operation + */ + addOperation(operation: T): this { + if (this._state !== 'pending') { + throw new Error(`Cannot add operation to transaction in state: ${this._state}`) + } + this._operations.push(operation) + return this + } + + /** + * @zh 执行事务 + * @en Execute transaction + */ + async execute(): Promise> { + if (this._state !== 'pending') { + return { + success: false, + transactionId: this._id, + results: [], + error: `Transaction already in state: ${this._state}`, + duration: 0, + } + } + + this._startTime = Date.now() + this._state = 'executing' + + const results: OperationResult[] = [] + let executedCount = 0 + + try { + await this._saveLog() + + for (let i = 0; i < this._operations.length; i++) { + if (this._isTimedOut()) { + throw new Error('Transaction timed out') + } + + const op = this._operations[i] + + const isValid = await op.validate(this) + if (!isValid) { + throw new Error(`Validation failed for operation: ${op.name}`) + } + + const result = await op.execute(this) + results.push(result) + executedCount++ + + await this._updateOperationLog(i, 'executed') + + if (!result.success) { + throw new Error(result.error ?? `Operation ${op.name} failed`) + } + } + + this._state = 'committed' + await this._updateTransactionState('committed') + + return { + success: true, + transactionId: this._id, + results, + data: this._collectResultData(results) as T, + duration: Date.now() - this._startTime, + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + + await this._compensate(executedCount - 1) + + return { + success: false, + transactionId: this._id, + results, + error: errorMessage, + duration: Date.now() - this._startTime, + } + } + } + + /** + * @zh 手动回滚事务 + * @en Manually rollback transaction + */ + async rollback(): Promise { + if (this._state === 'committed' || this._state === 'rolledback') { + return + } + + await this._compensate(this._operations.length - 1) + } + + /** + * @zh 获取上下文数据 + * @en Get context data + */ + get(key: string): T | undefined { + return this._contextData.get(key) as T | undefined + } + + /** + * @zh 设置上下文数据 + * @en Set context data + */ + set(key: string, value: T): void { + this._contextData.set(key, value) + } + + // ========================================================================= + // 私有方法 | Private methods + // ========================================================================= + + private _isTimedOut(): boolean { + return Date.now() - this._startTime > this._timeout + } + + private async _compensate(fromIndex: number): Promise { + this._state = 'rolledback' + + for (let i = fromIndex; i >= 0; i--) { + const op = this._operations[i] + try { + await op.compensate(this) + await this._updateOperationLog(i, 'compensated') + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + await this._updateOperationLog(i, 'failed', errorMessage) + } + } + + await this._updateTransactionState('rolledback') + } + + private async _saveLog(): Promise { + if (!this._storage) return + + const log: TransactionLog = { + id: this._id, + state: this._state, + createdAt: this._startTime, + updatedAt: this._startTime, + timeout: this._timeout, + operations: this._operations.map((op) => ({ + name: op.name, + data: op.data, + state: 'pending' as const, + })), + metadata: this._metadata, + distributed: this._distributed, + } + + await this._storage.saveTransaction(log) + } + + private async _updateTransactionState(state: TransactionState): Promise { + this._state = state + if (this._storage) { + await this._storage.updateTransactionState(this._id, state) + } + } + + private async _updateOperationLog( + index: number, + state: OperationLog['state'], + error?: string + ): Promise { + if (this._storage) { + await this._storage.updateOperationState(this._id, index, state, error) + } + } + + private _collectResultData(results: OperationResult[]): unknown { + const data: Record = {} + for (const result of results) { + if (result.data !== undefined) { + Object.assign(data, result.data) + } + } + return Object.keys(data).length > 0 ? data : undefined + } +} + +/** + * @zh 创建事务上下文 + * @en Create transaction context + */ +export function createTransactionContext( + options: TransactionOptions & { storage?: ITransactionStorage } = {} +): ITransactionContext { + return new TransactionContext(options) +} diff --git a/packages/framework/transaction/src/core/TransactionManager.ts b/packages/framework/transaction/src/core/TransactionManager.ts new file mode 100644 index 00000000..591f91b4 --- /dev/null +++ b/packages/framework/transaction/src/core/TransactionManager.ts @@ -0,0 +1,255 @@ +/** + * @zh 事务管理器 + * @en Transaction manager + */ + +import type { + ITransactionContext, + ITransactionStorage, + TransactionManagerConfig, + TransactionOptions, + TransactionLog, + TransactionResult, +} from './types.js' +import { TransactionContext } from './TransactionContext.js' + +/** + * @zh 事务管理器 + * @en Transaction manager + * + * @zh 管理事务的创建、执行和恢复 + * @en Manages transaction creation, execution, and recovery + * + * @example + * ```typescript + * const manager = new TransactionManager({ + * storage: new RedisStorage({ url: 'redis://localhost:6379' }), + * defaultTimeout: 10000, + * }) + * + * const tx = manager.begin({ timeout: 5000 }) + * tx.addOperation(new DeductCurrency({ ... })) + * tx.addOperation(new AddItem({ ... })) + * + * const result = await tx.execute() + * ``` + */ +export class TransactionManager { + private _storage: ITransactionStorage | null + private _defaultTimeout: number + private _serverId: string + private _autoRecover: boolean + private _activeTransactions: Map = new Map() + + constructor(config: TransactionManagerConfig = {}) { + this._storage = config.storage ?? null + this._defaultTimeout = config.defaultTimeout ?? 30000 + this._serverId = config.serverId ?? this._generateServerId() + this._autoRecover = config.autoRecover ?? true + } + + // ========================================================================= + // 只读属性 | Readonly properties + // ========================================================================= + + /** + * @zh 服务器 ID + * @en Server ID + */ + get serverId(): string { + return this._serverId + } + + /** + * @zh 存储实例 + * @en Storage instance + */ + get storage(): ITransactionStorage | null { + return this._storage + } + + /** + * @zh 活跃事务数量 + * @en Active transaction count + */ + get activeCount(): number { + return this._activeTransactions.size + } + + // ========================================================================= + // 公共方法 | Public methods + // ========================================================================= + + /** + * @zh 开始新事务 + * @en Begin new transaction + * + * @param options - @zh 事务选项 @en Transaction options + * @returns @zh 事务上下文 @en Transaction context + */ + begin(options: TransactionOptions = {}): ITransactionContext { + const ctx = new TransactionContext({ + timeout: options.timeout ?? this._defaultTimeout, + storage: this._storage ?? undefined, + metadata: { + ...options.metadata, + serverId: this._serverId, + }, + distributed: options.distributed, + }) + + this._activeTransactions.set(ctx.id, ctx) + + return ctx + } + + /** + * @zh 执行事务(便捷方法) + * @en Execute transaction (convenience method) + * + * @param builder - @zh 事务构建函数 @en Transaction builder function + * @param options - @zh 事务选项 @en Transaction options + * @returns @zh 事务结果 @en Transaction result + */ + async run( + builder: (ctx: ITransactionContext) => void | Promise, + options: TransactionOptions = {} + ): Promise> { + const ctx = this.begin(options) + + try { + await builder(ctx) + const result = await ctx.execute() + return result + } finally { + this._activeTransactions.delete(ctx.id) + } + } + + /** + * @zh 获取活跃事务 + * @en Get active transaction + */ + getTransaction(id: string): ITransactionContext | undefined { + return this._activeTransactions.get(id) + } + + /** + * @zh 恢复未完成的事务 + * @en Recover pending transactions + */ + async recover(): Promise { + if (!this._storage) return 0 + + const pendingTransactions = await this._storage.getPendingTransactions(this._serverId) + let recoveredCount = 0 + + for (const log of pendingTransactions) { + try { + await this._recoverTransaction(log) + recoveredCount++ + } catch (error) { + console.error(`Failed to recover transaction ${log.id}:`, error) + } + } + + return recoveredCount + } + + /** + * @zh 获取分布式锁 + * @en Acquire distributed lock + */ + async acquireLock(key: string, ttl: number = 10000): Promise { + if (!this._storage) return null + return this._storage.acquireLock(key, ttl) + } + + /** + * @zh 释放分布式锁 + * @en Release distributed lock + */ + async releaseLock(key: string, token: string): Promise { + if (!this._storage) return false + return this._storage.releaseLock(key, token) + } + + /** + * @zh 使用分布式锁执行 + * @en Execute with distributed lock + */ + async withLock( + key: string, + fn: () => Promise, + ttl: number = 10000 + ): Promise { + const token = await this.acquireLock(key, ttl) + if (!token) { + throw new Error(`Failed to acquire lock for key: ${key}`) + } + + try { + return await fn() + } finally { + await this.releaseLock(key, token) + } + } + + /** + * @zh 清理已完成的事务日志 + * @en Clean up completed transaction logs + */ + async cleanup(beforeTimestamp?: number): Promise { + if (!this._storage) return 0 + + const timestamp = beforeTimestamp ?? Date.now() - 24 * 60 * 60 * 1000 // 默认清理24小时前 + + const pendingTransactions = await this._storage.getPendingTransactions() + let cleanedCount = 0 + + for (const log of pendingTransactions) { + if ( + log.createdAt < timestamp && + (log.state === 'committed' || log.state === 'rolledback') + ) { + await this._storage.deleteTransaction(log.id) + cleanedCount++ + } + } + + return cleanedCount + } + + // ========================================================================= + // 私有方法 | Private methods + // ========================================================================= + + private _generateServerId(): string { + return `server_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}` + } + + private async _recoverTransaction(log: TransactionLog): Promise { + if (log.state === 'executing') { + const executedOps = log.operations.filter((op) => op.state === 'executed') + + if (executedOps.length > 0 && this._storage) { + for (let i = executedOps.length - 1; i >= 0; i--) { + await this._storage.updateOperationState(log.id, i, 'compensated') + } + await this._storage.updateTransactionState(log.id, 'rolledback') + } else { + await this._storage?.updateTransactionState(log.id, 'failed') + } + } + } +} + +/** + * @zh 创建事务管理器 + * @en Create transaction manager + */ +export function createTransactionManager( + config: TransactionManagerConfig = {} +): TransactionManager { + return new TransactionManager(config) +} diff --git a/packages/framework/transaction/src/core/index.ts b/packages/framework/transaction/src/core/index.ts new file mode 100644 index 00000000..08a02191 --- /dev/null +++ b/packages/framework/transaction/src/core/index.ts @@ -0,0 +1,20 @@ +/** + * @zh 核心模块导出 + * @en Core module exports + */ + +export type { + TransactionState, + OperationResult, + TransactionResult, + OperationLog, + TransactionLog, + TransactionOptions, + TransactionManagerConfig, + ITransactionStorage, + ITransactionOperation, + ITransactionContext, +} from './types.js' + +export { TransactionContext, createTransactionContext } from './TransactionContext.js' +export { TransactionManager, createTransactionManager } from './TransactionManager.js' diff --git a/packages/framework/transaction/src/core/types.ts b/packages/framework/transaction/src/core/types.ts new file mode 100644 index 00000000..c11db776 --- /dev/null +++ b/packages/framework/transaction/src/core/types.ts @@ -0,0 +1,484 @@ +/** + * @zh 事务系统核心类型定义 + * @en Transaction system core type definitions + */ + +// ============================================================================= +// 事务状态 | Transaction State +// ============================================================================= + +/** + * @zh 事务状态 + * @en Transaction state + */ +export type TransactionState = + | 'pending' // 等待执行 | Waiting to execute + | 'executing' // 执行中 | Executing + | 'committed' // 已提交 | Committed + | 'rolledback' // 已回滚 | Rolled back + | 'failed' // 失败 | Failed + +// ============================================================================= +// 操作结果 | Operation Result +// ============================================================================= + +/** + * @zh 操作结果 + * @en Operation result + */ +export interface OperationResult { + /** + * @zh 是否成功 + * @en Whether succeeded + */ + success: boolean + + /** + * @zh 返回数据 + * @en Return data + */ + data?: T + + /** + * @zh 错误信息 + * @en Error message + */ + error?: string + + /** + * @zh 错误代码 + * @en Error code + */ + errorCode?: string +} + +/** + * @zh 事务结果 + * @en Transaction result + */ +export interface TransactionResult { + /** + * @zh 是否成功 + * @en Whether succeeded + */ + success: boolean + + /** + * @zh 事务 ID + * @en Transaction ID + */ + transactionId: string + + /** + * @zh 操作结果列表 + * @en Operation results + */ + results: OperationResult[] + + /** + * @zh 最终数据 + * @en Final data + */ + data?: T + + /** + * @zh 错误信息 + * @en Error message + */ + error?: string + + /** + * @zh 执行时间(毫秒) + * @en Execution time in milliseconds + */ + duration: number +} + +// ============================================================================= +// 事务日志 | Transaction Log +// ============================================================================= + +/** + * @zh 操作日志 + * @en Operation log + */ +export interface OperationLog { + /** + * @zh 操作名称 + * @en Operation name + */ + name: string + + /** + * @zh 操作数据 + * @en Operation data + */ + data: unknown + + /** + * @zh 操作状态 + * @en Operation state + */ + state: 'pending' | 'executed' | 'compensated' | 'failed' + + /** + * @zh 执行时间 + * @en Execution timestamp + */ + executedAt?: number + + /** + * @zh 补偿时间 + * @en Compensation timestamp + */ + compensatedAt?: number + + /** + * @zh 错误信息 + * @en Error message + */ + error?: string +} + +/** + * @zh 事务日志 + * @en Transaction log + */ +export interface TransactionLog { + /** + * @zh 事务 ID + * @en Transaction ID + */ + id: string + + /** + * @zh 事务状态 + * @en Transaction state + */ + state: TransactionState + + /** + * @zh 创建时间 + * @en Creation timestamp + */ + createdAt: number + + /** + * @zh 更新时间 + * @en Update timestamp + */ + updatedAt: number + + /** + * @zh 超时时间(毫秒) + * @en Timeout in milliseconds + */ + timeout: number + + /** + * @zh 操作日志列表 + * @en Operation logs + */ + operations: OperationLog[] + + /** + * @zh 元数据 + * @en Metadata + */ + metadata?: Record + + /** + * @zh 是否分布式事务 + * @en Whether distributed transaction + */ + distributed?: boolean + + /** + * @zh 参与的服务器列表 + * @en Participating servers + */ + participants?: string[] +} + +// ============================================================================= +// 事务配置 | Transaction Configuration +// ============================================================================= + +/** + * @zh 事务选项 + * @en Transaction options + */ +export interface TransactionOptions { + /** + * @zh 超时时间(毫秒),默认 30000 + * @en Timeout in milliseconds, default 30000 + */ + timeout?: number + + /** + * @zh 是否分布式事务 + * @en Whether distributed transaction + */ + distributed?: boolean + + /** + * @zh 元数据 + * @en Metadata + */ + metadata?: Record + + /** + * @zh 重试次数,默认 0 + * @en Retry count, default 0 + */ + retryCount?: number + + /** + * @zh 重试间隔(毫秒),默认 1000 + * @en Retry interval in milliseconds, default 1000 + */ + retryInterval?: number +} + +/** + * @zh 事务管理器配置 + * @en Transaction manager configuration + */ +export interface TransactionManagerConfig { + /** + * @zh 存储实例 + * @en Storage instance + */ + storage?: ITransactionStorage + + /** + * @zh 默认超时时间(毫秒) + * @en Default timeout in milliseconds + */ + defaultTimeout?: number + + /** + * @zh 服务器 ID(分布式用) + * @en Server ID for distributed transactions + */ + serverId?: string + + /** + * @zh 是否自动恢复未完成事务 + * @en Whether to auto-recover pending transactions + */ + autoRecover?: boolean +} + +// ============================================================================= +// 存储接口 | Storage Interface +// ============================================================================= + +/** + * @zh 事务存储接口 + * @en Transaction storage interface + */ +export interface ITransactionStorage { + /** + * @zh 获取分布式锁 + * @en Acquire distributed lock + * + * @param key - @zh 锁的键 @en Lock key + * @param ttl - @zh 锁的生存时间(毫秒) @en Lock TTL in milliseconds + * @returns @zh 锁令牌,获取失败返回 null @en Lock token, null if failed + */ + acquireLock(key: string, ttl: number): Promise + + /** + * @zh 释放分布式锁 + * @en Release distributed lock + * + * @param key - @zh 锁的键 @en Lock key + * @param token - @zh 锁令牌 @en Lock token + * @returns @zh 是否成功释放 @en Whether released successfully + */ + releaseLock(key: string, token: string): Promise + + /** + * @zh 保存事务日志 + * @en Save transaction log + */ + saveTransaction(tx: TransactionLog): Promise + + /** + * @zh 获取事务日志 + * @en Get transaction log + */ + getTransaction(id: string): Promise + + /** + * @zh 更新事务状态 + * @en Update transaction state + */ + updateTransactionState(id: string, state: TransactionState): Promise + + /** + * @zh 更新操作状态 + * @en Update operation state + */ + updateOperationState( + transactionId: string, + operationIndex: number, + state: OperationLog['state'], + error?: string + ): Promise + + /** + * @zh 获取待恢复的事务列表 + * @en Get pending transactions for recovery + */ + getPendingTransactions(serverId?: string): Promise + + /** + * @zh 删除事务日志 + * @en Delete transaction log + */ + deleteTransaction(id: string): Promise + + /** + * @zh 获取数据 + * @en Get data + */ + get(key: string): Promise + + /** + * @zh 设置数据 + * @en Set data + */ + set(key: string, value: T, ttl?: number): Promise + + /** + * @zh 删除数据 + * @en Delete data + */ + delete(key: string): Promise +} + +// ============================================================================= +// 操作接口 | Operation Interface +// ============================================================================= + +/** + * @zh 事务操作接口 + * @en Transaction operation interface + */ +export interface ITransactionOperation { + /** + * @zh 操作名称 + * @en Operation name + */ + readonly name: string + + /** + * @zh 操作数据 + * @en Operation data + */ + readonly data: TData + + /** + * @zh 验证前置条件 + * @en Validate preconditions + * + * @param ctx - @zh 事务上下文 @en Transaction context + * @returns @zh 是否验证通过 @en Whether validation passed + */ + validate(ctx: ITransactionContext): Promise + + /** + * @zh 执行操作 + * @en Execute operation + * + * @param ctx - @zh 事务上下文 @en Transaction context + * @returns @zh 操作结果 @en Operation result + */ + execute(ctx: ITransactionContext): Promise> + + /** + * @zh 补偿操作(回滚) + * @en Compensate operation (rollback) + * + * @param ctx - @zh 事务上下文 @en Transaction context + */ + compensate(ctx: ITransactionContext): Promise +} + +// ============================================================================= +// 事务上下文接口 | Transaction Context Interface +// ============================================================================= + +/** + * @zh 事务上下文接口 + * @en Transaction context interface + */ +export interface ITransactionContext { + /** + * @zh 事务 ID + * @en Transaction ID + */ + readonly id: string + + /** + * @zh 事务状态 + * @en Transaction state + */ + readonly state: TransactionState + + /** + * @zh 超时时间(毫秒) + * @en Timeout in milliseconds + */ + readonly timeout: number + + /** + * @zh 操作列表 + * @en Operations + */ + readonly operations: ReadonlyArray + + /** + * @zh 存储实例 + * @en Storage instance + */ + readonly storage: ITransactionStorage | null + + /** + * @zh 元数据 + * @en Metadata + */ + readonly metadata: Record + + /** + * @zh 添加操作 + * @en Add operation + */ + addOperation(operation: T): this + + /** + * @zh 执行事务 + * @en Execute transaction + */ + execute(): Promise> + + /** + * @zh 回滚事务 + * @en Rollback transaction + */ + rollback(): Promise + + /** + * @zh 获取上下文数据 + * @en Get context data + */ + get(key: string): T | undefined + + /** + * @zh 设置上下文数据 + * @en Set context data + */ + set(key: string, value: T): void +} diff --git a/packages/framework/transaction/src/distributed/SagaOrchestrator.ts b/packages/framework/transaction/src/distributed/SagaOrchestrator.ts new file mode 100644 index 00000000..d88efb68 --- /dev/null +++ b/packages/framework/transaction/src/distributed/SagaOrchestrator.ts @@ -0,0 +1,350 @@ +/** + * @zh Saga 编排器 + * @en Saga Orchestrator + * + * @zh 实现分布式事务的 Saga 模式编排 + * @en Implements Saga pattern orchestration for distributed transactions + */ + +import type { + ITransactionStorage, + TransactionLog, + TransactionState, + OperationResult, +} from '../core/types.js' + +/** + * @zh Saga 步骤状态 + * @en Saga step state + */ +export type SagaStepState = 'pending' | 'executing' | 'completed' | 'compensating' | 'compensated' | 'failed' + +/** + * @zh Saga 步骤 + * @en Saga step + */ +export interface SagaStep { + /** + * @zh 步骤名称 + * @en Step name + */ + name: string + + /** + * @zh 目标服务器 ID(分布式用) + * @en Target server ID (for distributed) + */ + serverId?: string + + /** + * @zh 执行函数 + * @en Execute function + */ + execute: (data: T) => Promise + + /** + * @zh 补偿函数 + * @en Compensate function + */ + compensate: (data: T) => Promise + + /** + * @zh 步骤数据 + * @en Step data + */ + data: T +} + +/** + * @zh Saga 步骤日志 + * @en Saga step log + */ +export interface SagaStepLog { + name: string + serverId?: string + state: SagaStepState + startedAt?: number + completedAt?: number + error?: string +} + +/** + * @zh Saga 日志 + * @en Saga log + */ +export interface SagaLog { + id: string + state: 'pending' | 'running' | 'completed' | 'compensating' | 'compensated' | 'failed' + steps: SagaStepLog[] + createdAt: number + updatedAt: number + metadata?: Record +} + +/** + * @zh Saga 结果 + * @en Saga result + */ +export interface SagaResult { + success: boolean + sagaId: string + completedSteps: string[] + failedStep?: string + error?: string + duration: number +} + +/** + * @zh Saga 编排器配置 + * @en Saga orchestrator configuration + */ +export interface SagaOrchestratorConfig { + /** + * @zh 存储实例 + * @en Storage instance + */ + storage?: ITransactionStorage + + /** + * @zh 默认超时时间(毫秒) + * @en Default timeout in milliseconds + */ + timeout?: number + + /** + * @zh 服务器 ID + * @en Server ID + */ + serverId?: string +} + +/** + * @zh 生成 Saga ID + * @en Generate Saga ID + */ +function generateSagaId(): string { + return `saga_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 11)}` +} + +/** + * @zh Saga 编排器 + * @en Saga Orchestrator + * + * @zh 管理分布式事务的 Saga 模式执行流程 + * @en Manages Saga pattern execution flow for distributed transactions + * + * @example + * ```typescript + * const orchestrator = new SagaOrchestrator({ + * storage: redisStorage, + * serverId: 'server1', + * }) + * + * const result = await orchestrator.execute([ + * { + * name: 'deduct_currency', + * serverId: 'server1', + * execute: async (data) => { + * // 扣除货币 + * return { success: true } + * }, + * compensate: async (data) => { + * // 恢复货币 + * }, + * data: { playerId: '1', amount: 100 }, + * }, + * { + * name: 'add_item', + * serverId: 'server2', + * execute: async (data) => { + * // 添加物品 + * return { success: true } + * }, + * compensate: async (data) => { + * // 移除物品 + * }, + * data: { playerId: '1', itemId: 'sword' }, + * }, + * ]) + * ``` + */ +export class SagaOrchestrator { + private _storage: ITransactionStorage | null + private _timeout: number + private _serverId: string + + constructor(config: SagaOrchestratorConfig = {}) { + this._storage = config.storage ?? null + this._timeout = config.timeout ?? 30000 + this._serverId = config.serverId ?? 'default' + } + + /** + * @zh 执行 Saga + * @en Execute Saga + */ + async execute(steps: SagaStep[]): Promise { + const sagaId = generateSagaId() + const startTime = Date.now() + const completedSteps: string[] = [] + + const sagaLog: SagaLog = { + id: sagaId, + state: 'pending', + steps: steps.map((s) => ({ + name: s.name, + serverId: s.serverId, + state: 'pending' as SagaStepState, + })), + createdAt: startTime, + updatedAt: startTime, + metadata: { orchestratorServerId: this._serverId }, + } + + await this._saveSagaLog(sagaLog) + + try { + sagaLog.state = 'running' + await this._saveSagaLog(sagaLog) + + for (let i = 0; i < steps.length; i++) { + const step = steps[i] + + if (Date.now() - startTime > this._timeout) { + throw new Error('Saga execution timed out') + } + + sagaLog.steps[i].state = 'executing' + sagaLog.steps[i].startedAt = Date.now() + await this._saveSagaLog(sagaLog) + + const result = await step.execute(step.data) + + if (!result.success) { + sagaLog.steps[i].state = 'failed' + sagaLog.steps[i].error = result.error + await this._saveSagaLog(sagaLog) + + throw new Error(result.error ?? `Step ${step.name} failed`) + } + + sagaLog.steps[i].state = 'completed' + sagaLog.steps[i].completedAt = Date.now() + completedSteps.push(step.name) + await this._saveSagaLog(sagaLog) + } + + sagaLog.state = 'completed' + sagaLog.updatedAt = Date.now() + await this._saveSagaLog(sagaLog) + + return { + success: true, + sagaId, + completedSteps, + duration: Date.now() - startTime, + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + const failedStepIndex = completedSteps.length + + sagaLog.state = 'compensating' + await this._saveSagaLog(sagaLog) + + for (let i = completedSteps.length - 1; i >= 0; i--) { + const step = steps[i] + + sagaLog.steps[i].state = 'compensating' + await this._saveSagaLog(sagaLog) + + try { + await step.compensate(step.data) + sagaLog.steps[i].state = 'compensated' + } catch (compError) { + const compErrorMessage = compError instanceof Error ? compError.message : String(compError) + sagaLog.steps[i].state = 'failed' + sagaLog.steps[i].error = `Compensation failed: ${compErrorMessage}` + } + + await this._saveSagaLog(sagaLog) + } + + sagaLog.state = 'compensated' + sagaLog.updatedAt = Date.now() + await this._saveSagaLog(sagaLog) + + return { + success: false, + sagaId, + completedSteps, + failedStep: steps[failedStepIndex]?.name, + error: errorMessage, + duration: Date.now() - startTime, + } + } + } + + /** + * @zh 恢复未完成的 Saga + * @en Recover pending Sagas + */ + async recover(): Promise { + if (!this._storage) return 0 + + const pendingSagas = await this._getPendingSagas() + let recoveredCount = 0 + + for (const saga of pendingSagas) { + try { + await this._recoverSaga(saga) + recoveredCount++ + } catch (error) { + console.error(`Failed to recover saga ${saga.id}:`, error) + } + } + + return recoveredCount + } + + /** + * @zh 获取 Saga 日志 + * @en Get Saga log + */ + async getSagaLog(sagaId: string): Promise { + if (!this._storage) return null + return this._storage.get(`saga:${sagaId}`) + } + + private async _saveSagaLog(log: SagaLog): Promise { + if (!this._storage) return + log.updatedAt = Date.now() + await this._storage.set(`saga:${log.id}`, log) + } + + private async _getPendingSagas(): Promise { + return [] + } + + private async _recoverSaga(saga: SagaLog): Promise { + if (saga.state === 'running' || saga.state === 'compensating') { + const completedSteps = saga.steps + .filter((s) => s.state === 'completed') + .map((s) => s.name) + + saga.state = 'compensated' + saga.updatedAt = Date.now() + + if (this._storage) { + await this._storage.set(`saga:${saga.id}`, saga) + } + } + } +} + +/** + * @zh 创建 Saga 编排器 + * @en Create Saga orchestrator + */ +export function createSagaOrchestrator(config: SagaOrchestratorConfig = {}): SagaOrchestrator { + return new SagaOrchestrator(config) +} diff --git a/packages/framework/transaction/src/distributed/index.ts b/packages/framework/transaction/src/distributed/index.ts new file mode 100644 index 00000000..7cf4d56e --- /dev/null +++ b/packages/framework/transaction/src/distributed/index.ts @@ -0,0 +1,15 @@ +/** + * @zh 分布式模块导出 + * @en Distributed module exports + */ + +export { + SagaOrchestrator, + createSagaOrchestrator, + type SagaOrchestratorConfig, + type SagaStep, + type SagaStepState, + type SagaStepLog, + type SagaLog, + type SagaResult, +} from './SagaOrchestrator.js' diff --git a/packages/framework/transaction/src/index.ts b/packages/framework/transaction/src/index.ts new file mode 100644 index 00000000..717335d7 --- /dev/null +++ b/packages/framework/transaction/src/index.ts @@ -0,0 +1,165 @@ +/** + * @zh @esengine/transaction 事务系统 + * @en @esengine/transaction Transaction System + * + * @zh 提供游戏事务处理能力,支持商店购买、玩家交易、分布式事务 + * @en Provides game transaction capabilities, supporting shop purchases, player trading, and distributed transactions + * + * @example + * ```typescript + * import { + * TransactionManager, + * MemoryStorage, + * CurrencyOperation, + * InventoryOperation, + * } from '@esengine/transaction' + * + * // 创建事务管理器 + * const manager = new TransactionManager({ + * storage: new MemoryStorage(), + * }) + * + * // 执行事务 + * const result = await manager.run((tx) => { + * tx.addOperation(new CurrencyOperation({ + * type: 'deduct', + * playerId: 'player1', + * currency: 'gold', + * amount: 100, + * })) + * tx.addOperation(new InventoryOperation({ + * type: 'add', + * playerId: 'player1', + * itemId: 'sword', + * quantity: 1, + * })) + * }) + * + * if (result.success) { + * console.log('Transaction completed!') + * } + * ``` + */ + +// ============================================================================= +// Core | 核心 +// ============================================================================= + +export type { + TransactionState, + OperationResult, + TransactionResult, + OperationLog, + TransactionLog, + TransactionOptions, + TransactionManagerConfig, + ITransactionStorage, + ITransactionOperation, + ITransactionContext, +} from './core/types.js' + +export { + TransactionContext, + createTransactionContext, +} from './core/TransactionContext.js' + +export { + TransactionManager, + createTransactionManager, +} from './core/TransactionManager.js' + +// ============================================================================= +// Storage | 存储 +// ============================================================================= + +export { + MemoryStorage, + createMemoryStorage, + type MemoryStorageConfig, +} from './storage/MemoryStorage.js' + +export { + RedisStorage, + createRedisStorage, + type RedisStorageConfig, + type RedisClient, +} from './storage/RedisStorage.js' + +export { + MongoStorage, + createMongoStorage, + type MongoStorageConfig, + type MongoDb, + type MongoCollection, +} from './storage/MongoStorage.js' + +// ============================================================================= +// Operations | 操作 +// ============================================================================= + +export { BaseOperation } from './operations/BaseOperation.js' + +export { + CurrencyOperation, + createCurrencyOperation, + type CurrencyOperationType, + type CurrencyOperationData, + type CurrencyOperationResult, + type ICurrencyProvider, +} from './operations/CurrencyOperation.js' + +export { + InventoryOperation, + createInventoryOperation, + type InventoryOperationType, + type InventoryOperationData, + type InventoryOperationResult, + type IInventoryProvider, + type ItemData, +} from './operations/InventoryOperation.js' + +export { + TradeOperation, + createTradeOperation, + type TradeOperationData, + type TradeOperationResult, + type TradeItem, + type TradeCurrency, + type TradeParty, + type ITradeProvider, +} from './operations/TradeOperation.js' + +// ============================================================================= +// Distributed | 分布式 +// ============================================================================= + +export { + SagaOrchestrator, + createSagaOrchestrator, + type SagaOrchestratorConfig, + type SagaStep, + type SagaStepState, + type SagaStepLog, + type SagaLog, + type SagaResult, +} from './distributed/SagaOrchestrator.js' + +// ============================================================================= +// Integration | 集成 +// ============================================================================= + +export { + withTransactions, + TransactionRoom, + type TransactionRoomConfig, + type ITransactionRoom, +} from './integration/RoomTransactionMixin.js' + +// ============================================================================= +// Tokens | 令牌 +// ============================================================================= + +export { + TransactionManagerToken, + TransactionStorageToken, +} from './tokens.js' diff --git a/packages/framework/transaction/src/integration/RoomTransactionMixin.ts b/packages/framework/transaction/src/integration/RoomTransactionMixin.ts new file mode 100644 index 00000000..3eee7a60 --- /dev/null +++ b/packages/framework/transaction/src/integration/RoomTransactionMixin.ts @@ -0,0 +1,174 @@ +/** + * @zh Room 事务扩展 + * @en Room transaction extension + */ + +import type { + ITransactionStorage, + ITransactionContext, + TransactionOptions, + TransactionResult, +} from '../core/types.js' +import { TransactionManager } from '../core/TransactionManager.js' + +/** + * @zh 事务 Room 配置 + * @en Transaction Room configuration + */ +export interface TransactionRoomConfig { + /** + * @zh 存储实例 + * @en Storage instance + */ + storage?: ITransactionStorage + + /** + * @zh 默认超时时间(毫秒) + * @en Default timeout in milliseconds + */ + defaultTimeout?: number + + /** + * @zh 服务器 ID + * @en Server ID + */ + serverId?: string +} + +/** + * @zh 事务 Room 接口 + * @en Transaction Room interface + */ +export interface ITransactionRoom { + /** + * @zh 事务管理器 + * @en Transaction manager + */ + readonly transactions: TransactionManager + + /** + * @zh 开始事务 + * @en Begin transaction + */ + beginTransaction(options?: TransactionOptions): ITransactionContext + + /** + * @zh 执行事务 + * @en Run transaction + */ + runTransaction( + builder: (ctx: ITransactionContext) => void | Promise, + options?: TransactionOptions + ): Promise> +} + +/** + * @zh 创建事务 Room mixin + * @en Create transaction Room mixin + * + * @example + * ```typescript + * import { Room } from '@esengine/server' + * import { withTransactions, RedisStorage } from '@esengine/transaction' + * + * class GameRoom extends withTransactions(Room, { + * storage: new RedisStorage({ client: redisClient }), + * }) { + * async handleBuy(itemId: string, player: Player) { + * const result = await this.runTransaction((tx) => { + * tx.addOperation(new CurrencyOperation({ + * type: 'deduct', + * playerId: player.id, + * currency: 'gold', + * amount: 100, + * })) + * }) + * + * if (result.success) { + * player.send('buy_success', { itemId }) + * } + * } + * } + * ``` + */ +export function withTransactions any>( + Base: TBase, + config: TransactionRoomConfig = {} +): TBase & (new (...args: any[]) => ITransactionRoom) { + return class TransactionRoom extends Base implements ITransactionRoom { + private _transactionManager: TransactionManager + + constructor(...args: any[]) { + super(...args) + this._transactionManager = new TransactionManager({ + storage: config.storage, + defaultTimeout: config.defaultTimeout, + serverId: config.serverId, + }) + } + + get transactions(): TransactionManager { + return this._transactionManager + } + + beginTransaction(options?: TransactionOptions): ITransactionContext { + return this._transactionManager.begin(options) + } + + runTransaction( + builder: (ctx: ITransactionContext) => void | Promise, + options?: TransactionOptions + ): Promise> { + return this._transactionManager.run(builder, options) + } + } +} + +/** + * @zh 事务 Room 抽象基类 + * @en Transaction Room abstract base class + * + * @zh 可以直接继承使用,也可以使用 withTransactions mixin + * @en Can be extended directly or use withTransactions mixin + * + * @example + * ```typescript + * class GameRoom extends TransactionRoom { + * constructor() { + * super({ storage: new RedisStorage({ client: redisClient }) }) + * } + * + * async handleTrade(data: TradeData, player: Player) { + * const result = await this.runTransaction((tx) => { + * // 添加交易操作 + * }) + * } + * } + * ``` + */ +export abstract class TransactionRoom implements ITransactionRoom { + private _transactionManager: TransactionManager + + constructor(config: TransactionRoomConfig = {}) { + this._transactionManager = new TransactionManager({ + storage: config.storage, + defaultTimeout: config.defaultTimeout, + serverId: config.serverId, + }) + } + + get transactions(): TransactionManager { + return this._transactionManager + } + + beginTransaction(options?: TransactionOptions): ITransactionContext { + return this._transactionManager.begin(options) + } + + runTransaction( + builder: (ctx: ITransactionContext) => void | Promise, + options?: TransactionOptions + ): Promise> { + return this._transactionManager.run(builder, options) + } +} diff --git a/packages/framework/transaction/src/integration/index.ts b/packages/framework/transaction/src/integration/index.ts new file mode 100644 index 00000000..fcd1acc4 --- /dev/null +++ b/packages/framework/transaction/src/integration/index.ts @@ -0,0 +1,11 @@ +/** + * @zh 集成模块导出 + * @en Integration module exports + */ + +export { + withTransactions, + TransactionRoom, + type TransactionRoomConfig, + type ITransactionRoom, +} from './RoomTransactionMixin.js' diff --git a/packages/framework/transaction/src/operations/BaseOperation.ts b/packages/framework/transaction/src/operations/BaseOperation.ts new file mode 100644 index 00000000..55677644 --- /dev/null +++ b/packages/framework/transaction/src/operations/BaseOperation.ts @@ -0,0 +1,64 @@ +/** + * @zh 操作基类 + * @en Base operation class + */ + +import type { + ITransactionOperation, + ITransactionContext, + OperationResult, +} from '../core/types.js' + +/** + * @zh 操作基类 + * @en Base operation class + * + * @zh 提供通用的操作实现模板 + * @en Provides common operation implementation template + */ +export abstract class BaseOperation + implements ITransactionOperation +{ + abstract readonly name: string + readonly data: TData + + constructor(data: TData) { + this.data = data + } + + /** + * @zh 验证前置条件(默认通过) + * @en Validate preconditions (passes by default) + */ + async validate(_ctx: ITransactionContext): Promise { + return true + } + + /** + * @zh 执行操作 + * @en Execute operation + */ + abstract execute(ctx: ITransactionContext): Promise> + + /** + * @zh 补偿操作 + * @en Compensate operation + */ + abstract compensate(ctx: ITransactionContext): Promise + + /** + * @zh 创建成功结果 + * @en Create success result + */ + protected success(data?: TResult): OperationResult { + return { success: true, data } + } + + /** + * @zh 创建失败结果 + * @en Create failure result + */ + protected failure(error: string, errorCode?: string): OperationResult { + return { success: false, error, errorCode } + } +} diff --git a/packages/framework/transaction/src/operations/CurrencyOperation.ts b/packages/framework/transaction/src/operations/CurrencyOperation.ts new file mode 100644 index 00000000..56b4a08b --- /dev/null +++ b/packages/framework/transaction/src/operations/CurrencyOperation.ts @@ -0,0 +1,208 @@ +/** + * @zh 货币操作 + * @en Currency operation + */ + +import type { ITransactionContext, OperationResult } from '../core/types.js' +import { BaseOperation } from './BaseOperation.js' + +/** + * @zh 货币操作类型 + * @en Currency operation type + */ +export type CurrencyOperationType = 'add' | 'deduct' + +/** + * @zh 货币操作数据 + * @en Currency operation data + */ +export interface CurrencyOperationData { + /** + * @zh 操作类型 + * @en Operation type + */ + type: CurrencyOperationType + + /** + * @zh 玩家 ID + * @en Player ID + */ + playerId: string + + /** + * @zh 货币类型(如 gold, diamond 等) + * @en Currency type (e.g., gold, diamond) + */ + currency: string + + /** + * @zh 数量 + * @en Amount + */ + amount: number + + /** + * @zh 原因/来源 + * @en Reason/source + */ + reason?: string +} + +/** + * @zh 货币操作结果 + * @en Currency operation result + */ +export interface CurrencyOperationResult { + /** + * @zh 操作前余额 + * @en Balance before operation + */ + beforeBalance: number + + /** + * @zh 操作后余额 + * @en Balance after operation + */ + afterBalance: number +} + +/** + * @zh 货币数据提供者接口 + * @en Currency data provider interface + */ +export interface ICurrencyProvider { + /** + * @zh 获取货币余额 + * @en Get currency balance + */ + getBalance(playerId: string, currency: string): Promise + + /** + * @zh 设置货币余额 + * @en Set currency balance + */ + setBalance(playerId: string, currency: string, amount: number): Promise +} + +/** + * @zh 货币操作 + * @en Currency operation + * + * @zh 用于处理货币的增加和扣除 + * @en Used for handling currency addition and deduction + * + * @example + * ```typescript + * // 扣除金币 + * tx.addOperation(new CurrencyOperation({ + * type: 'deduct', + * playerId: 'player1', + * currency: 'gold', + * amount: 100, + * reason: 'purchase_item', + * })) + * + * // 增加钻石 + * tx.addOperation(new CurrencyOperation({ + * type: 'add', + * playerId: 'player1', + * currency: 'diamond', + * amount: 50, + * })) + * ``` + */ +export class CurrencyOperation extends BaseOperation { + readonly name = 'currency' + + private _provider: ICurrencyProvider | null = null + private _beforeBalance: number = 0 + + /** + * @zh 设置货币数据提供者 + * @en Set currency data provider + */ + setProvider(provider: ICurrencyProvider): this { + this._provider = provider + return this + } + + async validate(ctx: ITransactionContext): Promise { + if (this.data.amount <= 0) { + return false + } + + if (this.data.type === 'deduct') { + const balance = await this._getBalance(ctx) + return balance >= this.data.amount + } + + return true + } + + async execute(ctx: ITransactionContext): Promise> { + const { type, playerId, currency, amount } = this.data + + this._beforeBalance = await this._getBalance(ctx) + + let afterBalance: number + + if (type === 'add') { + afterBalance = this._beforeBalance + amount + } else { + if (this._beforeBalance < amount) { + return this.failure('Insufficient balance', 'INSUFFICIENT_BALANCE') + } + afterBalance = this._beforeBalance - amount + } + + await this._setBalance(ctx, afterBalance) + + ctx.set(`currency:${playerId}:${currency}:before`, this._beforeBalance) + ctx.set(`currency:${playerId}:${currency}:after`, afterBalance) + + return this.success({ + beforeBalance: this._beforeBalance, + afterBalance, + }) + } + + async compensate(ctx: ITransactionContext): Promise { + await this._setBalance(ctx, this._beforeBalance) + } + + private async _getBalance(ctx: ITransactionContext): Promise { + const { playerId, currency } = this.data + + if (this._provider) { + return this._provider.getBalance(playerId, currency) + } + + if (ctx.storage) { + const balance = await ctx.storage.get(`player:${playerId}:currency:${currency}`) + return balance ?? 0 + } + + return 0 + } + + private async _setBalance(ctx: ITransactionContext, amount: number): Promise { + const { playerId, currency } = this.data + + if (this._provider) { + await this._provider.setBalance(playerId, currency, amount) + return + } + + if (ctx.storage) { + await ctx.storage.set(`player:${playerId}:currency:${currency}`, amount) + } + } +} + +/** + * @zh 创建货币操作 + * @en Create currency operation + */ +export function createCurrencyOperation(data: CurrencyOperationData): CurrencyOperation { + return new CurrencyOperation(data) +} diff --git a/packages/framework/transaction/src/operations/InventoryOperation.ts b/packages/framework/transaction/src/operations/InventoryOperation.ts new file mode 100644 index 00000000..1f0bc93e --- /dev/null +++ b/packages/framework/transaction/src/operations/InventoryOperation.ts @@ -0,0 +1,291 @@ +/** + * @zh 背包操作 + * @en Inventory operation + */ + +import type { ITransactionContext, OperationResult } from '../core/types.js' +import { BaseOperation } from './BaseOperation.js' + +/** + * @zh 背包操作类型 + * @en Inventory operation type + */ +export type InventoryOperationType = 'add' | 'remove' | 'update' + +/** + * @zh 物品数据 + * @en Item data + */ +export interface ItemData { + /** + * @zh 物品 ID + * @en Item ID + */ + itemId: string + + /** + * @zh 数量 + * @en Quantity + */ + quantity: number + + /** + * @zh 物品属性 + * @en Item properties + */ + properties?: Record +} + +/** + * @zh 背包操作数据 + * @en Inventory operation data + */ +export interface InventoryOperationData { + /** + * @zh 操作类型 + * @en Operation type + */ + type: InventoryOperationType + + /** + * @zh 玩家 ID + * @en Player ID + */ + playerId: string + + /** + * @zh 物品 ID + * @en Item ID + */ + itemId: string + + /** + * @zh 数量 + * @en Quantity + */ + quantity: number + + /** + * @zh 物品属性(用于更新) + * @en Item properties (for update) + */ + properties?: Record + + /** + * @zh 原因/来源 + * @en Reason/source + */ + reason?: string +} + +/** + * @zh 背包操作结果 + * @en Inventory operation result + */ +export interface InventoryOperationResult { + /** + * @zh 操作前的物品数据 + * @en Item data before operation + */ + beforeItem?: ItemData + + /** + * @zh 操作后的物品数据 + * @en Item data after operation + */ + afterItem?: ItemData +} + +/** + * @zh 背包数据提供者接口 + * @en Inventory data provider interface + */ +export interface IInventoryProvider { + /** + * @zh 获取物品 + * @en Get item + */ + getItem(playerId: string, itemId: string): Promise + + /** + * @zh 设置物品 + * @en Set item + */ + setItem(playerId: string, itemId: string, item: ItemData | null): Promise + + /** + * @zh 检查背包容量 + * @en Check inventory capacity + */ + hasCapacity?(playerId: string, count: number): Promise +} + +/** + * @zh 背包操作 + * @en Inventory operation + * + * @zh 用于处理物品的添加、移除和更新 + * @en Used for handling item addition, removal, and update + * + * @example + * ```typescript + * // 添加物品 + * tx.addOperation(new InventoryOperation({ + * type: 'add', + * playerId: 'player1', + * itemId: 'sword_001', + * quantity: 1, + * })) + * + * // 移除物品 + * tx.addOperation(new InventoryOperation({ + * type: 'remove', + * playerId: 'player1', + * itemId: 'potion_hp', + * quantity: 5, + * })) + * ``` + */ +export class InventoryOperation extends BaseOperation { + readonly name = 'inventory' + + private _provider: IInventoryProvider | null = null + private _beforeItem: ItemData | null = null + + /** + * @zh 设置背包数据提供者 + * @en Set inventory data provider + */ + setProvider(provider: IInventoryProvider): this { + this._provider = provider + return this + } + + async validate(ctx: ITransactionContext): Promise { + const { type, quantity } = this.data + + if (quantity <= 0) { + return false + } + + if (type === 'remove') { + const item = await this._getItem(ctx) + return item !== null && item.quantity >= quantity + } + + if (type === 'add' && this._provider?.hasCapacity) { + return this._provider.hasCapacity(this.data.playerId, 1) + } + + return true + } + + async execute(ctx: ITransactionContext): Promise> { + const { type, playerId, itemId, quantity, properties } = this.data + + this._beforeItem = await this._getItem(ctx) + + let afterItem: ItemData | null = null + + switch (type) { + case 'add': { + if (this._beforeItem) { + afterItem = { + ...this._beforeItem, + quantity: this._beforeItem.quantity + quantity, + } + } else { + afterItem = { + itemId, + quantity, + properties, + } + } + break + } + + case 'remove': { + if (!this._beforeItem || this._beforeItem.quantity < quantity) { + return this.failure('Insufficient item quantity', 'INSUFFICIENT_ITEM') + } + + const newQuantity = this._beforeItem.quantity - quantity + if (newQuantity > 0) { + afterItem = { + ...this._beforeItem, + quantity: newQuantity, + } + } else { + afterItem = null + } + break + } + + case 'update': { + if (!this._beforeItem) { + return this.failure('Item not found', 'ITEM_NOT_FOUND') + } + + afterItem = { + ...this._beforeItem, + quantity: quantity > 0 ? quantity : this._beforeItem.quantity, + properties: properties ?? this._beforeItem.properties, + } + break + } + } + + await this._setItem(ctx, afterItem) + + ctx.set(`inventory:${playerId}:${itemId}:before`, this._beforeItem) + ctx.set(`inventory:${playerId}:${itemId}:after`, afterItem) + + return this.success({ + beforeItem: this._beforeItem ?? undefined, + afterItem: afterItem ?? undefined, + }) + } + + async compensate(ctx: ITransactionContext): Promise { + await this._setItem(ctx, this._beforeItem) + } + + private async _getItem(ctx: ITransactionContext): Promise { + const { playerId, itemId } = this.data + + if (this._provider) { + return this._provider.getItem(playerId, itemId) + } + + if (ctx.storage) { + return ctx.storage.get(`player:${playerId}:inventory:${itemId}`) + } + + return null + } + + private async _setItem(ctx: ITransactionContext, item: ItemData | null): Promise { + const { playerId, itemId } = this.data + + if (this._provider) { + await this._provider.setItem(playerId, itemId, item) + return + } + + if (ctx.storage) { + if (item) { + await ctx.storage.set(`player:${playerId}:inventory:${itemId}`, item) + } else { + await ctx.storage.delete(`player:${playerId}:inventory:${itemId}`) + } + } + } +} + +/** + * @zh 创建背包操作 + * @en Create inventory operation + */ +export function createInventoryOperation(data: InventoryOperationData): InventoryOperation { + return new InventoryOperation(data) +} diff --git a/packages/framework/transaction/src/operations/TradeOperation.ts b/packages/framework/transaction/src/operations/TradeOperation.ts new file mode 100644 index 00000000..16cb5a09 --- /dev/null +++ b/packages/framework/transaction/src/operations/TradeOperation.ts @@ -0,0 +1,331 @@ +/** + * @zh 交易操作 + * @en Trade operation + */ + +import type { ITransactionContext, OperationResult } from '../core/types.js' +import { BaseOperation } from './BaseOperation.js' +import { CurrencyOperation, type CurrencyOperationData, type ICurrencyProvider } from './CurrencyOperation.js' +import { InventoryOperation, type InventoryOperationData, type IInventoryProvider, type ItemData } from './InventoryOperation.js' + +/** + * @zh 交易物品 + * @en Trade item + */ +export interface TradeItem { + /** + * @zh 物品 ID + * @en Item ID + */ + itemId: string + + /** + * @zh 数量 + * @en Quantity + */ + quantity: number +} + +/** + * @zh 交易货币 + * @en Trade currency + */ +export interface TradeCurrency { + /** + * @zh 货币类型 + * @en Currency type + */ + currency: string + + /** + * @zh 数量 + * @en Amount + */ + amount: number +} + +/** + * @zh 交易方数据 + * @en Trade party data + */ +export interface TradeParty { + /** + * @zh 玩家 ID + * @en Player ID + */ + playerId: string + + /** + * @zh 给出的物品 + * @en Items to give + */ + items?: TradeItem[] + + /** + * @zh 给出的货币 + * @en Currencies to give + */ + currencies?: TradeCurrency[] +} + +/** + * @zh 交易操作数据 + * @en Trade operation data + */ +export interface TradeOperationData { + /** + * @zh 交易 ID + * @en Trade ID + */ + tradeId: string + + /** + * @zh 交易发起方 + * @en Trade initiator + */ + partyA: TradeParty + + /** + * @zh 交易接收方 + * @en Trade receiver + */ + partyB: TradeParty + + /** + * @zh 原因/备注 + * @en Reason/note + */ + reason?: string +} + +/** + * @zh 交易操作结果 + * @en Trade operation result + */ +export interface TradeOperationResult { + /** + * @zh 交易 ID + * @en Trade ID + */ + tradeId: string + + /** + * @zh 交易是否成功 + * @en Whether trade succeeded + */ + completed: boolean +} + +/** + * @zh 交易数据提供者 + * @en Trade data provider + */ +export interface ITradeProvider { + currencyProvider?: ICurrencyProvider + inventoryProvider?: IInventoryProvider +} + +/** + * @zh 交易操作 + * @en Trade operation + * + * @zh 用于处理玩家之间的物品和货币交换 + * @en Used for handling item and currency exchange between players + * + * @example + * ```typescript + * tx.addOperation(new TradeOperation({ + * tradeId: 'trade_001', + * partyA: { + * playerId: 'player1', + * items: [{ itemId: 'sword', quantity: 1 }], + * }, + * partyB: { + * playerId: 'player2', + * currencies: [{ currency: 'gold', amount: 1000 }], + * }, + * })) + * ``` + */ +export class TradeOperation extends BaseOperation { + readonly name = 'trade' + + private _provider: ITradeProvider | null = null + private _subOperations: (CurrencyOperation | InventoryOperation)[] = [] + private _executedCount = 0 + + /** + * @zh 设置交易数据提供者 + * @en Set trade data provider + */ + setProvider(provider: ITradeProvider): this { + this._provider = provider + return this + } + + async validate(ctx: ITransactionContext): Promise { + this._buildSubOperations() + + for (const op of this._subOperations) { + const isValid = await op.validate(ctx) + if (!isValid) { + return false + } + } + + return true + } + + async execute(ctx: ITransactionContext): Promise> { + this._buildSubOperations() + this._executedCount = 0 + + try { + for (const op of this._subOperations) { + const result = await op.execute(ctx) + if (!result.success) { + await this._compensateExecuted(ctx) + return this.failure(result.error ?? 'Trade operation failed', 'TRADE_FAILED') + } + this._executedCount++ + } + + return this.success({ + tradeId: this.data.tradeId, + completed: true, + }) + } catch (error) { + await this._compensateExecuted(ctx) + const errorMessage = error instanceof Error ? error.message : String(error) + return this.failure(errorMessage, 'TRADE_ERROR') + } + } + + async compensate(ctx: ITransactionContext): Promise { + await this._compensateExecuted(ctx) + } + + private _buildSubOperations(): void { + if (this._subOperations.length > 0) return + + const { partyA, partyB } = this.data + + if (partyA.items) { + for (const item of partyA.items) { + const removeOp = new InventoryOperation({ + type: 'remove', + playerId: partyA.playerId, + itemId: item.itemId, + quantity: item.quantity, + reason: `trade:${this.data.tradeId}:give`, + }) + const addOp = new InventoryOperation({ + type: 'add', + playerId: partyB.playerId, + itemId: item.itemId, + quantity: item.quantity, + reason: `trade:${this.data.tradeId}:receive`, + }) + + if (this._provider?.inventoryProvider) { + removeOp.setProvider(this._provider.inventoryProvider) + addOp.setProvider(this._provider.inventoryProvider) + } + + this._subOperations.push(removeOp, addOp) + } + } + + if (partyA.currencies) { + for (const curr of partyA.currencies) { + const deductOp = new CurrencyOperation({ + type: 'deduct', + playerId: partyA.playerId, + currency: curr.currency, + amount: curr.amount, + reason: `trade:${this.data.tradeId}:give`, + }) + const addOp = new CurrencyOperation({ + type: 'add', + playerId: partyB.playerId, + currency: curr.currency, + amount: curr.amount, + reason: `trade:${this.data.tradeId}:receive`, + }) + + if (this._provider?.currencyProvider) { + deductOp.setProvider(this._provider.currencyProvider) + addOp.setProvider(this._provider.currencyProvider) + } + + this._subOperations.push(deductOp, addOp) + } + } + + if (partyB.items) { + for (const item of partyB.items) { + const removeOp = new InventoryOperation({ + type: 'remove', + playerId: partyB.playerId, + itemId: item.itemId, + quantity: item.quantity, + reason: `trade:${this.data.tradeId}:give`, + }) + const addOp = new InventoryOperation({ + type: 'add', + playerId: partyA.playerId, + itemId: item.itemId, + quantity: item.quantity, + reason: `trade:${this.data.tradeId}:receive`, + }) + + if (this._provider?.inventoryProvider) { + removeOp.setProvider(this._provider.inventoryProvider) + addOp.setProvider(this._provider.inventoryProvider) + } + + this._subOperations.push(removeOp, addOp) + } + } + + if (partyB.currencies) { + for (const curr of partyB.currencies) { + const deductOp = new CurrencyOperation({ + type: 'deduct', + playerId: partyB.playerId, + currency: curr.currency, + amount: curr.amount, + reason: `trade:${this.data.tradeId}:give`, + }) + const addOp = new CurrencyOperation({ + type: 'add', + playerId: partyA.playerId, + currency: curr.currency, + amount: curr.amount, + reason: `trade:${this.data.tradeId}:receive`, + }) + + if (this._provider?.currencyProvider) { + deductOp.setProvider(this._provider.currencyProvider) + addOp.setProvider(this._provider.currencyProvider) + } + + this._subOperations.push(deductOp, addOp) + } + } + } + + private async _compensateExecuted(ctx: ITransactionContext): Promise { + for (let i = this._executedCount - 1; i >= 0; i--) { + await this._subOperations[i].compensate(ctx) + } + } +} + +/** + * @zh 创建交易操作 + * @en Create trade operation + */ +export function createTradeOperation(data: TradeOperationData): TradeOperation { + return new TradeOperation(data) +} diff --git a/packages/framework/transaction/src/operations/index.ts b/packages/framework/transaction/src/operations/index.ts new file mode 100644 index 00000000..70ca601a --- /dev/null +++ b/packages/framework/transaction/src/operations/index.ts @@ -0,0 +1,36 @@ +/** + * @zh 操作模块导出 + * @en Operations module exports + */ + +export { BaseOperation } from './BaseOperation.js' + +export { + CurrencyOperation, + createCurrencyOperation, + type CurrencyOperationType, + type CurrencyOperationData, + type CurrencyOperationResult, + type ICurrencyProvider, +} from './CurrencyOperation.js' + +export { + InventoryOperation, + createInventoryOperation, + type InventoryOperationType, + type InventoryOperationData, + type InventoryOperationResult, + type IInventoryProvider, + type ItemData, +} from './InventoryOperation.js' + +export { + TradeOperation, + createTradeOperation, + type TradeOperationData, + type TradeOperationResult, + type TradeItem, + type TradeCurrency, + type TradeParty, + type ITradeProvider, +} from './TradeOperation.js' diff --git a/packages/framework/transaction/src/storage/MemoryStorage.ts b/packages/framework/transaction/src/storage/MemoryStorage.ts new file mode 100644 index 00000000..d91f6ed1 --- /dev/null +++ b/packages/framework/transaction/src/storage/MemoryStorage.ts @@ -0,0 +1,229 @@ +/** + * @zh 内存存储实现 + * @en Memory storage implementation + * + * @zh 用于开发和测试环境,不支持分布式 + * @en For development and testing, does not support distributed scenarios + */ + +import type { + ITransactionStorage, + TransactionLog, + TransactionState, + OperationLog, +} from '../core/types.js' + +/** + * @zh 内存存储配置 + * @en Memory storage configuration + */ +export interface MemoryStorageConfig { + /** + * @zh 最大事务日志数量 + * @en Maximum transaction log count + */ + maxTransactions?: number +} + +/** + * @zh 内存存储 + * @en Memory storage + * + * @zh 适用于单机开发和测试,数据仅保存在内存中 + * @en Suitable for single-machine development and testing, data is stored in memory only + */ +export class MemoryStorage implements ITransactionStorage { + private _transactions: Map = new Map() + private _data: Map = new Map() + private _locks: Map = new Map() + private _maxTransactions: number + + constructor(config: MemoryStorageConfig = {}) { + this._maxTransactions = config.maxTransactions ?? 1000 + } + + // ========================================================================= + // 分布式锁 | Distributed Lock + // ========================================================================= + + async acquireLock(key: string, ttl: number): Promise { + this._cleanExpiredLocks() + + const existing = this._locks.get(key) + if (existing && existing.expireAt > Date.now()) { + return null + } + + const token = `lock_${Date.now()}_${Math.random().toString(36).substring(2)}` + this._locks.set(key, { + token, + expireAt: Date.now() + ttl, + }) + + return token + } + + async releaseLock(key: string, token: string): Promise { + const lock = this._locks.get(key) + if (!lock || lock.token !== token) { + return false + } + + this._locks.delete(key) + return true + } + + // ========================================================================= + // 事务日志 | Transaction Log + // ========================================================================= + + async saveTransaction(tx: TransactionLog): Promise { + if (this._transactions.size >= this._maxTransactions) { + this._cleanOldTransactions() + } + + this._transactions.set(tx.id, { ...tx }) + } + + async getTransaction(id: string): Promise { + const tx = this._transactions.get(id) + return tx ? { ...tx } : null + } + + async updateTransactionState(id: string, state: TransactionState): Promise { + const tx = this._transactions.get(id) + if (tx) { + tx.state = state + tx.updatedAt = Date.now() + } + } + + async updateOperationState( + transactionId: string, + operationIndex: number, + state: OperationLog['state'], + error?: string + ): Promise { + const tx = this._transactions.get(transactionId) + if (tx && tx.operations[operationIndex]) { + tx.operations[operationIndex].state = state + if (error) { + tx.operations[operationIndex].error = error + } + if (state === 'executed') { + tx.operations[operationIndex].executedAt = Date.now() + } else if (state === 'compensated') { + tx.operations[operationIndex].compensatedAt = Date.now() + } + tx.updatedAt = Date.now() + } + } + + async getPendingTransactions(serverId?: string): Promise { + const result: TransactionLog[] = [] + + for (const tx of this._transactions.values()) { + if (tx.state === 'pending' || tx.state === 'executing') { + if (!serverId || tx.metadata?.serverId === serverId) { + result.push({ ...tx }) + } + } + } + + return result + } + + async deleteTransaction(id: string): Promise { + this._transactions.delete(id) + } + + // ========================================================================= + // 数据操作 | Data Operations + // ========================================================================= + + async get(key: string): Promise { + this._cleanExpiredData() + + const entry = this._data.get(key) + if (!entry) return null + + if (entry.expireAt && entry.expireAt < Date.now()) { + this._data.delete(key) + return null + } + + return entry.value as T + } + + async set(key: string, value: T, ttl?: number): Promise { + this._data.set(key, { + value, + expireAt: ttl ? Date.now() + ttl : undefined, + }) + } + + async delete(key: string): Promise { + return this._data.delete(key) + } + + // ========================================================================= + // 辅助方法 | Helper methods + // ========================================================================= + + /** + * @zh 清空所有数据(测试用) + * @en Clear all data (for testing) + */ + clear(): void { + this._transactions.clear() + this._data.clear() + this._locks.clear() + } + + /** + * @zh 获取事务数量 + * @en Get transaction count + */ + get transactionCount(): number { + return this._transactions.size + } + + private _cleanExpiredLocks(): void { + const now = Date.now() + for (const [key, lock] of this._locks) { + if (lock.expireAt < now) { + this._locks.delete(key) + } + } + } + + private _cleanExpiredData(): void { + const now = Date.now() + for (const [key, entry] of this._data) { + if (entry.expireAt && entry.expireAt < now) { + this._data.delete(key) + } + } + } + + private _cleanOldTransactions(): void { + const sorted = Array.from(this._transactions.entries()) + .sort((a, b) => a[1].createdAt - b[1].createdAt) + + const toRemove = sorted + .slice(0, Math.floor(this._maxTransactions * 0.2)) + .filter(([_, tx]) => tx.state === 'committed' || tx.state === 'rolledback') + + for (const [id] of toRemove) { + this._transactions.delete(id) + } + } +} + +/** + * @zh 创建内存存储 + * @en Create memory storage + */ +export function createMemoryStorage(config: MemoryStorageConfig = {}): MemoryStorage { + return new MemoryStorage(config) +} diff --git a/packages/framework/transaction/src/storage/MongoStorage.ts b/packages/framework/transaction/src/storage/MongoStorage.ts new file mode 100644 index 00000000..cc14ebe4 --- /dev/null +++ b/packages/framework/transaction/src/storage/MongoStorage.ts @@ -0,0 +1,303 @@ +/** + * @zh MongoDB 存储实现 + * @en MongoDB storage implementation + * + * @zh 支持持久化事务日志和查询 + * @en Supports persistent transaction logs and queries + */ + +import type { + ITransactionStorage, + TransactionLog, + TransactionState, + OperationLog, +} from '../core/types.js' + +/** + * @zh MongoDB Collection 接口 + * @en MongoDB Collection interface + */ +export interface MongoCollection { + findOne(filter: object): Promise + find(filter: object): { + toArray(): Promise + } + insertOne(doc: T): Promise<{ insertedId: unknown }> + updateOne(filter: object, update: object): Promise<{ modifiedCount: number }> + deleteOne(filter: object): Promise<{ deletedCount: number }> + createIndex(spec: object, options?: object): Promise +} + +/** + * @zh MongoDB 数据库接口 + * @en MongoDB database interface + */ +export interface MongoDb { + collection(name: string): MongoCollection +} + +/** + * @zh MongoDB 存储配置 + * @en MongoDB storage configuration + */ +export interface MongoStorageConfig { + /** + * @zh MongoDB 数据库实例 + * @en MongoDB database instance + */ + db: MongoDb + + /** + * @zh 事务日志集合名称 + * @en Transaction log collection name + */ + transactionCollection?: string + + /** + * @zh 数据集合名称 + * @en Data collection name + */ + dataCollection?: string + + /** + * @zh 锁集合名称 + * @en Lock collection name + */ + lockCollection?: string +} + +interface LockDocument { + _id: string + token: string + expireAt: Date +} + +interface DataDocument { + _id: string + value: unknown + expireAt?: Date +} + +/** + * @zh MongoDB 存储 + * @en MongoDB storage + * + * @zh 基于 MongoDB 的事务存储,支持持久化和复杂查询 + * @en MongoDB-based transaction storage with persistence and complex query support + * + * @example + * ```typescript + * import { MongoClient } from 'mongodb' + * + * const client = new MongoClient('mongodb://localhost:27017') + * await client.connect() + * const db = client.db('game') + * + * const storage = new MongoStorage({ db }) + * await storage.ensureIndexes() + * ``` + */ +export class MongoStorage implements ITransactionStorage { + private _db: MongoDb + private _transactionCollection: string + private _dataCollection: string + private _lockCollection: string + + constructor(config: MongoStorageConfig) { + this._db = config.db + this._transactionCollection = config.transactionCollection ?? 'transactions' + this._dataCollection = config.dataCollection ?? 'transaction_data' + this._lockCollection = config.lockCollection ?? 'transaction_locks' + } + + /** + * @zh 确保索引存在 + * @en Ensure indexes exist + */ + async ensureIndexes(): Promise { + const txColl = this._db.collection(this._transactionCollection) + await txColl.createIndex({ state: 1 }) + await txColl.createIndex({ 'metadata.serverId': 1 }) + await txColl.createIndex({ createdAt: 1 }) + + const lockColl = this._db.collection(this._lockCollection) + await lockColl.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 }) + + const dataColl = this._db.collection(this._dataCollection) + await dataColl.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 }) + } + + // ========================================================================= + // 分布式锁 | Distributed Lock + // ========================================================================= + + async acquireLock(key: string, ttl: number): Promise { + const coll = this._db.collection(this._lockCollection) + const token = `${Date.now()}_${Math.random().toString(36).substring(2)}` + const expireAt = new Date(Date.now() + ttl) + + try { + await coll.insertOne({ + _id: key, + token, + expireAt, + }) + return token + } catch (error) { + const existing = await coll.findOne({ _id: key }) + if (existing && existing.expireAt < new Date()) { + const result = await coll.updateOne( + { _id: key, expireAt: { $lt: new Date() } }, + { $set: { token, expireAt } } + ) + if (result.modifiedCount > 0) { + return token + } + } + return null + } + } + + async releaseLock(key: string, token: string): Promise { + const coll = this._db.collection(this._lockCollection) + const result = await coll.deleteOne({ _id: key, token }) + return result.deletedCount > 0 + } + + // ========================================================================= + // 事务日志 | Transaction Log + // ========================================================================= + + async saveTransaction(tx: TransactionLog): Promise { + const coll = this._db.collection(this._transactionCollection) + + const existing = await coll.findOne({ _id: tx.id }) + if (existing) { + await coll.updateOne( + { _id: tx.id }, + { $set: { ...tx, _id: tx.id } } + ) + } else { + await coll.insertOne({ ...tx, _id: tx.id }) + } + } + + async getTransaction(id: string): Promise { + const coll = this._db.collection(this._transactionCollection) + const doc = await coll.findOne({ _id: id }) + + if (!doc) return null + + const { _id, ...tx } = doc + return tx as TransactionLog + } + + async updateTransactionState(id: string, state: TransactionState): Promise { + const coll = this._db.collection(this._transactionCollection) + await coll.updateOne( + { _id: id }, + { $set: { state, updatedAt: Date.now() } } + ) + } + + async updateOperationState( + transactionId: string, + operationIndex: number, + state: OperationLog['state'], + error?: string + ): Promise { + const coll = this._db.collection(this._transactionCollection) + + const update: Record = { + [`operations.${operationIndex}.state`]: state, + updatedAt: Date.now(), + } + + if (error) { + update[`operations.${operationIndex}.error`] = error + } + + if (state === 'executed') { + update[`operations.${operationIndex}.executedAt`] = Date.now() + } else if (state === 'compensated') { + update[`operations.${operationIndex}.compensatedAt`] = Date.now() + } + + await coll.updateOne( + { _id: transactionId }, + { $set: update } + ) + } + + async getPendingTransactions(serverId?: string): Promise { + const coll = this._db.collection(this._transactionCollection) + + const filter: Record = { + state: { $in: ['pending', 'executing'] }, + } + + if (serverId) { + filter['metadata.serverId'] = serverId + } + + const docs = await coll.find(filter).toArray() + return docs.map(({ _id, ...tx }) => tx as TransactionLog) + } + + async deleteTransaction(id: string): Promise { + const coll = this._db.collection(this._transactionCollection) + await coll.deleteOne({ _id: id }) + } + + // ========================================================================= + // 数据操作 | Data Operations + // ========================================================================= + + async get(key: string): Promise { + const coll = this._db.collection(this._dataCollection) + const doc = await coll.findOne({ _id: key }) + + if (!doc) return null + + if (doc.expireAt && doc.expireAt < new Date()) { + await coll.deleteOne({ _id: key }) + return null + } + + return doc.value as T + } + + async set(key: string, value: T, ttl?: number): Promise { + const coll = this._db.collection(this._dataCollection) + + const doc: DataDocument = { + _id: key, + value, + } + + if (ttl) { + doc.expireAt = new Date(Date.now() + ttl) + } + + const existing = await coll.findOne({ _id: key }) + if (existing) { + await coll.updateOne({ _id: key }, { $set: doc }) + } else { + await coll.insertOne(doc) + } + } + + async delete(key: string): Promise { + const coll = this._db.collection(this._dataCollection) + const result = await coll.deleteOne({ _id: key }) + return result.deletedCount > 0 + } +} + +/** + * @zh 创建 MongoDB 存储 + * @en Create MongoDB storage + */ +export function createMongoStorage(config: MongoStorageConfig): MongoStorage { + return new MongoStorage(config) +} diff --git a/packages/framework/transaction/src/storage/RedisStorage.ts b/packages/framework/transaction/src/storage/RedisStorage.ts new file mode 100644 index 00000000..fdbfa54b --- /dev/null +++ b/packages/framework/transaction/src/storage/RedisStorage.ts @@ -0,0 +1,244 @@ +/** + * @zh Redis 存储实现 + * @en Redis storage implementation + * + * @zh 支持分布式锁和快速缓存 + * @en Supports distributed locking and fast caching + */ + +import type { + ITransactionStorage, + TransactionLog, + TransactionState, + OperationLog, +} from '../core/types.js' + +/** + * @zh Redis 客户端接口(兼容 ioredis) + * @en Redis client interface (compatible with ioredis) + */ +export interface RedisClient { + get(key: string): Promise + set(key: string, value: string, ...args: string[]): Promise + del(...keys: string[]): Promise + eval(script: string, numkeys: number, ...args: (string | number)[]): Promise + hget(key: string, field: string): Promise + hset(key: string, ...args: (string | number)[]): Promise + hdel(key: string, ...fields: string[]): Promise + hgetall(key: string): Promise> + keys(pattern: string): Promise + expire(key: string, seconds: number): Promise +} + +/** + * @zh Redis 存储配置 + * @en Redis storage configuration + */ +export interface RedisStorageConfig { + /** + * @zh Redis 客户端实例 + * @en Redis client instance + */ + client: RedisClient + + /** + * @zh 键前缀 + * @en Key prefix + */ + prefix?: string + + /** + * @zh 事务日志过期时间(秒) + * @en Transaction log expiration time in seconds + */ + transactionTTL?: number +} + +const LOCK_SCRIPT = ` +if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) +else + return 0 +end +` + +/** + * @zh Redis 存储 + * @en Redis storage + * + * @zh 基于 Redis 的分布式事务存储,支持分布式锁 + * @en Redis-based distributed transaction storage with distributed locking support + * + * @example + * ```typescript + * import Redis from 'ioredis' + * + * const redis = new Redis('redis://localhost:6379') + * const storage = new RedisStorage({ client: redis }) + * ``` + */ +export class RedisStorage implements ITransactionStorage { + private _client: RedisClient + private _prefix: string + private _transactionTTL: number + + constructor(config: RedisStorageConfig) { + this._client = config.client + this._prefix = config.prefix ?? 'tx:' + this._transactionTTL = config.transactionTTL ?? 86400 // 24 hours + } + + // ========================================================================= + // 分布式锁 | Distributed Lock + // ========================================================================= + + async acquireLock(key: string, ttl: number): Promise { + const lockKey = `${this._prefix}lock:${key}` + const token = `${Date.now()}_${Math.random().toString(36).substring(2)}` + const ttlSeconds = Math.ceil(ttl / 1000) + + const result = await this._client.set(lockKey, token, 'NX', 'EX', String(ttlSeconds)) + + return result === 'OK' ? token : null + } + + async releaseLock(key: string, token: string): Promise { + const lockKey = `${this._prefix}lock:${key}` + + const result = await this._client.eval(LOCK_SCRIPT, 1, lockKey, token) + return result === 1 + } + + // ========================================================================= + // 事务日志 | Transaction Log + // ========================================================================= + + async saveTransaction(tx: TransactionLog): Promise { + const key = `${this._prefix}tx:${tx.id}` + + await this._client.set(key, JSON.stringify(tx)) + await this._client.expire(key, this._transactionTTL) + + if (tx.metadata?.serverId) { + const serverKey = `${this._prefix}server:${tx.metadata.serverId}:txs` + await this._client.hset(serverKey, tx.id, String(tx.createdAt)) + } + } + + async getTransaction(id: string): Promise { + const key = `${this._prefix}tx:${id}` + const data = await this._client.get(key) + + return data ? JSON.parse(data) : null + } + + async updateTransactionState(id: string, state: TransactionState): Promise { + const tx = await this.getTransaction(id) + if (tx) { + tx.state = state + tx.updatedAt = Date.now() + await this.saveTransaction(tx) + } + } + + async updateOperationState( + transactionId: string, + operationIndex: number, + state: OperationLog['state'], + error?: string + ): Promise { + const tx = await this.getTransaction(transactionId) + if (tx && tx.operations[operationIndex]) { + tx.operations[operationIndex].state = state + if (error) { + tx.operations[operationIndex].error = error + } + if (state === 'executed') { + tx.operations[operationIndex].executedAt = Date.now() + } else if (state === 'compensated') { + tx.operations[operationIndex].compensatedAt = Date.now() + } + tx.updatedAt = Date.now() + await this.saveTransaction(tx) + } + } + + async getPendingTransactions(serverId?: string): Promise { + const result: TransactionLog[] = [] + + if (serverId) { + const serverKey = `${this._prefix}server:${serverId}:txs` + const txIds = await this._client.hgetall(serverKey) + + for (const id of Object.keys(txIds)) { + const tx = await this.getTransaction(id) + if (tx && (tx.state === 'pending' || tx.state === 'executing')) { + result.push(tx) + } + } + } else { + const pattern = `${this._prefix}tx:*` + const keys = await this._client.keys(pattern) + + for (const key of keys) { + const data = await this._client.get(key) + if (data) { + const tx: TransactionLog = JSON.parse(data) + if (tx.state === 'pending' || tx.state === 'executing') { + result.push(tx) + } + } + } + } + + return result + } + + async deleteTransaction(id: string): Promise { + const key = `${this._prefix}tx:${id}` + const tx = await this.getTransaction(id) + + await this._client.del(key) + + if (tx?.metadata?.serverId) { + const serverKey = `${this._prefix}server:${tx.metadata.serverId}:txs` + await this._client.hdel(serverKey, id) + } + } + + // ========================================================================= + // 数据操作 | Data Operations + // ========================================================================= + + async get(key: string): Promise { + const fullKey = `${this._prefix}data:${key}` + const data = await this._client.get(fullKey) + + return data ? JSON.parse(data) : null + } + + async set(key: string, value: T, ttl?: number): Promise { + const fullKey = `${this._prefix}data:${key}` + + if (ttl) { + const ttlSeconds = Math.ceil(ttl / 1000) + await this._client.set(fullKey, JSON.stringify(value), 'EX', String(ttlSeconds)) + } else { + await this._client.set(fullKey, JSON.stringify(value)) + } + } + + async delete(key: string): Promise { + const fullKey = `${this._prefix}data:${key}` + const result = await this._client.del(fullKey) + return result > 0 + } +} + +/** + * @zh 创建 Redis 存储 + * @en Create Redis storage + */ +export function createRedisStorage(config: RedisStorageConfig): RedisStorage { + return new RedisStorage(config) +} diff --git a/packages/framework/transaction/src/storage/index.ts b/packages/framework/transaction/src/storage/index.ts new file mode 100644 index 00000000..f8378ddc --- /dev/null +++ b/packages/framework/transaction/src/storage/index.ts @@ -0,0 +1,8 @@ +/** + * @zh 存储模块导出 + * @en Storage module exports + */ + +export { MemoryStorage, createMemoryStorage, type MemoryStorageConfig } from './MemoryStorage.js' +export { RedisStorage, createRedisStorage, type RedisStorageConfig, type RedisClient } from './RedisStorage.js' +export { MongoStorage, createMongoStorage, type MongoStorageConfig, type MongoDb, type MongoCollection } from './MongoStorage.js' diff --git a/packages/framework/transaction/src/tokens.ts b/packages/framework/transaction/src/tokens.ts new file mode 100644 index 00000000..a2a0798c --- /dev/null +++ b/packages/framework/transaction/src/tokens.ts @@ -0,0 +1,20 @@ +/** + * @zh Transaction 模块服务令牌 + * @en Transaction module service tokens + */ + +import { createServiceToken } from '@esengine/ecs-framework' +import type { TransactionManager } from './core/TransactionManager.js' +import type { ITransactionStorage } from './core/types.js' + +/** + * @zh 事务管理器令牌 + * @en Transaction manager token + */ +export const TransactionManagerToken = createServiceToken('transactionManager') + +/** + * @zh 事务存储令牌 + * @en Transaction storage token + */ +export const TransactionStorageToken = createServiceToken('transactionStorage') diff --git a/packages/framework/transaction/tsconfig.build.json b/packages/framework/transaction/tsconfig.build.json new file mode 100644 index 00000000..d7653608 --- /dev/null +++ b/packages/framework/transaction/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": false, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/framework/transaction/tsconfig.json b/packages/framework/transaction/tsconfig.json new file mode 100644 index 00000000..805b5a56 --- /dev/null +++ b/packages/framework/transaction/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "composite": true, + "declaration": true, + "declarationMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + "references": [ + { "path": "../../core" }, + { "path": "../server" } + ] +} diff --git a/packages/framework/transaction/tsup.config.ts b/packages/framework/transaction/tsup.config.ts new file mode 100644 index 00000000..f7cdd7f2 --- /dev/null +++ b/packages/framework/transaction/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsup' +import { runtimeOnlyPreset } from '../../tools/build-config/src/presets/plugin-tsup' + +export default defineConfig({ + ...runtimeOnlyPreset({ + external: ['ioredis', 'mongodb'], + }), + tsconfig: 'tsconfig.build.json', + // tsup 的 DTS bundler 无法正确解析 workspace 包的类型继承链 + // tsup's DTS bundler cannot correctly resolve workspace package type inheritance + dts: false, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3f6d880..3a3fc1fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -158,16 +158,16 @@ importers: dependencies: '@astrojs/starlight': specifier: ^0.37.1 - version: 0.37.1(astro@5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)) + version: 0.37.1(astro@5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)) '@astrojs/vue': specifier: ^5.1.3 - version: 5.1.3(@types/node@22.19.3)(astro@5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(vue@3.5.26(typescript@5.9.3))(yaml@2.8.2) + version: 5.1.3(@types/node@22.19.3)(astro@5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(vue@3.5.26(typescript@5.9.3))(yaml@2.8.2) '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@6.4.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) astro: specifier: ^5.6.1 - version: 5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) sharp: specifier: ^0.34.2 version: 0.34.5 @@ -1763,6 +1763,34 @@ importers: specifier: ^5.8.3 version: 5.9.3 + packages/framework/transaction: + dependencies: + '@esengine/server': + specifier: workspace:* + version: link:../server + ioredis: + specifier: ^5.3.0 + version: 5.8.2 + mongodb: + specifier: ^6.0.0 + version: 6.21.0(socks@2.8.7) + devDependencies: + '@esengine/build-config': + specifier: workspace:* + version: link:../../tools/build-config + '@esengine/ecs-framework': + specifier: workspace:* + version: link:../core/dist + rimraf: + specifier: ^5.0.0 + version: 5.0.10 + tsup: + specifier: ^8.0.0 + version: 8.5.1(@microsoft/api-extractor@7.55.2(@types/node@22.19.3))(@swc/core@1.15.7(@swc/helpers@0.5.18))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: ^5.8.0 + version: 5.9.3 + packages/framework/world-streaming: dependencies: '@esengine/ecs-framework': @@ -3824,6 +3852,9 @@ packages: '@types/node': optional: true + '@ioredis/commands@1.4.0': + resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==} + '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} @@ -3984,6 +4015,9 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@mongodb-js/saslprep@1.4.4': + resolution: {integrity: sha512-p7X/ytJDIdwUfFL/CLOhKgdfJe1Fa8uw9seJYvdOmnP9JBWGWHW69HkOixXS6Wy9yvGf1MbhcS6lVmrhy4jm2g==} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} cpu: [arm64] @@ -5238,6 +5272,12 @@ packages: '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@types/webidl-conversions@7.0.3': + resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} + + '@types/whatwg-url@11.0.5': + resolution: {integrity: sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -5825,6 +5865,10 @@ packages: bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + bson@6.10.4: + resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==} + engines: {node: '>=16.20.1'} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -6011,6 +6055,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + cmd-shim@6.0.3: resolution: {integrity: sha512-FMabTRlc5t5zjdenF6mS0MBeFZm0XqHqeOkcskKFb/LYCcRQ5fVgLOHVc4Lq9CqABd9zhjwPjMBCJvMCziSVtA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -6378,6 +6426,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + deprecation@2.3.1: resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==} @@ -7400,6 +7452,10 @@ packages: resolution: {integrity: sha512-CYdFeFexxhv/Bcny+Q0BfOV+ltRlJcd4BBZBYFX/O0u4npJrgZtIcjokegtiSMAvlMTJ+Koq0GBCc//3bueQxw==} engines: {node: '>=8'} + ioredis@5.8.2: + resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==} + engines: {node: '>=12.22.0'} + ip-address@10.1.0: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} @@ -8052,6 +8108,9 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + lodash.escaperegexp@4.1.2: resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} @@ -8059,6 +8118,9 @@ packages: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. @@ -8286,6 +8348,9 @@ packages: resolution: {integrity: sha512-qvwipnozMohxLXG1pOqoLiZKNkC4r4qqRucSoDwXowsNGDSULiqFTRUF05vcZWnwJSG22qTsynQhxbaMtnX9gw==} engines: {node: '>=8'} + memory-pager@1.5.0: + resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} + meow@12.1.1: resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==} engines: {node: '>=16.10'} @@ -8543,6 +8608,36 @@ packages: monaco-editor@0.55.1: resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + mongodb-connection-string-url@3.0.2: + resolution: {integrity: sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==} + + mongodb@6.21.0: + resolution: {integrity: sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==} + engines: {node: '>=16.20.1'} + peerDependencies: + '@aws-sdk/credential-providers': ^3.188.0 + '@mongodb-js/zstd': ^1.1.0 || ^2.0.0 + gcp-metadata: ^5.2.0 + kerberos: ^2.0.1 + mongodb-client-encryption: '>=6.0.0 <7' + snappy: ^7.3.2 + socks: ^2.7.1 + peerDependenciesMeta: + '@aws-sdk/credential-providers': + optional: true + '@mongodb-js/zstd': + optional: true + gcp-metadata: + optional: true + kerberos: + optional: true + mongodb-client-encryption: + optional: true + snappy: + optional: true + socks: + optional: true + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -9457,6 +9552,14 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -9843,6 +9946,9 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + sparse-bitfield@3.0.3: + resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} + spawn-error-forwarder@1.0.0: resolution: {integrity: sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g==} @@ -9889,6 +9995,9 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + state-local@1.0.7: resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} @@ -10141,6 +10250,10 @@ packages: resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} engines: {node: '>=12'} + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + traverse@0.6.8: resolution: {integrity: sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==} engines: {node: '>= 0.4'} @@ -10904,6 +11017,10 @@ packages: resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} engines: {node: '>=12'} + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -11262,12 +11379,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/mdx@4.3.13(astro@5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))': + '@astrojs/mdx@4.3.13(astro@5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))': dependencies: '@astrojs/markdown-remark': 6.3.10 '@mdx-js/mdx': 3.1.1 acorn: 8.15.0 - astro: 5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + astro: 5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) es-module-lexer: 1.7.0 estree-util-visit: 2.0.0 hast-util-to-html: 9.0.5 @@ -11291,17 +11408,17 @@ snapshots: stream-replace-string: 2.0.0 zod: 3.25.76 - '@astrojs/starlight@0.37.1(astro@5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))': + '@astrojs/starlight@0.37.1(astro@5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))': dependencies: '@astrojs/markdown-remark': 6.3.10 - '@astrojs/mdx': 4.3.13(astro@5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)) + '@astrojs/mdx': 4.3.13(astro@5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)) '@astrojs/sitemap': 3.6.0 '@pagefind/default-ui': 1.4.0 '@types/hast': 3.0.4 '@types/js-yaml': 4.0.9 '@types/mdast': 4.0.4 - astro: 5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) - astro-expressive-code: 0.41.5(astro@5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)) + astro: 5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + astro-expressive-code: 0.41.5(astro@5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)) bcp-47: 2.1.0 hast-util-from-html: 2.0.3 hast-util-select: 6.0.4 @@ -11337,12 +11454,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/vue@5.1.3(@types/node@22.19.3)(astro@5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(vue@3.5.26(typescript@5.9.3))(yaml@2.8.2)': + '@astrojs/vue@5.1.3(@types/node@22.19.3)(astro@5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(vue@3.5.26(typescript@5.9.3))(yaml@2.8.2)': dependencies: '@vitejs/plugin-vue': 5.2.1(vite@6.4.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3)) '@vitejs/plugin-vue-jsx': 4.2.0(vite@6.4.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3)) '@vue/compiler-sfc': 3.5.26 - astro: 5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + astro: 5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) vite: 6.4.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite-plugin-vue-devtools: 7.7.9(rollup@4.54.0)(vite@6.4.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3)) vue: 3.5.26(typescript@5.9.3) @@ -12902,6 +13019,8 @@ snapshots: optionalDependencies: '@types/node': 22.19.3 + '@ioredis/commands@1.4.0': {} + '@isaacs/balanced-match@4.0.1': {} '@isaacs/brace-expansion@5.0.0': @@ -13359,6 +13478,10 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + '@mongodb-js/saslprep@1.4.4': + dependencies: + sparse-bitfield: 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': optional: true @@ -14732,6 +14855,12 @@ snapshots: '@types/web-bluetooth@0.0.21': {} + '@types/webidl-conversions@7.0.3': {} + + '@types/whatwg-url@11.0.5': + dependencies: + '@types/webidl-conversions': 7.0.3 + '@types/ws@8.18.1': dependencies: '@types/node': 20.19.27 @@ -15280,12 +15409,12 @@ snapshots: astring@1.9.0: {} - astro-expressive-code@0.41.5(astro@5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)): + astro-expressive-code@0.41.5(astro@5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)): dependencies: - astro: 5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + astro: 5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) rehype-expressive-code: 0.41.5 - astro@5.16.6(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): + astro@5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): dependencies: '@astrojs/compiler': 2.13.0 '@astrojs/internal-helpers': 0.7.5 @@ -15340,7 +15469,7 @@ snapshots: ultrahtml: 1.6.0 unifont: 0.6.0 unist-util-visit: 5.0.0 - unstorage: 1.17.3 + unstorage: 1.17.3(ioredis@5.8.2) vfile: 6.0.3 vite: 6.4.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitefu: 1.1.1(vite@6.4.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -15571,6 +15700,8 @@ snapshots: dependencies: node-int64: 0.4.0 + bson@6.10.4: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -15750,6 +15881,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.2: {} + cmd-shim@6.0.3: {} co@4.6.0: {} @@ -16094,6 +16227,8 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: {} + deprecation@2.3.1: {} dequal@2.0.3: {} @@ -17421,6 +17556,20 @@ snapshots: invert-kv@3.0.1: {} + ioredis@5.8.2: + dependencies: + '@ioredis/commands': 1.4.0 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ip-address@10.1.0: {} iron-webcrypto@1.2.1: {} @@ -18307,10 +18456,14 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.defaults@4.2.0: {} + lodash.escaperegexp@4.1.2: {} lodash.get@4.4.2: {} + lodash.isarguments@3.1.0: {} + lodash.isequal@4.5.0: {} lodash.isfunction@3.0.9: {} @@ -18662,6 +18815,8 @@ snapshots: mimic-fn: 2.1.0 p-is-promise: 2.1.0 + memory-pager@1.5.0: {} + meow@12.1.1: {} meow@8.1.2: @@ -19078,6 +19233,19 @@ snapshots: dompurify: 3.2.7 marked: 14.0.0 + mongodb-connection-string-url@3.0.2: + dependencies: + '@types/whatwg-url': 11.0.5 + whatwg-url: 14.2.0 + + mongodb@6.21.0(socks@2.8.7): + dependencies: + '@mongodb-js/saslprep': 1.4.4 + bson: 6.10.4 + mongodb-connection-string-url: 3.0.2 + optionalDependencies: + socks: 2.8.7 + mri@1.2.0: {} mrmime@2.0.1: {} @@ -20008,6 +20176,12 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + reflect-metadata@0.2.2: {} regenerate-unicode-properties@10.2.2: @@ -20545,6 +20719,10 @@ snapshots: space-separated-tokens@2.0.2: {} + sparse-bitfield@3.0.3: + dependencies: + memory-pager: 1.5.0 + spawn-error-forwarder@1.0.0: {} spawndamnit@3.0.1: @@ -20592,6 +20770,8 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + standard-as-callback@2.1.0: {} + state-local@1.0.7: {} stream-combiner2@1.1.1: @@ -20853,6 +21033,10 @@ snapshots: dependencies: punycode: 2.3.1 + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + traverse@0.6.8: {} tree-kill@1.2.2: {} @@ -21321,7 +21505,7 @@ snapshots: picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 - unstorage@1.17.3: + unstorage@1.17.3(ioredis@5.8.2): dependencies: anymatch: 3.1.3 chokidar: 4.0.3 @@ -21331,6 +21515,8 @@ snapshots: node-fetch-native: 1.6.7 ofetch: 1.5.1 ufo: 1.6.1 + optionalDependencies: + ioredis: 5.8.2 upath@2.0.1: {} @@ -21661,6 +21847,11 @@ snapshots: tr46: 3.0.0 webidl-conversions: 7.0.0 + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + whatwg-url@5.0.0: dependencies: tr46: 0.0.3