feat(framework): server testing utils, transaction storage simplify, pathfinding tests (#384)
## Server Testing Utils - Add TestServer, TestClient, MockRoom for unit testing - Export testing utilities from @esengine/server/testing ## Transaction Storage (BREAKING) - Simplify RedisStorage/MongoStorage to factory pattern only - Remove DI client injection option - Add lazy connection and Symbol.asyncDispose support - Add 161 unit tests with full coverage ## Pathfinding Tests - Add 150 unit tests covering all components - BinaryHeap, Heuristics, AStarPathfinder, GridMap, NavMesh, PathSmoother ## Docs - Update storage.md for new factory pattern API
This commit is contained in:
@@ -12,15 +12,15 @@ import type {
|
||||
TransactionOptions,
|
||||
TransactionLog,
|
||||
OperationLog,
|
||||
OperationResult,
|
||||
} from './types.js'
|
||||
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)}`
|
||||
return `tx_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 11)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,22 +39,22 @@ function generateId(): string {
|
||||
* ```
|
||||
*/
|
||||
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<string, unknown>
|
||||
private _contextData: Map<string, unknown> = new Map()
|
||||
private _startTime: number = 0
|
||||
private _distributed: boolean
|
||||
private _id: string;
|
||||
private _state: TransactionState = 'pending';
|
||||
private _timeout: number;
|
||||
private _operations: ITransactionOperation[] = [];
|
||||
private _storage: ITransactionStorage | null;
|
||||
private _metadata: Record<string, unknown>;
|
||||
private _contextData: Map<string, unknown> = 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
|
||||
this._id = generateId();
|
||||
this._timeout = options.timeout ?? 30000;
|
||||
this._storage = options.storage ?? null;
|
||||
this._metadata = options.metadata ?? {};
|
||||
this._distributed = options.distributed ?? false;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -62,27 +62,27 @@ export class TransactionContext implements ITransactionContext {
|
||||
// =========================================================================
|
||||
|
||||
get id(): string {
|
||||
return this._id
|
||||
return this._id;
|
||||
}
|
||||
|
||||
get state(): TransactionState {
|
||||
return this._state
|
||||
return this._state;
|
||||
}
|
||||
|
||||
get timeout(): number {
|
||||
return this._timeout
|
||||
return this._timeout;
|
||||
}
|
||||
|
||||
get operations(): ReadonlyArray<ITransactionOperation> {
|
||||
return this._operations
|
||||
return this._operations;
|
||||
}
|
||||
|
||||
get storage(): ITransactionStorage | null {
|
||||
return this._storage
|
||||
return this._storage;
|
||||
}
|
||||
|
||||
get metadata(): Record<string, unknown> {
|
||||
return this._metadata
|
||||
return this._metadata;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -95,10 +95,10 @@ export class TransactionContext implements ITransactionContext {
|
||||
*/
|
||||
addOperation<T extends ITransactionOperation>(operation: T): this {
|
||||
if (this._state !== 'pending') {
|
||||
throw new Error(`Cannot add operation to transaction in state: ${this._state}`)
|
||||
throw new Error(`Cannot add operation to transaction in state: ${this._state}`);
|
||||
}
|
||||
this._operations.push(operation)
|
||||
return this
|
||||
this._operations.push(operation);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,64 +112,64 @@ export class TransactionContext implements ITransactionContext {
|
||||
transactionId: this._id,
|
||||
results: [],
|
||||
error: `Transaction already in state: ${this._state}`,
|
||||
duration: 0,
|
||||
}
|
||||
duration: 0
|
||||
};
|
||||
}
|
||||
|
||||
this._startTime = Date.now()
|
||||
this._state = 'executing'
|
||||
this._startTime = Date.now();
|
||||
this._state = 'executing';
|
||||
|
||||
const results: OperationResult[] = []
|
||||
let executedCount = 0
|
||||
const results: OperationResult[] = [];
|
||||
let executedCount = 0;
|
||||
|
||||
try {
|
||||
await this._saveLog()
|
||||
await this._saveLog();
|
||||
|
||||
for (let i = 0; i < this._operations.length; i++) {
|
||||
if (this._isTimedOut()) {
|
||||
throw new Error('Transaction timed out')
|
||||
throw new Error('Transaction timed out');
|
||||
}
|
||||
|
||||
const op = this._operations[i]
|
||||
const op = this._operations[i];
|
||||
|
||||
const isValid = await op.validate(this)
|
||||
const isValid = await op.validate(this);
|
||||
if (!isValid) {
|
||||
throw new Error(`Validation failed for operation: ${op.name}`)
|
||||
throw new Error(`Validation failed for operation: ${op.name}`);
|
||||
}
|
||||
|
||||
const result = await op.execute(this)
|
||||
results.push(result)
|
||||
executedCount++
|
||||
const result = await op.execute(this);
|
||||
results.push(result);
|
||||
executedCount++;
|
||||
|
||||
await this._updateOperationLog(i, 'executed')
|
||||
await this._updateOperationLog(i, 'executed');
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? `Operation ${op.name} failed`)
|
||||
throw new Error(result.error ?? `Operation ${op.name} failed`);
|
||||
}
|
||||
}
|
||||
|
||||
this._state = 'committed'
|
||||
await this._updateTransactionState('committed')
|
||||
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,
|
||||
}
|
||||
duration: Date.now() - this._startTime
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
await this._compensate(executedCount - 1)
|
||||
await this._compensate(executedCount - 1);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
transactionId: this._id,
|
||||
results,
|
||||
error: errorMessage,
|
||||
duration: Date.now() - this._startTime,
|
||||
}
|
||||
duration: Date.now() - this._startTime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,10 +179,10 @@ export class TransactionContext implements ITransactionContext {
|
||||
*/
|
||||
async rollback(): Promise<void> {
|
||||
if (this._state === 'committed' || this._state === 'rolledback') {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
await this._compensate(this._operations.length - 1)
|
||||
await this._compensate(this._operations.length - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,7 +190,7 @@ export class TransactionContext implements ITransactionContext {
|
||||
* @en Get context data
|
||||
*/
|
||||
get<T>(key: string): T | undefined {
|
||||
return this._contextData.get(key) as T | undefined
|
||||
return this._contextData.get(key) as T | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,7 +198,7 @@ export class TransactionContext implements ITransactionContext {
|
||||
* @en Set context data
|
||||
*/
|
||||
set<T>(key: string, value: T): void {
|
||||
this._contextData.set(key, value)
|
||||
this._contextData.set(key, value);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -206,28 +206,28 @@ export class TransactionContext implements ITransactionContext {
|
||||
// =========================================================================
|
||||
|
||||
private _isTimedOut(): boolean {
|
||||
return Date.now() - this._startTime > this._timeout
|
||||
return Date.now() - this._startTime > this._timeout;
|
||||
}
|
||||
|
||||
private async _compensate(fromIndex: number): Promise<void> {
|
||||
this._state = 'rolledback'
|
||||
this._state = 'rolledback';
|
||||
|
||||
for (let i = fromIndex; i >= 0; i--) {
|
||||
const op = this._operations[i]
|
||||
const op = this._operations[i];
|
||||
try {
|
||||
await op.compensate(this)
|
||||
await this._updateOperationLog(i, 'compensated')
|
||||
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)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
await this._updateOperationLog(i, 'failed', errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
await this._updateTransactionState('rolledback')
|
||||
await this._updateTransactionState('rolledback');
|
||||
}
|
||||
|
||||
private async _saveLog(): Promise<void> {
|
||||
if (!this._storage) return
|
||||
if (!this._storage) return;
|
||||
|
||||
const log: TransactionLog = {
|
||||
id: this._id,
|
||||
@@ -238,19 +238,19 @@ export class TransactionContext implements ITransactionContext {
|
||||
operations: this._operations.map((op) => ({
|
||||
name: op.name,
|
||||
data: op.data,
|
||||
state: 'pending' as const,
|
||||
state: 'pending' as const
|
||||
})),
|
||||
metadata: this._metadata,
|
||||
distributed: this._distributed,
|
||||
}
|
||||
distributed: this._distributed
|
||||
};
|
||||
|
||||
await this._storage.saveTransaction(log)
|
||||
await this._storage.saveTransaction(log);
|
||||
}
|
||||
|
||||
private async _updateTransactionState(state: TransactionState): Promise<void> {
|
||||
this._state = state
|
||||
this._state = state;
|
||||
if (this._storage) {
|
||||
await this._storage.updateTransactionState(this._id, state)
|
||||
await this._storage.updateTransactionState(this._id, state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,18 +260,18 @@ export class TransactionContext implements ITransactionContext {
|
||||
error?: string
|
||||
): Promise<void> {
|
||||
if (this._storage) {
|
||||
await this._storage.updateOperationState(this._id, index, state, error)
|
||||
await this._storage.updateOperationState(this._id, index, state, error);
|
||||
}
|
||||
}
|
||||
|
||||
private _collectResultData(results: OperationResult[]): unknown {
|
||||
const data: Record<string, unknown> = {}
|
||||
const data: Record<string, unknown> = {};
|
||||
for (const result of results) {
|
||||
if (result.data !== undefined) {
|
||||
Object.assign(data, result.data)
|
||||
Object.assign(data, result.data);
|
||||
}
|
||||
}
|
||||
return Object.keys(data).length > 0 ? data : undefined
|
||||
return Object.keys(data).length > 0 ? data : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,5 +282,5 @@ export class TransactionContext implements ITransactionContext {
|
||||
export function createTransactionContext(
|
||||
options: TransactionOptions & { storage?: ITransactionStorage } = {}
|
||||
): ITransactionContext {
|
||||
return new TransactionContext(options)
|
||||
return new TransactionContext(options);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ import type {
|
||||
TransactionManagerConfig,
|
||||
TransactionOptions,
|
||||
TransactionLog,
|
||||
TransactionResult,
|
||||
} from './types.js'
|
||||
import { TransactionContext } from './TransactionContext.js'
|
||||
TransactionResult
|
||||
} from './types.js';
|
||||
import { TransactionContext } from './TransactionContext.js';
|
||||
|
||||
/**
|
||||
* @zh 事务管理器
|
||||
@@ -35,17 +35,17 @@ import { TransactionContext } from './TransactionContext.js'
|
||||
* ```
|
||||
*/
|
||||
export class TransactionManager {
|
||||
private _storage: ITransactionStorage | null
|
||||
private _defaultTimeout: number
|
||||
private _serverId: string
|
||||
private _autoRecover: boolean
|
||||
private _activeTransactions: Map<string, ITransactionContext> = new Map()
|
||||
private _storage: ITransactionStorage | null;
|
||||
private _defaultTimeout: number;
|
||||
private _serverId: string;
|
||||
private _autoRecover: boolean;
|
||||
private _activeTransactions: Map<string, ITransactionContext> = 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
|
||||
this._storage = config.storage ?? null;
|
||||
this._defaultTimeout = config.defaultTimeout ?? 30000;
|
||||
this._serverId = config.serverId ?? this._generateServerId();
|
||||
this._autoRecover = config.autoRecover ?? true;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -57,7 +57,7 @@ export class TransactionManager {
|
||||
* @en Server ID
|
||||
*/
|
||||
get serverId(): string {
|
||||
return this._serverId
|
||||
return this._serverId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,7 +65,7 @@ export class TransactionManager {
|
||||
* @en Storage instance
|
||||
*/
|
||||
get storage(): ITransactionStorage | null {
|
||||
return this._storage
|
||||
return this._storage;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,7 +73,7 @@ export class TransactionManager {
|
||||
* @en Active transaction count
|
||||
*/
|
||||
get activeCount(): number {
|
||||
return this._activeTransactions.size
|
||||
return this._activeTransactions.size;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -93,14 +93,14 @@ export class TransactionManager {
|
||||
storage: this._storage ?? undefined,
|
||||
metadata: {
|
||||
...options.metadata,
|
||||
serverId: this._serverId,
|
||||
serverId: this._serverId
|
||||
},
|
||||
distributed: options.distributed,
|
||||
})
|
||||
distributed: options.distributed
|
||||
});
|
||||
|
||||
this._activeTransactions.set(ctx.id, ctx)
|
||||
this._activeTransactions.set(ctx.id, ctx);
|
||||
|
||||
return ctx
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,14 +115,14 @@ export class TransactionManager {
|
||||
builder: (ctx: ITransactionContext) => void | Promise<void>,
|
||||
options: TransactionOptions = {}
|
||||
): Promise<TransactionResult<T>> {
|
||||
const ctx = this.begin(options)
|
||||
const ctx = this.begin(options);
|
||||
|
||||
try {
|
||||
await builder(ctx)
|
||||
const result = await ctx.execute<T>()
|
||||
return result
|
||||
await builder(ctx);
|
||||
const result = await ctx.execute<T>();
|
||||
return result;
|
||||
} finally {
|
||||
this._activeTransactions.delete(ctx.id)
|
||||
this._activeTransactions.delete(ctx.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ export class TransactionManager {
|
||||
* @en Get active transaction
|
||||
*/
|
||||
getTransaction(id: string): ITransactionContext | undefined {
|
||||
return this._activeTransactions.get(id)
|
||||
return this._activeTransactions.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,21 +139,21 @@ export class TransactionManager {
|
||||
* @en Recover pending transactions
|
||||
*/
|
||||
async recover(): Promise<number> {
|
||||
if (!this._storage) return 0
|
||||
if (!this._storage) return 0;
|
||||
|
||||
const pendingTransactions = await this._storage.getPendingTransactions(this._serverId)
|
||||
let recoveredCount = 0
|
||||
const pendingTransactions = await this._storage.getPendingTransactions(this._serverId);
|
||||
let recoveredCount = 0;
|
||||
|
||||
for (const log of pendingTransactions) {
|
||||
try {
|
||||
await this._recoverTransaction(log)
|
||||
recoveredCount++
|
||||
await this._recoverTransaction(log);
|
||||
recoveredCount++;
|
||||
} catch (error) {
|
||||
console.error(`Failed to recover transaction ${log.id}:`, error)
|
||||
console.error(`Failed to recover transaction ${log.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return recoveredCount
|
||||
return recoveredCount;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,8 +161,8 @@ export class TransactionManager {
|
||||
* @en Acquire distributed lock
|
||||
*/
|
||||
async acquireLock(key: string, ttl: number = 10000): Promise<string | null> {
|
||||
if (!this._storage) return null
|
||||
return this._storage.acquireLock(key, ttl)
|
||||
if (!this._storage) return null;
|
||||
return this._storage.acquireLock(key, ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,8 +170,8 @@ export class TransactionManager {
|
||||
* @en Release distributed lock
|
||||
*/
|
||||
async releaseLock(key: string, token: string): Promise<boolean> {
|
||||
if (!this._storage) return false
|
||||
return this._storage.releaseLock(key, token)
|
||||
if (!this._storage) return false;
|
||||
return this._storage.releaseLock(key, token);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -183,15 +183,15 @@ export class TransactionManager {
|
||||
fn: () => Promise<T>,
|
||||
ttl: number = 10000
|
||||
): Promise<T> {
|
||||
const token = await this.acquireLock(key, ttl)
|
||||
const token = await this.acquireLock(key, ttl);
|
||||
if (!token) {
|
||||
throw new Error(`Failed to acquire lock for key: ${key}`)
|
||||
throw new Error(`Failed to acquire lock for key: ${key}`);
|
||||
}
|
||||
|
||||
try {
|
||||
return await fn()
|
||||
return await fn();
|
||||
} finally {
|
||||
await this.releaseLock(key, token)
|
||||
await this.releaseLock(key, token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,24 +200,24 @@ export class TransactionManager {
|
||||
* @en Clean up completed transaction logs
|
||||
*/
|
||||
async cleanup(beforeTimestamp?: number): Promise<number> {
|
||||
if (!this._storage) return 0
|
||||
if (!this._storage) return 0;
|
||||
|
||||
const timestamp = beforeTimestamp ?? Date.now() - 24 * 60 * 60 * 1000 // 默认清理24小时前
|
||||
const timestamp = beforeTimestamp ?? Date.now() - 24 * 60 * 60 * 1000; // 默认清理24小时前
|
||||
|
||||
const pendingTransactions = await this._storage.getPendingTransactions()
|
||||
let cleanedCount = 0
|
||||
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++
|
||||
await this._storage.deleteTransaction(log.id);
|
||||
cleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return cleanedCount
|
||||
return cleanedCount;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -225,20 +225,20 @@ export class TransactionManager {
|
||||
// =========================================================================
|
||||
|
||||
private _generateServerId(): string {
|
||||
return `server_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`
|
||||
return `server_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`;
|
||||
}
|
||||
|
||||
private async _recoverTransaction(log: TransactionLog): Promise<void> {
|
||||
if (log.state === 'executing') {
|
||||
const executedOps = log.operations.filter((op) => op.state === 'executed')
|
||||
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.updateOperationState(log.id, i, 'compensated');
|
||||
}
|
||||
await this._storage.updateTransactionState(log.id, 'rolledback')
|
||||
await this._storage.updateTransactionState(log.id, 'rolledback');
|
||||
} else {
|
||||
await this._storage?.updateTransactionState(log.id, 'failed')
|
||||
await this._storage?.updateTransactionState(log.id, 'failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,5 +251,5 @@ export class TransactionManager {
|
||||
export function createTransactionManager(
|
||||
config: TransactionManagerConfig = {}
|
||||
): TransactionManager {
|
||||
return new TransactionManager(config)
|
||||
return new TransactionManager(config);
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ export type {
|
||||
TransactionManagerConfig,
|
||||
ITransactionStorage,
|
||||
ITransactionOperation,
|
||||
ITransactionContext,
|
||||
} from './types.js'
|
||||
ITransactionContext
|
||||
} from './types.js';
|
||||
|
||||
export { TransactionContext, createTransactionContext } from './TransactionContext.js'
|
||||
export { TransactionManager, createTransactionManager } from './TransactionManager.js'
|
||||
export { TransactionContext, createTransactionContext } from './TransactionContext.js';
|
||||
export { TransactionManager, createTransactionManager } from './TransactionManager.js';
|
||||
|
||||
@@ -279,6 +279,15 @@ export interface TransactionManagerConfig {
|
||||
* @en Transaction storage interface
|
||||
*/
|
||||
export interface ITransactionStorage {
|
||||
/**
|
||||
* @zh 关闭存储连接
|
||||
* @en Close storage connection
|
||||
*
|
||||
* @zh 释放所有资源,关闭数据库连接
|
||||
* @en Release all resources, close database connections
|
||||
*/
|
||||
close?(): Promise<void>
|
||||
|
||||
/**
|
||||
* @zh 获取分布式锁
|
||||
* @en Acquire distributed lock
|
||||
|
||||
@@ -10,8 +10,8 @@ import type {
|
||||
ITransactionStorage,
|
||||
TransactionLog,
|
||||
TransactionState,
|
||||
OperationResult,
|
||||
} from '../core/types.js'
|
||||
OperationResult
|
||||
} from '../core/types.js';
|
||||
|
||||
/**
|
||||
* @zh Saga 步骤状态
|
||||
@@ -123,7 +123,7 @@ export interface SagaOrchestratorConfig {
|
||||
* @en Generate Saga ID
|
||||
*/
|
||||
function generateSagaId(): string {
|
||||
return `saga_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 11)}`
|
||||
return `saga_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 11)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,14 +169,14 @@ function generateSagaId(): string {
|
||||
* ```
|
||||
*/
|
||||
export class SagaOrchestrator {
|
||||
private _storage: ITransactionStorage | null
|
||||
private _timeout: number
|
||||
private _serverId: string
|
||||
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'
|
||||
this._storage = config.storage ?? null;
|
||||
this._timeout = config.timeout ?? 30000;
|
||||
this._serverId = config.serverId ?? 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,9 +184,9 @@ export class SagaOrchestrator {
|
||||
* @en Execute Saga
|
||||
*/
|
||||
async execute<T>(steps: SagaStep<T>[]): Promise<SagaResult> {
|
||||
const sagaId = generateSagaId()
|
||||
const startTime = Date.now()
|
||||
const completedSteps: string[] = []
|
||||
const sagaId = generateSagaId();
|
||||
const startTime = Date.now();
|
||||
const completedSteps: string[] = [];
|
||||
|
||||
const sagaLog: SagaLog = {
|
||||
id: sagaId,
|
||||
@@ -194,84 +194,84 @@ export class SagaOrchestrator {
|
||||
steps: steps.map((s) => ({
|
||||
name: s.name,
|
||||
serverId: s.serverId,
|
||||
state: 'pending' as SagaStepState,
|
||||
state: 'pending' as SagaStepState
|
||||
})),
|
||||
createdAt: startTime,
|
||||
updatedAt: startTime,
|
||||
metadata: { orchestratorServerId: this._serverId },
|
||||
}
|
||||
metadata: { orchestratorServerId: this._serverId }
|
||||
};
|
||||
|
||||
await this._saveSagaLog(sagaLog)
|
||||
await this._saveSagaLog(sagaLog);
|
||||
|
||||
try {
|
||||
sagaLog.state = 'running'
|
||||
await this._saveSagaLog(sagaLog)
|
||||
sagaLog.state = 'running';
|
||||
await this._saveSagaLog(sagaLog);
|
||||
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i]
|
||||
const step = steps[i];
|
||||
|
||||
if (Date.now() - startTime > this._timeout) {
|
||||
throw new Error('Saga execution timed out')
|
||||
throw new Error('Saga execution timed out');
|
||||
}
|
||||
|
||||
sagaLog.steps[i].state = 'executing'
|
||||
sagaLog.steps[i].startedAt = Date.now()
|
||||
await this._saveSagaLog(sagaLog)
|
||||
sagaLog.steps[i].state = 'executing';
|
||||
sagaLog.steps[i].startedAt = Date.now();
|
||||
await this._saveSagaLog(sagaLog);
|
||||
|
||||
const result = await step.execute(step.data)
|
||||
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)
|
||||
sagaLog.steps[i].state = 'failed';
|
||||
sagaLog.steps[i].error = result.error;
|
||||
await this._saveSagaLog(sagaLog);
|
||||
|
||||
throw new Error(result.error ?? `Step ${step.name} failed`)
|
||||
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.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)
|
||||
sagaLog.state = 'completed';
|
||||
sagaLog.updatedAt = Date.now();
|
||||
await this._saveSagaLog(sagaLog);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sagaId,
|
||||
completedSteps,
|
||||
duration: Date.now() - startTime,
|
||||
}
|
||||
duration: Date.now() - startTime
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const failedStepIndex = completedSteps.length
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const failedStepIndex = completedSteps.length;
|
||||
|
||||
sagaLog.state = 'compensating'
|
||||
await this._saveSagaLog(sagaLog)
|
||||
sagaLog.state = 'compensating';
|
||||
await this._saveSagaLog(sagaLog);
|
||||
|
||||
for (let i = completedSteps.length - 1; i >= 0; i--) {
|
||||
const step = steps[i]
|
||||
const step = steps[i];
|
||||
|
||||
sagaLog.steps[i].state = 'compensating'
|
||||
await this._saveSagaLog(sagaLog)
|
||||
sagaLog.steps[i].state = 'compensating';
|
||||
await this._saveSagaLog(sagaLog);
|
||||
|
||||
try {
|
||||
await step.compensate(step.data)
|
||||
sagaLog.steps[i].state = 'compensated'
|
||||
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}`
|
||||
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)
|
||||
await this._saveSagaLog(sagaLog);
|
||||
}
|
||||
|
||||
sagaLog.state = 'compensated'
|
||||
sagaLog.updatedAt = Date.now()
|
||||
await this._saveSagaLog(sagaLog)
|
||||
sagaLog.state = 'compensated';
|
||||
sagaLog.updatedAt = Date.now();
|
||||
await this._saveSagaLog(sagaLog);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
@@ -279,8 +279,8 @@ export class SagaOrchestrator {
|
||||
completedSteps,
|
||||
failedStep: steps[failedStepIndex]?.name,
|
||||
error: errorMessage,
|
||||
duration: Date.now() - startTime,
|
||||
}
|
||||
duration: Date.now() - startTime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,21 +289,21 @@ export class SagaOrchestrator {
|
||||
* @en Recover pending Sagas
|
||||
*/
|
||||
async recover(): Promise<number> {
|
||||
if (!this._storage) return 0
|
||||
if (!this._storage) return 0;
|
||||
|
||||
const pendingSagas = await this._getPendingSagas()
|
||||
let recoveredCount = 0
|
||||
const pendingSagas = await this._getPendingSagas();
|
||||
let recoveredCount = 0;
|
||||
|
||||
for (const saga of pendingSagas) {
|
||||
try {
|
||||
await this._recoverSaga(saga)
|
||||
recoveredCount++
|
||||
await this._recoverSaga(saga);
|
||||
recoveredCount++;
|
||||
} catch (error) {
|
||||
console.error(`Failed to recover saga ${saga.id}:`, error)
|
||||
console.error(`Failed to recover saga ${saga.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return recoveredCount
|
||||
return recoveredCount;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -311,31 +311,31 @@ export class SagaOrchestrator {
|
||||
* @en Get Saga log
|
||||
*/
|
||||
async getSagaLog(sagaId: string): Promise<SagaLog | null> {
|
||||
if (!this._storage) return null
|
||||
return this._storage.get<SagaLog>(`saga:${sagaId}`)
|
||||
if (!this._storage) return null;
|
||||
return this._storage.get<SagaLog>(`saga:${sagaId}`);
|
||||
}
|
||||
|
||||
private async _saveSagaLog(log: SagaLog): Promise<void> {
|
||||
if (!this._storage) return
|
||||
log.updatedAt = Date.now()
|
||||
await this._storage.set(`saga:${log.id}`, log)
|
||||
if (!this._storage) return;
|
||||
log.updatedAt = Date.now();
|
||||
await this._storage.set(`saga:${log.id}`, log);
|
||||
}
|
||||
|
||||
private async _getPendingSagas(): Promise<SagaLog[]> {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
|
||||
private async _recoverSaga(saga: SagaLog): Promise<void> {
|
||||
if (saga.state === 'running' || saga.state === 'compensating') {
|
||||
const completedSteps = saga.steps
|
||||
.filter((s) => s.state === 'completed')
|
||||
.map((s) => s.name)
|
||||
.map((s) => s.name);
|
||||
|
||||
saga.state = 'compensated'
|
||||
saga.updatedAt = Date.now()
|
||||
saga.state = 'compensated';
|
||||
saga.updatedAt = Date.now();
|
||||
|
||||
if (this._storage) {
|
||||
await this._storage.set(`saga:${saga.id}`, saga)
|
||||
await this._storage.set(`saga:${saga.id}`, saga);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,5 +346,5 @@ export class SagaOrchestrator {
|
||||
* @en Create Saga orchestrator
|
||||
*/
|
||||
export function createSagaOrchestrator(config: SagaOrchestratorConfig = {}): SagaOrchestrator {
|
||||
return new SagaOrchestrator(config)
|
||||
return new SagaOrchestrator(config);
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@ export {
|
||||
type SagaStepState,
|
||||
type SagaStepLog,
|
||||
type SagaLog,
|
||||
type SagaResult,
|
||||
} from './SagaOrchestrator.js'
|
||||
type SagaResult
|
||||
} from './SagaOrchestrator.js';
|
||||
|
||||
@@ -55,18 +55,18 @@ export type {
|
||||
TransactionManagerConfig,
|
||||
ITransactionStorage,
|
||||
ITransactionOperation,
|
||||
ITransactionContext,
|
||||
} from './core/types.js'
|
||||
ITransactionContext
|
||||
} from './core/types.js';
|
||||
|
||||
export {
|
||||
TransactionContext,
|
||||
createTransactionContext,
|
||||
} from './core/TransactionContext.js'
|
||||
createTransactionContext
|
||||
} from './core/TransactionContext.js';
|
||||
|
||||
export {
|
||||
TransactionManager,
|
||||
createTransactionManager,
|
||||
} from './core/TransactionManager.js'
|
||||
createTransactionManager
|
||||
} from './core/TransactionManager.js';
|
||||
|
||||
// =============================================================================
|
||||
// Storage | 存储
|
||||
@@ -75,29 +75,29 @@ export {
|
||||
export {
|
||||
MemoryStorage,
|
||||
createMemoryStorage,
|
||||
type MemoryStorageConfig,
|
||||
} from './storage/MemoryStorage.js'
|
||||
type MemoryStorageConfig
|
||||
} from './storage/MemoryStorage.js';
|
||||
|
||||
export {
|
||||
RedisStorage,
|
||||
createRedisStorage,
|
||||
type RedisStorageConfig,
|
||||
type RedisClient,
|
||||
} from './storage/RedisStorage.js'
|
||||
type RedisClient
|
||||
} from './storage/RedisStorage.js';
|
||||
|
||||
export {
|
||||
MongoStorage,
|
||||
createMongoStorage,
|
||||
type MongoStorageConfig,
|
||||
type MongoDb,
|
||||
type MongoCollection,
|
||||
} from './storage/MongoStorage.js'
|
||||
type MongoCollection
|
||||
} from './storage/MongoStorage.js';
|
||||
|
||||
// =============================================================================
|
||||
// Operations | 操作
|
||||
// =============================================================================
|
||||
|
||||
export { BaseOperation } from './operations/BaseOperation.js'
|
||||
export { BaseOperation } from './operations/BaseOperation.js';
|
||||
|
||||
export {
|
||||
CurrencyOperation,
|
||||
@@ -105,8 +105,8 @@ export {
|
||||
type CurrencyOperationType,
|
||||
type CurrencyOperationData,
|
||||
type CurrencyOperationResult,
|
||||
type ICurrencyProvider,
|
||||
} from './operations/CurrencyOperation.js'
|
||||
type ICurrencyProvider
|
||||
} from './operations/CurrencyOperation.js';
|
||||
|
||||
export {
|
||||
InventoryOperation,
|
||||
@@ -115,8 +115,8 @@ export {
|
||||
type InventoryOperationData,
|
||||
type InventoryOperationResult,
|
||||
type IInventoryProvider,
|
||||
type ItemData,
|
||||
} from './operations/InventoryOperation.js'
|
||||
type ItemData
|
||||
} from './operations/InventoryOperation.js';
|
||||
|
||||
export {
|
||||
TradeOperation,
|
||||
@@ -126,8 +126,8 @@ export {
|
||||
type TradeItem,
|
||||
type TradeCurrency,
|
||||
type TradeParty,
|
||||
type ITradeProvider,
|
||||
} from './operations/TradeOperation.js'
|
||||
type ITradeProvider
|
||||
} from './operations/TradeOperation.js';
|
||||
|
||||
// =============================================================================
|
||||
// Distributed | 分布式
|
||||
@@ -141,8 +141,8 @@ export {
|
||||
type SagaStepState,
|
||||
type SagaStepLog,
|
||||
type SagaLog,
|
||||
type SagaResult,
|
||||
} from './distributed/SagaOrchestrator.js'
|
||||
type SagaResult
|
||||
} from './distributed/SagaOrchestrator.js';
|
||||
|
||||
// =============================================================================
|
||||
// Integration | 集成
|
||||
@@ -152,8 +152,8 @@ export {
|
||||
withTransactions,
|
||||
TransactionRoom,
|
||||
type TransactionRoomConfig,
|
||||
type ITransactionRoom,
|
||||
} from './integration/RoomTransactionMixin.js'
|
||||
type ITransactionRoom
|
||||
} from './integration/RoomTransactionMixin.js';
|
||||
|
||||
// =============================================================================
|
||||
// Tokens | 令牌
|
||||
@@ -161,5 +161,5 @@ export {
|
||||
|
||||
export {
|
||||
TransactionManagerToken,
|
||||
TransactionStorageToken,
|
||||
} from './tokens.js'
|
||||
TransactionStorageToken
|
||||
} from './tokens.js';
|
||||
|
||||
@@ -7,9 +7,9 @@ import type {
|
||||
ITransactionStorage,
|
||||
ITransactionContext,
|
||||
TransactionOptions,
|
||||
TransactionResult,
|
||||
} from '../core/types.js'
|
||||
import { TransactionManager } from '../core/TransactionManager.js'
|
||||
TransactionResult
|
||||
} from '../core/types.js';
|
||||
import { TransactionManager } from '../core/TransactionManager.js';
|
||||
|
||||
/**
|
||||
* @zh 事务 Room 配置
|
||||
@@ -96,32 +96,32 @@ export function withTransactions<TBase extends new (...args: any[]) => any>(
|
||||
config: TransactionRoomConfig = {}
|
||||
): TBase & (new (...args: any[]) => ITransactionRoom) {
|
||||
return class TransactionRoom extends Base implements ITransactionRoom {
|
||||
private _transactionManager: TransactionManager
|
||||
private _transactionManager: TransactionManager;
|
||||
|
||||
constructor(...args: any[]) {
|
||||
super(...args)
|
||||
super(...args);
|
||||
this._transactionManager = new TransactionManager({
|
||||
storage: config.storage,
|
||||
defaultTimeout: config.defaultTimeout,
|
||||
serverId: config.serverId,
|
||||
})
|
||||
serverId: config.serverId
|
||||
});
|
||||
}
|
||||
|
||||
get transactions(): TransactionManager {
|
||||
return this._transactionManager
|
||||
return this._transactionManager;
|
||||
}
|
||||
|
||||
beginTransaction(options?: TransactionOptions): ITransactionContext {
|
||||
return this._transactionManager.begin(options)
|
||||
return this._transactionManager.begin(options);
|
||||
}
|
||||
|
||||
runTransaction<T = unknown>(
|
||||
builder: (ctx: ITransactionContext) => void | Promise<void>,
|
||||
options?: TransactionOptions
|
||||
): Promise<TransactionResult<T>> {
|
||||
return this._transactionManager.run<T>(builder, options)
|
||||
return this._transactionManager.run<T>(builder, options);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,28 +147,28 @@ export function withTransactions<TBase extends new (...args: any[]) => any>(
|
||||
* ```
|
||||
*/
|
||||
export abstract class TransactionRoom implements ITransactionRoom {
|
||||
private _transactionManager: TransactionManager
|
||||
private _transactionManager: TransactionManager;
|
||||
|
||||
constructor(config: TransactionRoomConfig = {}) {
|
||||
this._transactionManager = new TransactionManager({
|
||||
storage: config.storage,
|
||||
defaultTimeout: config.defaultTimeout,
|
||||
serverId: config.serverId,
|
||||
})
|
||||
serverId: config.serverId
|
||||
});
|
||||
}
|
||||
|
||||
get transactions(): TransactionManager {
|
||||
return this._transactionManager
|
||||
return this._transactionManager;
|
||||
}
|
||||
|
||||
beginTransaction(options?: TransactionOptions): ITransactionContext {
|
||||
return this._transactionManager.begin(options)
|
||||
return this._transactionManager.begin(options);
|
||||
}
|
||||
|
||||
runTransaction<T = unknown>(
|
||||
builder: (ctx: ITransactionContext) => void | Promise<void>,
|
||||
options?: TransactionOptions
|
||||
): Promise<TransactionResult<T>> {
|
||||
return this._transactionManager.run<T>(builder, options)
|
||||
return this._transactionManager.run<T>(builder, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@ export {
|
||||
withTransactions,
|
||||
TransactionRoom,
|
||||
type TransactionRoomConfig,
|
||||
type ITransactionRoom,
|
||||
} from './RoomTransactionMixin.js'
|
||||
type ITransactionRoom
|
||||
} from './RoomTransactionMixin.js';
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
import type {
|
||||
ITransactionOperation,
|
||||
ITransactionContext,
|
||||
OperationResult,
|
||||
} from '../core/types.js'
|
||||
OperationResult
|
||||
} from '../core/types.js';
|
||||
|
||||
/**
|
||||
* @zh 操作基类
|
||||
@@ -17,13 +17,13 @@ import type {
|
||||
* @en Provides common operation implementation template
|
||||
*/
|
||||
export abstract class BaseOperation<TData = unknown, TResult = unknown>
|
||||
implements ITransactionOperation<TData, TResult>
|
||||
implements ITransactionOperation<TData, TResult>
|
||||
{
|
||||
abstract readonly name: string
|
||||
readonly data: TData
|
||||
readonly data: TData;
|
||||
|
||||
constructor(data: TData) {
|
||||
this.data = data
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,7 +31,7 @@ export abstract class BaseOperation<TData = unknown, TResult = unknown>
|
||||
* @en Validate preconditions (passes by default)
|
||||
*/
|
||||
async validate(_ctx: ITransactionContext): Promise<boolean> {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,7 +51,7 @@ export abstract class BaseOperation<TData = unknown, TResult = unknown>
|
||||
* @en Create success result
|
||||
*/
|
||||
protected success(data?: TResult): OperationResult<TResult> {
|
||||
return { success: true, data }
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,6 +59,6 @@ export abstract class BaseOperation<TData = unknown, TResult = unknown>
|
||||
* @en Create failure result
|
||||
*/
|
||||
protected failure(error: string, errorCode?: string): OperationResult<TResult> {
|
||||
return { success: false, error, errorCode }
|
||||
return { success: false, error, errorCode };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* @en Currency operation
|
||||
*/
|
||||
|
||||
import type { ITransactionContext, OperationResult } from '../core/types.js'
|
||||
import { BaseOperation } from './BaseOperation.js'
|
||||
import type { ITransactionContext, OperationResult } from '../core/types.js';
|
||||
import { BaseOperation } from './BaseOperation.js';
|
||||
|
||||
/**
|
||||
* @zh 货币操作类型
|
||||
@@ -112,89 +112,89 @@ export interface ICurrencyProvider {
|
||||
* ```
|
||||
*/
|
||||
export class CurrencyOperation extends BaseOperation<CurrencyOperationData, CurrencyOperationResult> {
|
||||
readonly name = 'currency'
|
||||
readonly name = 'currency';
|
||||
|
||||
private _provider: ICurrencyProvider | null = null
|
||||
private _beforeBalance: number = 0
|
||||
private _provider: ICurrencyProvider | null = null;
|
||||
private _beforeBalance: number = 0;
|
||||
|
||||
/**
|
||||
* @zh 设置货币数据提供者
|
||||
* @en Set currency data provider
|
||||
*/
|
||||
setProvider(provider: ICurrencyProvider): this {
|
||||
this._provider = provider
|
||||
return this
|
||||
this._provider = provider;
|
||||
return this;
|
||||
}
|
||||
|
||||
async validate(ctx: ITransactionContext): Promise<boolean> {
|
||||
if (this.data.amount <= 0) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.data.type === 'deduct') {
|
||||
const balance = await this._getBalance(ctx)
|
||||
return balance >= this.data.amount
|
||||
const balance = await this._getBalance(ctx);
|
||||
return balance >= this.data.amount;
|
||||
}
|
||||
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
async execute(ctx: ITransactionContext): Promise<OperationResult<CurrencyOperationResult>> {
|
||||
const { type, playerId, currency, amount } = this.data
|
||||
const { type, playerId, currency, amount } = this.data;
|
||||
|
||||
this._beforeBalance = await this._getBalance(ctx)
|
||||
this._beforeBalance = await this._getBalance(ctx);
|
||||
|
||||
let afterBalance: number
|
||||
let afterBalance: number;
|
||||
|
||||
if (type === 'add') {
|
||||
afterBalance = this._beforeBalance + amount
|
||||
afterBalance = this._beforeBalance + amount;
|
||||
} else {
|
||||
if (this._beforeBalance < amount) {
|
||||
return this.failure('Insufficient balance', 'INSUFFICIENT_BALANCE')
|
||||
return this.failure('Insufficient balance', 'INSUFFICIENT_BALANCE');
|
||||
}
|
||||
afterBalance = this._beforeBalance - amount
|
||||
afterBalance = this._beforeBalance - amount;
|
||||
}
|
||||
|
||||
await this._setBalance(ctx, afterBalance)
|
||||
await this._setBalance(ctx, afterBalance);
|
||||
|
||||
ctx.set(`currency:${playerId}:${currency}:before`, this._beforeBalance)
|
||||
ctx.set(`currency:${playerId}:${currency}:after`, afterBalance)
|
||||
ctx.set(`currency:${playerId}:${currency}:before`, this._beforeBalance);
|
||||
ctx.set(`currency:${playerId}:${currency}:after`, afterBalance);
|
||||
|
||||
return this.success({
|
||||
beforeBalance: this._beforeBalance,
|
||||
afterBalance,
|
||||
})
|
||||
afterBalance
|
||||
});
|
||||
}
|
||||
|
||||
async compensate(ctx: ITransactionContext): Promise<void> {
|
||||
await this._setBalance(ctx, this._beforeBalance)
|
||||
await this._setBalance(ctx, this._beforeBalance);
|
||||
}
|
||||
|
||||
private async _getBalance(ctx: ITransactionContext): Promise<number> {
|
||||
const { playerId, currency } = this.data
|
||||
const { playerId, currency } = this.data;
|
||||
|
||||
if (this._provider) {
|
||||
return this._provider.getBalance(playerId, currency)
|
||||
return this._provider.getBalance(playerId, currency);
|
||||
}
|
||||
|
||||
if (ctx.storage) {
|
||||
const balance = await ctx.storage.get<number>(`player:${playerId}:currency:${currency}`)
|
||||
return balance ?? 0
|
||||
const balance = await ctx.storage.get<number>(`player:${playerId}:currency:${currency}`);
|
||||
return balance ?? 0;
|
||||
}
|
||||
|
||||
return 0
|
||||
return 0;
|
||||
}
|
||||
|
||||
private async _setBalance(ctx: ITransactionContext, amount: number): Promise<void> {
|
||||
const { playerId, currency } = this.data
|
||||
const { playerId, currency } = this.data;
|
||||
|
||||
if (this._provider) {
|
||||
await this._provider.setBalance(playerId, currency, amount)
|
||||
return
|
||||
await this._provider.setBalance(playerId, currency, amount);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.storage) {
|
||||
await ctx.storage.set(`player:${playerId}:currency:${currency}`, amount)
|
||||
await ctx.storage.set(`player:${playerId}:currency:${currency}`, amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,5 +204,5 @@ export class CurrencyOperation extends BaseOperation<CurrencyOperationData, Curr
|
||||
* @en Create currency operation
|
||||
*/
|
||||
export function createCurrencyOperation(data: CurrencyOperationData): CurrencyOperation {
|
||||
return new CurrencyOperation(data)
|
||||
return new CurrencyOperation(data);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* @en Inventory operation
|
||||
*/
|
||||
|
||||
import type { ITransactionContext, OperationResult } from '../core/types.js'
|
||||
import { BaseOperation } from './BaseOperation.js'
|
||||
import type { ITransactionContext, OperationResult } from '../core/types.js';
|
||||
import { BaseOperation } from './BaseOperation.js';
|
||||
|
||||
/**
|
||||
* @zh 背包操作类型
|
||||
@@ -147,136 +147,136 @@ export interface IInventoryProvider {
|
||||
* ```
|
||||
*/
|
||||
export class InventoryOperation extends BaseOperation<InventoryOperationData, InventoryOperationResult> {
|
||||
readonly name = 'inventory'
|
||||
readonly name = 'inventory';
|
||||
|
||||
private _provider: IInventoryProvider | null = null
|
||||
private _beforeItem: ItemData | null = null
|
||||
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
|
||||
this._provider = provider;
|
||||
return this;
|
||||
}
|
||||
|
||||
async validate(ctx: ITransactionContext): Promise<boolean> {
|
||||
const { type, quantity } = this.data
|
||||
const { type, quantity } = this.data;
|
||||
|
||||
if (quantity <= 0) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
if (type === 'remove') {
|
||||
const item = await this._getItem(ctx)
|
||||
return item !== null && item.quantity >= quantity
|
||||
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 this._provider.hasCapacity(this.data.playerId, 1);
|
||||
}
|
||||
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
async execute(ctx: ITransactionContext): Promise<OperationResult<InventoryOperationResult>> {
|
||||
const { type, playerId, itemId, quantity, properties } = this.data
|
||||
const { type, playerId, itemId, quantity, properties } = this.data;
|
||||
|
||||
this._beforeItem = await this._getItem(ctx)
|
||||
this._beforeItem = await this._getItem(ctx);
|
||||
|
||||
let afterItem: ItemData | null = null
|
||||
let afterItem: ItemData | null = null;
|
||||
|
||||
switch (type) {
|
||||
case 'add': {
|
||||
if (this._beforeItem) {
|
||||
afterItem = {
|
||||
...this._beforeItem,
|
||||
quantity: this._beforeItem.quantity + quantity,
|
||||
}
|
||||
quantity: this._beforeItem.quantity + quantity
|
||||
};
|
||||
} else {
|
||||
afterItem = {
|
||||
itemId,
|
||||
quantity,
|
||||
properties,
|
||||
}
|
||||
properties
|
||||
};
|
||||
}
|
||||
break
|
||||
break;
|
||||
}
|
||||
|
||||
case 'remove': {
|
||||
if (!this._beforeItem || this._beforeItem.quantity < quantity) {
|
||||
return this.failure('Insufficient item quantity', 'INSUFFICIENT_ITEM')
|
||||
return this.failure('Insufficient item quantity', 'INSUFFICIENT_ITEM');
|
||||
}
|
||||
|
||||
const newQuantity = this._beforeItem.quantity - quantity
|
||||
const newQuantity = this._beforeItem.quantity - quantity;
|
||||
if (newQuantity > 0) {
|
||||
afterItem = {
|
||||
...this._beforeItem,
|
||||
quantity: newQuantity,
|
||||
}
|
||||
quantity: newQuantity
|
||||
};
|
||||
} else {
|
||||
afterItem = null
|
||||
afterItem = null;
|
||||
}
|
||||
break
|
||||
break;
|
||||
}
|
||||
|
||||
case 'update': {
|
||||
if (!this._beforeItem) {
|
||||
return this.failure('Item not found', 'ITEM_NOT_FOUND')
|
||||
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
|
||||
properties: properties ?? this._beforeItem.properties
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await this._setItem(ctx, afterItem)
|
||||
await this._setItem(ctx, afterItem);
|
||||
|
||||
ctx.set(`inventory:${playerId}:${itemId}:before`, this._beforeItem)
|
||||
ctx.set(`inventory:${playerId}:${itemId}:after`, 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,
|
||||
})
|
||||
afterItem: afterItem ?? undefined
|
||||
});
|
||||
}
|
||||
|
||||
async compensate(ctx: ITransactionContext): Promise<void> {
|
||||
await this._setItem(ctx, this._beforeItem)
|
||||
await this._setItem(ctx, this._beforeItem);
|
||||
}
|
||||
|
||||
private async _getItem(ctx: ITransactionContext): Promise<ItemData | null> {
|
||||
const { playerId, itemId } = this.data
|
||||
const { playerId, itemId } = this.data;
|
||||
|
||||
if (this._provider) {
|
||||
return this._provider.getItem(playerId, itemId)
|
||||
return this._provider.getItem(playerId, itemId);
|
||||
}
|
||||
|
||||
if (ctx.storage) {
|
||||
return ctx.storage.get<ItemData>(`player:${playerId}:inventory:${itemId}`)
|
||||
return ctx.storage.get<ItemData>(`player:${playerId}:inventory:${itemId}`);
|
||||
}
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
private async _setItem(ctx: ITransactionContext, item: ItemData | null): Promise<void> {
|
||||
const { playerId, itemId } = this.data
|
||||
const { playerId, itemId } = this.data;
|
||||
|
||||
if (this._provider) {
|
||||
await this._provider.setItem(playerId, itemId, item)
|
||||
return
|
||||
await this._provider.setItem(playerId, itemId, item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.storage) {
|
||||
if (item) {
|
||||
await ctx.storage.set(`player:${playerId}:inventory:${itemId}`, item)
|
||||
await ctx.storage.set(`player:${playerId}:inventory:${itemId}`, item);
|
||||
} else {
|
||||
await ctx.storage.delete(`player:${playerId}:inventory:${itemId}`)
|
||||
await ctx.storage.delete(`player:${playerId}:inventory:${itemId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,5 +287,5 @@ export class InventoryOperation extends BaseOperation<InventoryOperationData, In
|
||||
* @en Create inventory operation
|
||||
*/
|
||||
export function createInventoryOperation(data: InventoryOperationData): InventoryOperation {
|
||||
return new InventoryOperation(data)
|
||||
return new InventoryOperation(data);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
* @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'
|
||||
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 交易物品
|
||||
@@ -148,67 +148,67 @@ export interface ITradeProvider {
|
||||
* ```
|
||||
*/
|
||||
export class TradeOperation extends BaseOperation<TradeOperationData, TradeOperationResult> {
|
||||
readonly name = 'trade'
|
||||
readonly name = 'trade';
|
||||
|
||||
private _provider: ITradeProvider | null = null
|
||||
private _subOperations: (CurrencyOperation | InventoryOperation)[] = []
|
||||
private _executedCount = 0
|
||||
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
|
||||
this._provider = provider;
|
||||
return this;
|
||||
}
|
||||
|
||||
async validate(ctx: ITransactionContext): Promise<boolean> {
|
||||
this._buildSubOperations()
|
||||
this._buildSubOperations();
|
||||
|
||||
for (const op of this._subOperations) {
|
||||
const isValid = await op.validate(ctx)
|
||||
const isValid = await op.validate(ctx);
|
||||
if (!isValid) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
async execute(ctx: ITransactionContext): Promise<OperationResult<TradeOperationResult>> {
|
||||
this._buildSubOperations()
|
||||
this._executedCount = 0
|
||||
this._buildSubOperations();
|
||||
this._executedCount = 0;
|
||||
|
||||
try {
|
||||
for (const op of this._subOperations) {
|
||||
const result = await op.execute(ctx)
|
||||
const result = await op.execute(ctx);
|
||||
if (!result.success) {
|
||||
await this._compensateExecuted(ctx)
|
||||
return this.failure(result.error ?? 'Trade operation failed', 'TRADE_FAILED')
|
||||
await this._compensateExecuted(ctx);
|
||||
return this.failure(result.error ?? 'Trade operation failed', 'TRADE_FAILED');
|
||||
}
|
||||
this._executedCount++
|
||||
this._executedCount++;
|
||||
}
|
||||
|
||||
return this.success({
|
||||
tradeId: this.data.tradeId,
|
||||
completed: true,
|
||||
})
|
||||
completed: true
|
||||
});
|
||||
} catch (error) {
|
||||
await this._compensateExecuted(ctx)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return this.failure(errorMessage, 'TRADE_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<void> {
|
||||
await this._compensateExecuted(ctx)
|
||||
await this._compensateExecuted(ctx);
|
||||
}
|
||||
|
||||
private _buildSubOperations(): void {
|
||||
if (this._subOperations.length > 0) return
|
||||
if (this._subOperations.length > 0) return;
|
||||
|
||||
const { partyA, partyB } = this.data
|
||||
const { partyA, partyB } = this.data;
|
||||
|
||||
if (partyA.items) {
|
||||
for (const item of partyA.items) {
|
||||
@@ -217,22 +217,22 @@ export class TradeOperation extends BaseOperation<TradeOperationData, TradeOpera
|
||||
playerId: partyA.playerId,
|
||||
itemId: item.itemId,
|
||||
quantity: item.quantity,
|
||||
reason: `trade:${this.data.tradeId}:give`,
|
||||
})
|
||||
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`,
|
||||
})
|
||||
reason: `trade:${this.data.tradeId}:receive`
|
||||
});
|
||||
|
||||
if (this._provider?.inventoryProvider) {
|
||||
removeOp.setProvider(this._provider.inventoryProvider)
|
||||
addOp.setProvider(this._provider.inventoryProvider)
|
||||
removeOp.setProvider(this._provider.inventoryProvider);
|
||||
addOp.setProvider(this._provider.inventoryProvider);
|
||||
}
|
||||
|
||||
this._subOperations.push(removeOp, addOp)
|
||||
this._subOperations.push(removeOp, addOp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,22 +243,22 @@ export class TradeOperation extends BaseOperation<TradeOperationData, TradeOpera
|
||||
playerId: partyA.playerId,
|
||||
currency: curr.currency,
|
||||
amount: curr.amount,
|
||||
reason: `trade:${this.data.tradeId}:give`,
|
||||
})
|
||||
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`,
|
||||
})
|
||||
reason: `trade:${this.data.tradeId}:receive`
|
||||
});
|
||||
|
||||
if (this._provider?.currencyProvider) {
|
||||
deductOp.setProvider(this._provider.currencyProvider)
|
||||
addOp.setProvider(this._provider.currencyProvider)
|
||||
deductOp.setProvider(this._provider.currencyProvider);
|
||||
addOp.setProvider(this._provider.currencyProvider);
|
||||
}
|
||||
|
||||
this._subOperations.push(deductOp, addOp)
|
||||
this._subOperations.push(deductOp, addOp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,22 +269,22 @@ export class TradeOperation extends BaseOperation<TradeOperationData, TradeOpera
|
||||
playerId: partyB.playerId,
|
||||
itemId: item.itemId,
|
||||
quantity: item.quantity,
|
||||
reason: `trade:${this.data.tradeId}:give`,
|
||||
})
|
||||
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`,
|
||||
})
|
||||
reason: `trade:${this.data.tradeId}:receive`
|
||||
});
|
||||
|
||||
if (this._provider?.inventoryProvider) {
|
||||
removeOp.setProvider(this._provider.inventoryProvider)
|
||||
addOp.setProvider(this._provider.inventoryProvider)
|
||||
removeOp.setProvider(this._provider.inventoryProvider);
|
||||
addOp.setProvider(this._provider.inventoryProvider);
|
||||
}
|
||||
|
||||
this._subOperations.push(removeOp, addOp)
|
||||
this._subOperations.push(removeOp, addOp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,29 +295,29 @@ export class TradeOperation extends BaseOperation<TradeOperationData, TradeOpera
|
||||
playerId: partyB.playerId,
|
||||
currency: curr.currency,
|
||||
amount: curr.amount,
|
||||
reason: `trade:${this.data.tradeId}:give`,
|
||||
})
|
||||
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`,
|
||||
})
|
||||
reason: `trade:${this.data.tradeId}:receive`
|
||||
});
|
||||
|
||||
if (this._provider?.currencyProvider) {
|
||||
deductOp.setProvider(this._provider.currencyProvider)
|
||||
addOp.setProvider(this._provider.currencyProvider)
|
||||
deductOp.setProvider(this._provider.currencyProvider);
|
||||
addOp.setProvider(this._provider.currencyProvider);
|
||||
}
|
||||
|
||||
this._subOperations.push(deductOp, addOp)
|
||||
this._subOperations.push(deductOp, addOp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _compensateExecuted(ctx: ITransactionContext): Promise<void> {
|
||||
for (let i = this._executedCount - 1; i >= 0; i--) {
|
||||
await this._subOperations[i].compensate(ctx)
|
||||
await this._subOperations[i].compensate(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,5 +327,5 @@ export class TradeOperation extends BaseOperation<TradeOperationData, TradeOpera
|
||||
* @en Create trade operation
|
||||
*/
|
||||
export function createTradeOperation(data: TradeOperationData): TradeOperation {
|
||||
return new TradeOperation(data)
|
||||
return new TradeOperation(data);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @en Operations module exports
|
||||
*/
|
||||
|
||||
export { BaseOperation } from './BaseOperation.js'
|
||||
export { BaseOperation } from './BaseOperation.js';
|
||||
|
||||
export {
|
||||
CurrencyOperation,
|
||||
@@ -11,8 +11,8 @@ export {
|
||||
type CurrencyOperationType,
|
||||
type CurrencyOperationData,
|
||||
type CurrencyOperationResult,
|
||||
type ICurrencyProvider,
|
||||
} from './CurrencyOperation.js'
|
||||
type ICurrencyProvider
|
||||
} from './CurrencyOperation.js';
|
||||
|
||||
export {
|
||||
InventoryOperation,
|
||||
@@ -21,8 +21,8 @@ export {
|
||||
type InventoryOperationData,
|
||||
type InventoryOperationResult,
|
||||
type IInventoryProvider,
|
||||
type ItemData,
|
||||
} from './InventoryOperation.js'
|
||||
type ItemData
|
||||
} from './InventoryOperation.js';
|
||||
|
||||
export {
|
||||
TradeOperation,
|
||||
@@ -32,5 +32,5 @@ export {
|
||||
type TradeItem,
|
||||
type TradeCurrency,
|
||||
type TradeParty,
|
||||
type ITradeProvider,
|
||||
} from './TradeOperation.js'
|
||||
type ITradeProvider
|
||||
} from './TradeOperation.js';
|
||||
|
||||
@@ -10,8 +10,8 @@ import type {
|
||||
ITransactionStorage,
|
||||
TransactionLog,
|
||||
TransactionState,
|
||||
OperationLog,
|
||||
} from '../core/types.js'
|
||||
OperationLog
|
||||
} from '../core/types.js';
|
||||
|
||||
/**
|
||||
* @zh 内存存储配置
|
||||
@@ -33,13 +33,13 @@ export interface MemoryStorageConfig {
|
||||
* @en Suitable for single-machine development and testing, data is stored in memory only
|
||||
*/
|
||||
export class MemoryStorage implements ITransactionStorage {
|
||||
private _transactions: Map<string, TransactionLog> = new Map()
|
||||
private _data: Map<string, { value: unknown; expireAt?: number }> = new Map()
|
||||
private _locks: Map<string, { token: string; expireAt: number }> = new Map()
|
||||
private _maxTransactions: number
|
||||
private _transactions: Map<string, TransactionLog> = new Map();
|
||||
private _data: Map<string, { value: unknown; expireAt?: number }> = new Map();
|
||||
private _locks: Map<string, { token: string; expireAt: number }> = new Map();
|
||||
private _maxTransactions: number;
|
||||
|
||||
constructor(config: MemoryStorageConfig = {}) {
|
||||
this._maxTransactions = config.maxTransactions ?? 1000
|
||||
this._maxTransactions = config.maxTransactions ?? 1000;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -47,30 +47,30 @@ export class MemoryStorage implements ITransactionStorage {
|
||||
// =========================================================================
|
||||
|
||||
async acquireLock(key: string, ttl: number): Promise<string | null> {
|
||||
this._cleanExpiredLocks()
|
||||
this._cleanExpiredLocks();
|
||||
|
||||
const existing = this._locks.get(key)
|
||||
const existing = this._locks.get(key);
|
||||
if (existing && existing.expireAt > Date.now()) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = `lock_${Date.now()}_${Math.random().toString(36).substring(2)}`
|
||||
const token = `lock_${Date.now()}_${Math.random().toString(36).substring(2)}`;
|
||||
this._locks.set(key, {
|
||||
token,
|
||||
expireAt: Date.now() + ttl,
|
||||
})
|
||||
expireAt: Date.now() + ttl
|
||||
});
|
||||
|
||||
return token
|
||||
return token;
|
||||
}
|
||||
|
||||
async releaseLock(key: string, token: string): Promise<boolean> {
|
||||
const lock = this._locks.get(key)
|
||||
const lock = this._locks.get(key);
|
||||
if (!lock || lock.token !== token) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
this._locks.delete(key)
|
||||
return true
|
||||
this._locks.delete(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -79,22 +79,22 @@ export class MemoryStorage implements ITransactionStorage {
|
||||
|
||||
async saveTransaction(tx: TransactionLog): Promise<void> {
|
||||
if (this._transactions.size >= this._maxTransactions) {
|
||||
this._cleanOldTransactions()
|
||||
this._cleanOldTransactions();
|
||||
}
|
||||
|
||||
this._transactions.set(tx.id, { ...tx })
|
||||
this._transactions.set(tx.id, { ...tx });
|
||||
}
|
||||
|
||||
async getTransaction(id: string): Promise<TransactionLog | null> {
|
||||
const tx = this._transactions.get(id)
|
||||
return tx ? { ...tx } : null
|
||||
const tx = this._transactions.get(id);
|
||||
return tx ? { ...tx } : null;
|
||||
}
|
||||
|
||||
async updateTransactionState(id: string, state: TransactionState): Promise<void> {
|
||||
const tx = this._transactions.get(id)
|
||||
const tx = this._transactions.get(id);
|
||||
if (tx) {
|
||||
tx.state = state
|
||||
tx.updatedAt = Date.now()
|
||||
tx.state = state;
|
||||
tx.updatedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,37 +104,37 @@ export class MemoryStorage implements ITransactionStorage {
|
||||
state: OperationLog['state'],
|
||||
error?: string
|
||||
): Promise<void> {
|
||||
const tx = this._transactions.get(transactionId)
|
||||
const tx = this._transactions.get(transactionId);
|
||||
if (tx && tx.operations[operationIndex]) {
|
||||
tx.operations[operationIndex].state = state
|
||||
tx.operations[operationIndex].state = state;
|
||||
if (error) {
|
||||
tx.operations[operationIndex].error = error
|
||||
tx.operations[operationIndex].error = error;
|
||||
}
|
||||
if (state === 'executed') {
|
||||
tx.operations[operationIndex].executedAt = Date.now()
|
||||
tx.operations[operationIndex].executedAt = Date.now();
|
||||
} else if (state === 'compensated') {
|
||||
tx.operations[operationIndex].compensatedAt = Date.now()
|
||||
tx.operations[operationIndex].compensatedAt = Date.now();
|
||||
}
|
||||
tx.updatedAt = Date.now()
|
||||
tx.updatedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
async getPendingTransactions(serverId?: string): Promise<TransactionLog[]> {
|
||||
const result: TransactionLog[] = []
|
||||
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 })
|
||||
result.push({ ...tx });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteTransaction(id: string): Promise<void> {
|
||||
this._transactions.delete(id)
|
||||
this._transactions.delete(id);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -142,28 +142,28 @@ export class MemoryStorage implements ITransactionStorage {
|
||||
// =========================================================================
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
this._cleanExpiredData()
|
||||
this._cleanExpiredData();
|
||||
|
||||
const entry = this._data.get(key)
|
||||
if (!entry) return null
|
||||
const entry = this._data.get(key);
|
||||
if (!entry) return null;
|
||||
|
||||
if (entry.expireAt && entry.expireAt < Date.now()) {
|
||||
this._data.delete(key)
|
||||
return null
|
||||
this._data.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.value as T
|
||||
return entry.value as T;
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
||||
this._data.set(key, {
|
||||
value,
|
||||
expireAt: ttl ? Date.now() + ttl : undefined,
|
||||
})
|
||||
expireAt: ttl ? Date.now() + ttl : undefined
|
||||
});
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<boolean> {
|
||||
return this._data.delete(key)
|
||||
return this._data.delete(key);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -175,9 +175,9 @@ export class MemoryStorage implements ITransactionStorage {
|
||||
* @en Clear all data (for testing)
|
||||
*/
|
||||
clear(): void {
|
||||
this._transactions.clear()
|
||||
this._data.clear()
|
||||
this._locks.clear()
|
||||
this._transactions.clear();
|
||||
this._data.clear();
|
||||
this._locks.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -185,37 +185,37 @@ export class MemoryStorage implements ITransactionStorage {
|
||||
* @en Get transaction count
|
||||
*/
|
||||
get transactionCount(): number {
|
||||
return this._transactions.size
|
||||
return this._transactions.size;
|
||||
}
|
||||
|
||||
private _cleanExpiredLocks(): void {
|
||||
const now = Date.now()
|
||||
const now = Date.now();
|
||||
for (const [key, lock] of this._locks) {
|
||||
if (lock.expireAt < now) {
|
||||
this._locks.delete(key)
|
||||
this._locks.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _cleanExpiredData(): void {
|
||||
const now = Date.now()
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this._data) {
|
||||
if (entry.expireAt && entry.expireAt < now) {
|
||||
this._data.delete(key)
|
||||
this._data.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _cleanOldTransactions(): void {
|
||||
const sorted = Array.from(this._transactions.entries())
|
||||
.sort((a, b) => a[1].createdAt - b[1].createdAt)
|
||||
.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')
|
||||
.filter(([_, tx]) => tx.state === 'committed' || tx.state === 'rolledback');
|
||||
|
||||
for (const [id] of toRemove) {
|
||||
this._transactions.delete(id)
|
||||
this._transactions.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,5 +225,5 @@ export class MemoryStorage implements ITransactionStorage {
|
||||
* @en Create memory storage
|
||||
*/
|
||||
export function createMemoryStorage(config: MemoryStorageConfig = {}): MemoryStorage {
|
||||
return new MemoryStorage(config)
|
||||
return new MemoryStorage(config);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import type {
|
||||
ITransactionStorage,
|
||||
TransactionLog,
|
||||
TransactionState,
|
||||
OperationLog,
|
||||
} from '../core/types.js'
|
||||
OperationLog
|
||||
} from '../core/types.js';
|
||||
|
||||
/**
|
||||
* @zh MongoDB Collection 接口
|
||||
@@ -36,16 +36,50 @@ export interface MongoDb {
|
||||
collection<T = unknown>(name: string): MongoCollection<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh MongoDB 客户端接口
|
||||
* @en MongoDB client interface
|
||||
*/
|
||||
export interface MongoClient {
|
||||
db(name?: string): MongoDb
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh MongoDB 连接工厂
|
||||
* @en MongoDB connection factory
|
||||
*/
|
||||
export type MongoClientFactory = () => MongoClient | Promise<MongoClient>
|
||||
|
||||
/**
|
||||
* @zh MongoDB 存储配置
|
||||
* @en MongoDB storage configuration
|
||||
*/
|
||||
export interface MongoStorageConfig {
|
||||
/**
|
||||
* @zh MongoDB 数据库实例
|
||||
* @en MongoDB database instance
|
||||
* @zh MongoDB 客户端工厂(惰性连接)
|
||||
* @en MongoDB client factory (lazy connection)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { MongoClient } from 'mongodb'
|
||||
* const storage = new MongoStorage({
|
||||
* factory: async () => {
|
||||
* const client = new MongoClient('mongodb://localhost:27017')
|
||||
* await client.connect()
|
||||
* return client
|
||||
* },
|
||||
* database: 'game'
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
db: MongoDb
|
||||
factory: MongoClientFactory
|
||||
|
||||
/**
|
||||
* @zh 数据库名称
|
||||
* @en Database name
|
||||
*/
|
||||
database: string
|
||||
|
||||
/**
|
||||
* @zh 事务日志集合名称
|
||||
@@ -82,32 +116,94 @@ interface DataDocument {
|
||||
* @zh MongoDB 存储
|
||||
* @en MongoDB storage
|
||||
*
|
||||
* @zh 基于 MongoDB 的事务存储,支持持久化和复杂查询
|
||||
* @en MongoDB-based transaction storage with persistence and complex query support
|
||||
* @zh 基于 MongoDB 的事务存储,支持持久化、复杂查询和惰性连接
|
||||
* @en MongoDB-based transaction storage with persistence, complex queries and lazy connection
|
||||
*
|
||||
* @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({
|
||||
* factory: async () => {
|
||||
* const client = new MongoClient('mongodb://localhost:27017')
|
||||
* await client.connect()
|
||||
* return client
|
||||
* },
|
||||
* database: 'game'
|
||||
* })
|
||||
*
|
||||
* const storage = new MongoStorage({ db })
|
||||
* await storage.ensureIndexes()
|
||||
*
|
||||
* // 使用后手动关闭
|
||||
* await storage.close()
|
||||
*
|
||||
* // 或使用 await using 自动关闭 (TypeScript 5.2+)
|
||||
* await using storage = new MongoStorage({ ... })
|
||||
* // 作用域结束时自动关闭
|
||||
* ```
|
||||
*/
|
||||
export class MongoStorage implements ITransactionStorage {
|
||||
private _db: MongoDb
|
||||
private _transactionCollection: string
|
||||
private _dataCollection: string
|
||||
private _lockCollection: string
|
||||
private _client: MongoClient | null = null;
|
||||
private _db: MongoDb | null = null;
|
||||
private _factory: MongoClientFactory;
|
||||
private _database: string;
|
||||
private _transactionCollection: string;
|
||||
private _dataCollection: string;
|
||||
private _lockCollection: string;
|
||||
private _closed: boolean = false;
|
||||
|
||||
constructor(config: MongoStorageConfig) {
|
||||
this._db = config.db
|
||||
this._transactionCollection = config.transactionCollection ?? 'transactions'
|
||||
this._dataCollection = config.dataCollection ?? 'transaction_data'
|
||||
this._lockCollection = config.lockCollection ?? 'transaction_locks'
|
||||
this._factory = config.factory;
|
||||
this._database = config.database;
|
||||
this._transactionCollection = config.transactionCollection ?? 'transactions';
|
||||
this._dataCollection = config.dataCollection ?? 'transaction_data';
|
||||
this._lockCollection = config.lockCollection ?? 'transaction_locks';
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 生命周期 | Lifecycle
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 获取数据库实例(惰性连接)
|
||||
* @en Get database instance (lazy connection)
|
||||
*/
|
||||
private async _getDb(): Promise<MongoDb> {
|
||||
if (this._closed) {
|
||||
throw new Error('MongoStorage is closed');
|
||||
}
|
||||
|
||||
if (!this._db) {
|
||||
this._client = await this._factory();
|
||||
this._db = this._client.db(this._database);
|
||||
}
|
||||
|
||||
return this._db;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 关闭存储连接
|
||||
* @en Close storage connection
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (this._closed) return;
|
||||
|
||||
this._closed = true;
|
||||
|
||||
if (this._client) {
|
||||
await this._client.close();
|
||||
this._client = null;
|
||||
this._db = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 支持 await using 语法
|
||||
* @en Support await using syntax
|
||||
*/
|
||||
async [Symbol.asyncDispose](): Promise<void> {
|
||||
await this.close();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,16 +211,17 @@ export class MongoStorage implements ITransactionStorage {
|
||||
* @en Ensure indexes exist
|
||||
*/
|
||||
async ensureIndexes(): Promise<void> {
|
||||
const txColl = this._db.collection<TransactionLog>(this._transactionCollection)
|
||||
await txColl.createIndex({ state: 1 })
|
||||
await txColl.createIndex({ 'metadata.serverId': 1 })
|
||||
await txColl.createIndex({ createdAt: 1 })
|
||||
const db = await this._getDb();
|
||||
const txColl = db.collection<TransactionLog>(this._transactionCollection);
|
||||
await txColl.createIndex({ state: 1 });
|
||||
await txColl.createIndex({ 'metadata.serverId': 1 });
|
||||
await txColl.createIndex({ createdAt: 1 });
|
||||
|
||||
const lockColl = this._db.collection<LockDocument>(this._lockCollection)
|
||||
await lockColl.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 })
|
||||
const lockColl = db.collection<LockDocument>(this._lockCollection);
|
||||
await lockColl.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 });
|
||||
|
||||
const dataColl = this._db.collection<DataDocument>(this._dataCollection)
|
||||
await dataColl.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 })
|
||||
const dataColl = db.collection<DataDocument>(this._dataCollection);
|
||||
await dataColl.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 });
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -132,36 +229,38 @@ export class MongoStorage implements ITransactionStorage {
|
||||
// =========================================================================
|
||||
|
||||
async acquireLock(key: string, ttl: number): Promise<string | null> {
|
||||
const coll = this._db.collection<LockDocument>(this._lockCollection)
|
||||
const token = `${Date.now()}_${Math.random().toString(36).substring(2)}`
|
||||
const expireAt = new Date(Date.now() + ttl)
|
||||
const db = await this._getDb();
|
||||
const coll = db.collection<LockDocument>(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
|
||||
expireAt
|
||||
});
|
||||
return token;
|
||||
} catch (error) {
|
||||
const existing = await coll.findOne({ _id: key })
|
||||
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 token;
|
||||
}
|
||||
}
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async releaseLock(key: string, token: string): Promise<boolean> {
|
||||
const coll = this._db.collection<LockDocument>(this._lockCollection)
|
||||
const result = await coll.deleteOne({ _id: key, token })
|
||||
return result.deletedCount > 0
|
||||
const db = await this._getDb();
|
||||
const coll = db.collection<LockDocument>(this._lockCollection);
|
||||
const result = await coll.deleteOne({ _id: key, token });
|
||||
return result.deletedCount > 0;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -169,35 +268,38 @@ export class MongoStorage implements ITransactionStorage {
|
||||
// =========================================================================
|
||||
|
||||
async saveTransaction(tx: TransactionLog): Promise<void> {
|
||||
const coll = this._db.collection<TransactionLog & { _id: string }>(this._transactionCollection)
|
||||
const db = await this._getDb();
|
||||
const coll = db.collection<TransactionLog & { _id: string }>(this._transactionCollection);
|
||||
|
||||
const existing = await coll.findOne({ _id: tx.id })
|
||||
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 })
|
||||
await coll.insertOne({ ...tx, _id: tx.id });
|
||||
}
|
||||
}
|
||||
|
||||
async getTransaction(id: string): Promise<TransactionLog | null> {
|
||||
const coll = this._db.collection<TransactionLog & { _id: string }>(this._transactionCollection)
|
||||
const doc = await coll.findOne({ _id: id })
|
||||
const db = await this._getDb();
|
||||
const coll = db.collection<TransactionLog & { _id: string }>(this._transactionCollection);
|
||||
const doc = await coll.findOne({ _id: id });
|
||||
|
||||
if (!doc) return null
|
||||
if (!doc) return null;
|
||||
|
||||
const { _id, ...tx } = doc
|
||||
return tx as TransactionLog
|
||||
const { _id, ...tx } = doc;
|
||||
return tx as TransactionLog;
|
||||
}
|
||||
|
||||
async updateTransactionState(id: string, state: TransactionState): Promise<void> {
|
||||
const coll = this._db.collection(this._transactionCollection)
|
||||
const db = await this._getDb();
|
||||
const coll = db.collection(this._transactionCollection);
|
||||
await coll.updateOne(
|
||||
{ _id: id },
|
||||
{ $set: { state, updatedAt: Date.now() } }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async updateOperationState(
|
||||
@@ -206,47 +308,50 @@ export class MongoStorage implements ITransactionStorage {
|
||||
state: OperationLog['state'],
|
||||
error?: string
|
||||
): Promise<void> {
|
||||
const coll = this._db.collection(this._transactionCollection)
|
||||
const db = await this._getDb();
|
||||
const coll = db.collection(this._transactionCollection);
|
||||
|
||||
const update: Record<string, unknown> = {
|
||||
[`operations.${operationIndex}.state`]: state,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
|
||||
if (error) {
|
||||
update[`operations.${operationIndex}.error`] = error
|
||||
update[`operations.${operationIndex}.error`] = error;
|
||||
}
|
||||
|
||||
if (state === 'executed') {
|
||||
update[`operations.${operationIndex}.executedAt`] = Date.now()
|
||||
update[`operations.${operationIndex}.executedAt`] = Date.now();
|
||||
} else if (state === 'compensated') {
|
||||
update[`operations.${operationIndex}.compensatedAt`] = Date.now()
|
||||
update[`operations.${operationIndex}.compensatedAt`] = Date.now();
|
||||
}
|
||||
|
||||
await coll.updateOne(
|
||||
{ _id: transactionId },
|
||||
{ $set: update }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async getPendingTransactions(serverId?: string): Promise<TransactionLog[]> {
|
||||
const coll = this._db.collection<TransactionLog & { _id: string }>(this._transactionCollection)
|
||||
const db = await this._getDb();
|
||||
const coll = db.collection<TransactionLog & { _id: string }>(this._transactionCollection);
|
||||
|
||||
const filter: Record<string, unknown> = {
|
||||
state: { $in: ['pending', 'executing'] },
|
||||
}
|
||||
state: { $in: ['pending', 'executing'] }
|
||||
};
|
||||
|
||||
if (serverId) {
|
||||
filter['metadata.serverId'] = serverId
|
||||
filter['metadata.serverId'] = serverId;
|
||||
}
|
||||
|
||||
const docs = await coll.find(filter).toArray()
|
||||
return docs.map(({ _id, ...tx }) => tx as TransactionLog)
|
||||
const docs = await coll.find(filter).toArray();
|
||||
return docs.map(({ _id, ...tx }) => tx as TransactionLog);
|
||||
}
|
||||
|
||||
async deleteTransaction(id: string): Promise<void> {
|
||||
const coll = this._db.collection(this._transactionCollection)
|
||||
await coll.deleteOne({ _id: id })
|
||||
const db = await this._getDb();
|
||||
const coll = db.collection(this._transactionCollection);
|
||||
await coll.deleteOne({ _id: id });
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -254,43 +359,46 @@ export class MongoStorage implements ITransactionStorage {
|
||||
// =========================================================================
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const coll = this._db.collection<DataDocument>(this._dataCollection)
|
||||
const doc = await coll.findOne({ _id: key })
|
||||
const db = await this._getDb();
|
||||
const coll = db.collection<DataDocument>(this._dataCollection);
|
||||
const doc = await coll.findOne({ _id: key });
|
||||
|
||||
if (!doc) return null
|
||||
if (!doc) return null;
|
||||
|
||||
if (doc.expireAt && doc.expireAt < new Date()) {
|
||||
await coll.deleteOne({ _id: key })
|
||||
return null
|
||||
await coll.deleteOne({ _id: key });
|
||||
return null;
|
||||
}
|
||||
|
||||
return doc.value as T
|
||||
return doc.value as T;
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
||||
const coll = this._db.collection<DataDocument>(this._dataCollection)
|
||||
const db = await this._getDb();
|
||||
const coll = db.collection<DataDocument>(this._dataCollection);
|
||||
|
||||
const doc: DataDocument = {
|
||||
_id: key,
|
||||
value,
|
||||
}
|
||||
value
|
||||
};
|
||||
|
||||
if (ttl) {
|
||||
doc.expireAt = new Date(Date.now() + ttl)
|
||||
doc.expireAt = new Date(Date.now() + ttl);
|
||||
}
|
||||
|
||||
const existing = await coll.findOne({ _id: key })
|
||||
const existing = await coll.findOne({ _id: key });
|
||||
if (existing) {
|
||||
await coll.updateOne({ _id: key }, { $set: doc })
|
||||
await coll.updateOne({ _id: key }, { $set: doc });
|
||||
} else {
|
||||
await coll.insertOne(doc)
|
||||
await coll.insertOne(doc);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<boolean> {
|
||||
const coll = this._db.collection(this._dataCollection)
|
||||
const result = await coll.deleteOne({ _id: key })
|
||||
return result.deletedCount > 0
|
||||
const db = await this._getDb();
|
||||
const coll = db.collection(this._dataCollection);
|
||||
const result = await coll.deleteOne({ _id: key });
|
||||
return result.deletedCount > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,5 +407,5 @@ export class MongoStorage implements ITransactionStorage {
|
||||
* @en Create MongoDB storage
|
||||
*/
|
||||
export function createMongoStorage(config: MongoStorageConfig): MongoStorage {
|
||||
return new MongoStorage(config)
|
||||
return new MongoStorage(config);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import type {
|
||||
ITransactionStorage,
|
||||
TransactionLog,
|
||||
TransactionState,
|
||||
OperationLog,
|
||||
} from '../core/types.js'
|
||||
OperationLog
|
||||
} from '../core/types.js';
|
||||
|
||||
/**
|
||||
* @zh Redis 客户端接口(兼容 ioredis)
|
||||
@@ -28,18 +28,33 @@ export interface RedisClient {
|
||||
hgetall(key: string): Promise<Record<string, string>>
|
||||
keys(pattern: string): Promise<string[]>
|
||||
expire(key: string, seconds: number): Promise<number>
|
||||
quit(): Promise<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh Redis 连接工厂
|
||||
* @en Redis connection factory
|
||||
*/
|
||||
export type RedisClientFactory = () => RedisClient | Promise<RedisClient>
|
||||
|
||||
/**
|
||||
* @zh Redis 存储配置
|
||||
* @en Redis storage configuration
|
||||
*/
|
||||
export interface RedisStorageConfig {
|
||||
/**
|
||||
* @zh Redis 客户端实例
|
||||
* @en Redis client instance
|
||||
* @zh Redis 客户端工厂(惰性连接)
|
||||
* @en Redis client factory (lazy connection)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import Redis from 'ioredis'
|
||||
* const storage = new RedisStorage({
|
||||
* factory: () => new Redis('redis://localhost:6379')
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
client: RedisClient
|
||||
factory: RedisClientFactory
|
||||
|
||||
/**
|
||||
* @zh 键前缀
|
||||
@@ -60,32 +75,88 @@ if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||
else
|
||||
return 0
|
||||
end
|
||||
`
|
||||
`;
|
||||
|
||||
/**
|
||||
* @zh Redis 存储
|
||||
* @en Redis storage
|
||||
*
|
||||
* @zh 基于 Redis 的分布式事务存储,支持分布式锁
|
||||
* @en Redis-based distributed transaction storage with distributed locking support
|
||||
* @zh 基于 Redis 的分布式事务存储,支持分布式锁和惰性连接
|
||||
* @en Redis-based distributed transaction storage with distributed locking and lazy connection
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import Redis from 'ioredis'
|
||||
*
|
||||
* const redis = new Redis('redis://localhost:6379')
|
||||
* const storage = new RedisStorage({ client: redis })
|
||||
* // 创建存储(惰性连接,首次操作时才连接)
|
||||
* const storage = new RedisStorage({
|
||||
* factory: () => new Redis('redis://localhost:6379')
|
||||
* })
|
||||
*
|
||||
* // 使用后手动关闭
|
||||
* await storage.close()
|
||||
*
|
||||
* // 或使用 await using 自动关闭 (TypeScript 5.2+)
|
||||
* await using storage = new RedisStorage({
|
||||
* factory: () => new Redis('redis://localhost:6379')
|
||||
* })
|
||||
* // 作用域结束时自动关闭
|
||||
* ```
|
||||
*/
|
||||
export class RedisStorage implements ITransactionStorage {
|
||||
private _client: RedisClient
|
||||
private _prefix: string
|
||||
private _transactionTTL: number
|
||||
private _client: RedisClient | null = null;
|
||||
private _factory: RedisClientFactory;
|
||||
private _prefix: string;
|
||||
private _transactionTTL: number;
|
||||
private _closed: boolean = false;
|
||||
|
||||
constructor(config: RedisStorageConfig) {
|
||||
this._client = config.client
|
||||
this._prefix = config.prefix ?? 'tx:'
|
||||
this._transactionTTL = config.transactionTTL ?? 86400 // 24 hours
|
||||
this._factory = config.factory;
|
||||
this._prefix = config.prefix ?? 'tx:';
|
||||
this._transactionTTL = config.transactionTTL ?? 86400; // 24 hours
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 生命周期 | Lifecycle
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @zh 获取 Redis 客户端(惰性连接)
|
||||
* @en Get Redis client (lazy connection)
|
||||
*/
|
||||
private async _getClient(): Promise<RedisClient> {
|
||||
if (this._closed) {
|
||||
throw new Error('RedisStorage is closed');
|
||||
}
|
||||
|
||||
if (!this._client) {
|
||||
this._client = await this._factory();
|
||||
}
|
||||
|
||||
return this._client;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 关闭存储连接
|
||||
* @en Close storage connection
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (this._closed) return;
|
||||
|
||||
this._closed = true;
|
||||
|
||||
if (this._client) {
|
||||
await this._client.quit();
|
||||
this._client = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 支持 await using 语法
|
||||
* @en Support await using syntax
|
||||
*/
|
||||
async [Symbol.asyncDispose](): Promise<void> {
|
||||
await this.close();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -93,20 +164,22 @@ export class RedisStorage implements ITransactionStorage {
|
||||
// =========================================================================
|
||||
|
||||
async acquireLock(key: string, ttl: number): Promise<string | null> {
|
||||
const lockKey = `${this._prefix}lock:${key}`
|
||||
const token = `${Date.now()}_${Math.random().toString(36).substring(2)}`
|
||||
const ttlSeconds = Math.ceil(ttl / 1000)
|
||||
const client = await this._getClient();
|
||||
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))
|
||||
const result = await client.set(lockKey, token, 'NX', 'EX', String(ttlSeconds));
|
||||
|
||||
return result === 'OK' ? token : null
|
||||
return result === 'OK' ? token : null;
|
||||
}
|
||||
|
||||
async releaseLock(key: string, token: string): Promise<boolean> {
|
||||
const lockKey = `${this._prefix}lock:${key}`
|
||||
const client = await this._getClient();
|
||||
const lockKey = `${this._prefix}lock:${key}`;
|
||||
|
||||
const result = await this._client.eval(LOCK_SCRIPT, 1, lockKey, token)
|
||||
return result === 1
|
||||
const result = await client.eval(LOCK_SCRIPT, 1, lockKey, token);
|
||||
return result === 1;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -114,30 +187,32 @@ export class RedisStorage implements ITransactionStorage {
|
||||
// =========================================================================
|
||||
|
||||
async saveTransaction(tx: TransactionLog): Promise<void> {
|
||||
const key = `${this._prefix}tx:${tx.id}`
|
||||
const client = await this._getClient();
|
||||
const key = `${this._prefix}tx:${tx.id}`;
|
||||
|
||||
await this._client.set(key, JSON.stringify(tx))
|
||||
await this._client.expire(key, this._transactionTTL)
|
||||
await client.set(key, JSON.stringify(tx));
|
||||
await 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))
|
||||
const serverKey = `${this._prefix}server:${tx.metadata.serverId}:txs`;
|
||||
await client.hset(serverKey, tx.id, String(tx.createdAt));
|
||||
}
|
||||
}
|
||||
|
||||
async getTransaction(id: string): Promise<TransactionLog | null> {
|
||||
const key = `${this._prefix}tx:${id}`
|
||||
const data = await this._client.get(key)
|
||||
const client = await this._getClient();
|
||||
const key = `${this._prefix}tx:${id}`;
|
||||
const data = await client.get(key);
|
||||
|
||||
return data ? JSON.parse(data) : null
|
||||
return data ? JSON.parse(data) : null;
|
||||
}
|
||||
|
||||
async updateTransactionState(id: string, state: TransactionState): Promise<void> {
|
||||
const tx = await this.getTransaction(id)
|
||||
const tx = await this.getTransaction(id);
|
||||
if (tx) {
|
||||
tx.state = state
|
||||
tx.updatedAt = Date.now()
|
||||
await this.saveTransaction(tx)
|
||||
tx.state = state;
|
||||
tx.updatedAt = Date.now();
|
||||
await this.saveTransaction(tx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,62 +222,64 @@ export class RedisStorage implements ITransactionStorage {
|
||||
state: OperationLog['state'],
|
||||
error?: string
|
||||
): Promise<void> {
|
||||
const tx = await this.getTransaction(transactionId)
|
||||
const tx = await this.getTransaction(transactionId);
|
||||
if (tx && tx.operations[operationIndex]) {
|
||||
tx.operations[operationIndex].state = state
|
||||
tx.operations[operationIndex].state = state;
|
||||
if (error) {
|
||||
tx.operations[operationIndex].error = error
|
||||
tx.operations[operationIndex].error = error;
|
||||
}
|
||||
if (state === 'executed') {
|
||||
tx.operations[operationIndex].executedAt = Date.now()
|
||||
tx.operations[operationIndex].executedAt = Date.now();
|
||||
} else if (state === 'compensated') {
|
||||
tx.operations[operationIndex].compensatedAt = Date.now()
|
||||
tx.operations[operationIndex].compensatedAt = Date.now();
|
||||
}
|
||||
tx.updatedAt = Date.now()
|
||||
await this.saveTransaction(tx)
|
||||
tx.updatedAt = Date.now();
|
||||
await this.saveTransaction(tx);
|
||||
}
|
||||
}
|
||||
|
||||
async getPendingTransactions(serverId?: string): Promise<TransactionLog[]> {
|
||||
const result: TransactionLog[] = []
|
||||
const client = await this._getClient();
|
||||
const result: TransactionLog[] = [];
|
||||
|
||||
if (serverId) {
|
||||
const serverKey = `${this._prefix}server:${serverId}:txs`
|
||||
const txIds = await this._client.hgetall(serverKey)
|
||||
const serverKey = `${this._prefix}server:${serverId}:txs`;
|
||||
const txIds = await client.hgetall(serverKey);
|
||||
|
||||
for (const id of Object.keys(txIds)) {
|
||||
const tx = await this.getTransaction(id)
|
||||
const tx = await this.getTransaction(id);
|
||||
if (tx && (tx.state === 'pending' || tx.state === 'executing')) {
|
||||
result.push(tx)
|
||||
result.push(tx);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const pattern = `${this._prefix}tx:*`
|
||||
const keys = await this._client.keys(pattern)
|
||||
const pattern = `${this._prefix}tx:*`;
|
||||
const keys = await client.keys(pattern);
|
||||
|
||||
for (const key of keys) {
|
||||
const data = await this._client.get(key)
|
||||
const data = await client.get(key);
|
||||
if (data) {
|
||||
const tx: TransactionLog = JSON.parse(data)
|
||||
const tx: TransactionLog = JSON.parse(data);
|
||||
if (tx.state === 'pending' || tx.state === 'executing') {
|
||||
result.push(tx)
|
||||
result.push(tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteTransaction(id: string): Promise<void> {
|
||||
const key = `${this._prefix}tx:${id}`
|
||||
const tx = await this.getTransaction(id)
|
||||
const client = await this._getClient();
|
||||
const key = `${this._prefix}tx:${id}`;
|
||||
const tx = await this.getTransaction(id);
|
||||
|
||||
await this._client.del(key)
|
||||
await client.del(key);
|
||||
|
||||
if (tx?.metadata?.serverId) {
|
||||
const serverKey = `${this._prefix}server:${tx.metadata.serverId}:txs`
|
||||
await this._client.hdel(serverKey, id)
|
||||
const serverKey = `${this._prefix}server:${tx.metadata.serverId}:txs`;
|
||||
await client.hdel(serverKey, id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,27 +288,30 @@ export class RedisStorage implements ITransactionStorage {
|
||||
// =========================================================================
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const fullKey = `${this._prefix}data:${key}`
|
||||
const data = await this._client.get(fullKey)
|
||||
const client = await this._getClient();
|
||||
const fullKey = `${this._prefix}data:${key}`;
|
||||
const data = await client.get(fullKey);
|
||||
|
||||
return data ? JSON.parse(data) : null
|
||||
return data ? JSON.parse(data) : null;
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
||||
const fullKey = `${this._prefix}data:${key}`
|
||||
const client = await this._getClient();
|
||||
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))
|
||||
const ttlSeconds = Math.ceil(ttl / 1000);
|
||||
await client.set(fullKey, JSON.stringify(value), 'EX', String(ttlSeconds));
|
||||
} else {
|
||||
await this._client.set(fullKey, JSON.stringify(value))
|
||||
await client.set(fullKey, JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<boolean> {
|
||||
const fullKey = `${this._prefix}data:${key}`
|
||||
const result = await this._client.del(fullKey)
|
||||
return result > 0
|
||||
const client = await this._getClient();
|
||||
const fullKey = `${this._prefix}data:${key}`;
|
||||
const result = await client.del(fullKey);
|
||||
return result > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,5 +320,5 @@ export class RedisStorage implements ITransactionStorage {
|
||||
* @en Create Redis storage
|
||||
*/
|
||||
export function createRedisStorage(config: RedisStorageConfig): RedisStorage {
|
||||
return new RedisStorage(config)
|
||||
return new RedisStorage(config);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
* @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'
|
||||
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';
|
||||
|
||||
@@ -3,18 +3,18 @@
|
||||
* @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'
|
||||
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>('transactionManager')
|
||||
export const TransactionManagerToken = createServiceToken<TransactionManager>('transactionManager');
|
||||
|
||||
/**
|
||||
* @zh 事务存储令牌
|
||||
* @en Transaction storage token
|
||||
*/
|
||||
export const TransactionStorageToken = createServiceToken<ITransactionStorage>('transactionStorage')
|
||||
export const TransactionStorageToken = createServiceToken<ITransactionStorage>('transactionStorage');
|
||||
|
||||
Reference in New Issue
Block a user