mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-26 13:48:33 +02:00
feat: add transaction context store (#10211)
Create transaction context, that generates a random ID for each transaction, but also allows you to define your own id, for example change-request.
This commit is contained in:
parent
16c0f3167a
commit
8c1c9076b3
268
src/lib/db/transaction.test.ts
Normal file
268
src/lib/db/transaction.test.ts
Normal file
@ -0,0 +1,268 @@
|
||||
import {
|
||||
withTransactional,
|
||||
withRollbackTransaction,
|
||||
withFakeTransactional,
|
||||
inTransaction,
|
||||
type TransactionUserParams,
|
||||
} from './transaction.js';
|
||||
import { type Mock, vi } from 'vitest';
|
||||
|
||||
interface MockService {
|
||||
getData: () => string;
|
||||
saveData: (data: string) => Promise<void>;
|
||||
}
|
||||
|
||||
describe('transaction utilities', () => {
|
||||
let mockDb: any;
|
||||
let mockTransaction: any;
|
||||
let mockServiceFactory: Mock;
|
||||
let mockService: MockService;
|
||||
|
||||
beforeEach(() => {
|
||||
mockTransaction = {
|
||||
commit: vi.fn(),
|
||||
rollback: vi.fn(),
|
||||
isTransaction: true,
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
userParams: undefined,
|
||||
};
|
||||
|
||||
mockDb = {
|
||||
transaction: vi
|
||||
.fn()
|
||||
.mockImplementation((callback) => callback(mockTransaction)),
|
||||
isTransaction: false,
|
||||
};
|
||||
|
||||
mockService = {
|
||||
getData: vi.fn().mockReturnValue('test-data'),
|
||||
saveData: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
mockServiceFactory = vi.fn().mockReturnValue(mockService);
|
||||
});
|
||||
|
||||
describe('withTransactional', () => {
|
||||
it('should add transactional method to service', () => {
|
||||
const serviceWithTransactional = withTransactional(
|
||||
mockServiceFactory,
|
||||
mockDb,
|
||||
);
|
||||
|
||||
expect(typeof serviceWithTransactional.transactional).toBe(
|
||||
'function',
|
||||
);
|
||||
expect(serviceWithTransactional.getData).toBe(mockService.getData);
|
||||
expect(serviceWithTransactional.saveData).toBe(
|
||||
mockService.saveData,
|
||||
);
|
||||
});
|
||||
|
||||
it('should execute callback within database transaction', async () => {
|
||||
const serviceWithTransactional = withTransactional(
|
||||
mockServiceFactory,
|
||||
mockDb,
|
||||
);
|
||||
|
||||
const result = await serviceWithTransactional.transactional(
|
||||
(service) => {
|
||||
return service.getData();
|
||||
},
|
||||
);
|
||||
|
||||
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBe('test-data');
|
||||
});
|
||||
|
||||
it('should set default userParams when no transactionContext provided', async () => {
|
||||
const serviceWithTransactional = withTransactional(
|
||||
mockServiceFactory,
|
||||
mockDb,
|
||||
);
|
||||
|
||||
await serviceWithTransactional.transactional((service) => {
|
||||
return service.getData();
|
||||
});
|
||||
|
||||
expect(mockTransaction.userParams).toBeDefined();
|
||||
expect(mockTransaction.userParams.type).toBe('transaction');
|
||||
expect(mockTransaction.userParams.value).toBeDefined();
|
||||
expect(typeof mockTransaction.userParams.value).toBe('number');
|
||||
});
|
||||
|
||||
it('should use provided transactionContext when given', async () => {
|
||||
const serviceWithTransactional = withTransactional(
|
||||
mockServiceFactory,
|
||||
mockDb,
|
||||
);
|
||||
const customContext: TransactionUserParams = {
|
||||
type: 'change-request',
|
||||
value: 42,
|
||||
};
|
||||
|
||||
await serviceWithTransactional.transactional((service) => {
|
||||
return service.getData();
|
||||
}, customContext);
|
||||
|
||||
expect(mockTransaction.userParams).toEqual(customContext);
|
||||
expect(mockTransaction.userParams.type).toBe('change-request');
|
||||
expect(mockTransaction.userParams.value).toBe(42);
|
||||
});
|
||||
|
||||
it('should generate unique numeric IDs for default context', async () => {
|
||||
const serviceWithTransactional = withTransactional(
|
||||
mockServiceFactory,
|
||||
mockDb,
|
||||
);
|
||||
const userParamsValues: number[] = [];
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await serviceWithTransactional.transactional((service) => {
|
||||
userParamsValues.push(mockTransaction.userParams.value);
|
||||
return service.getData();
|
||||
});
|
||||
}
|
||||
|
||||
expect(userParamsValues).toHaveLength(3);
|
||||
expect(userParamsValues.every((id) => typeof id === 'number')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(new Set(userParamsValues).size).toBe(3);
|
||||
});
|
||||
|
||||
it('should create transactional service with transaction instance', async () => {
|
||||
const serviceWithTransactional = withTransactional(
|
||||
mockServiceFactory,
|
||||
mockDb,
|
||||
);
|
||||
|
||||
await serviceWithTransactional.transactional((service) => {
|
||||
return service.getData();
|
||||
});
|
||||
|
||||
expect(mockServiceFactory).toHaveBeenCalledWith(mockTransaction);
|
||||
});
|
||||
|
||||
it('should handle promise-based callbacks', async () => {
|
||||
const serviceWithTransactional = withTransactional(
|
||||
mockServiceFactory,
|
||||
mockDb,
|
||||
);
|
||||
|
||||
const result = await serviceWithTransactional.transactional(
|
||||
async (service) => {
|
||||
await service.saveData('new-data');
|
||||
return 'success';
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(mockService.saveData).toHaveBeenCalledWith('new-data');
|
||||
});
|
||||
|
||||
it('should propagate errors from callback', async () => {
|
||||
const serviceWithTransactional = withTransactional(
|
||||
mockServiceFactory,
|
||||
mockDb,
|
||||
);
|
||||
const error = new Error('Test error');
|
||||
|
||||
await expect(
|
||||
serviceWithTransactional.transactional(() => {
|
||||
throw error;
|
||||
}),
|
||||
).rejects.toThrow('Test error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withRollbackTransaction', () => {
|
||||
beforeEach(() => {
|
||||
mockDb.transaction = vi.fn().mockResolvedValue(mockTransaction);
|
||||
});
|
||||
|
||||
it('should add rollbackTransaction method to service', () => {
|
||||
const serviceWithRollback = withRollbackTransaction(
|
||||
mockServiceFactory,
|
||||
mockDb,
|
||||
);
|
||||
|
||||
expect(typeof serviceWithRollback.rollbackTransaction).toBe(
|
||||
'function',
|
||||
);
|
||||
expect(serviceWithRollback.getData).toBe(mockService.getData);
|
||||
expect(serviceWithRollback.saveData).toBe(mockService.saveData);
|
||||
});
|
||||
|
||||
it('should execute callback and rollback transaction', async () => {
|
||||
const serviceWithRollback = withRollbackTransaction(
|
||||
mockServiceFactory,
|
||||
mockDb,
|
||||
);
|
||||
|
||||
const result = await serviceWithRollback.rollbackTransaction(
|
||||
(service) => {
|
||||
return service.getData();
|
||||
},
|
||||
);
|
||||
|
||||
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransaction.rollback).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBe('test-data');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withFakeTransactional', () => {
|
||||
it('should add transactional method to service', () => {
|
||||
const serviceWithFakeTransactional =
|
||||
withFakeTransactional(mockService);
|
||||
|
||||
expect(typeof serviceWithFakeTransactional.transactional).toBe(
|
||||
'function',
|
||||
);
|
||||
expect(serviceWithFakeTransactional.getData).toBe(
|
||||
mockService.getData,
|
||||
);
|
||||
expect(serviceWithFakeTransactional.saveData).toBe(
|
||||
mockService.saveData,
|
||||
);
|
||||
});
|
||||
|
||||
it('should execute callback directly without transaction', async () => {
|
||||
const serviceWithFakeTransactional =
|
||||
withFakeTransactional(mockService);
|
||||
|
||||
const result = await serviceWithFakeTransactional.transactional(
|
||||
(service) => {
|
||||
return service.getData();
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe('test-data');
|
||||
});
|
||||
});
|
||||
|
||||
describe('inTransaction', () => {
|
||||
it('should execute callback directly when db is already a transaction', async () => {
|
||||
const transactionDb = { ...mockDb, isTransaction: true };
|
||||
const callback = vi.fn().mockReturnValue('result');
|
||||
|
||||
const result = await inTransaction(transactionDb, callback);
|
||||
|
||||
expect(result).toBe('result');
|
||||
expect(callback).toHaveBeenCalledWith(transactionDb);
|
||||
expect(transactionDb.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create new transaction when db is not a transaction', async () => {
|
||||
const callback = vi.fn().mockReturnValue('result');
|
||||
|
||||
const result = await inTransaction(mockDb, callback);
|
||||
|
||||
expect(result).toBe('result');
|
||||
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith(mockTransaction);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,6 +1,17 @@
|
||||
import type { Knex } from 'knex';
|
||||
import type { IUnleashConfig } from '../types/index.ts';
|
||||
|
||||
export interface TransactionUserParams {
|
||||
type: 'change-request' | 'transaction';
|
||||
value: number;
|
||||
}
|
||||
|
||||
function generateNumericTransactionId(): number {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.floor(Math.random() * 1000);
|
||||
return timestamp * 1000 + random;
|
||||
}
|
||||
|
||||
export type KnexTransaction = Knex.Transaction;
|
||||
|
||||
export type MockTransaction = null;
|
||||
@ -38,7 +49,10 @@ export type ServiceFactory<S> = (
|
||||
) => DeferredServiceFactory<S>;
|
||||
|
||||
export type WithTransactional<S> = S & {
|
||||
transactional: <R>(fn: (service: S) => R) => Promise<R>;
|
||||
transactional: <R>(
|
||||
fn: (service: S) => R,
|
||||
transactionContext?: TransactionUserParams,
|
||||
) => Promise<R>;
|
||||
};
|
||||
|
||||
export type WithRollbackTransaction<S> = S & {
|
||||
@ -75,10 +89,17 @@ export function withTransactional<S>(
|
||||
): WithTransactional<S> {
|
||||
const service = serviceFactory(db) as WithTransactional<S>;
|
||||
|
||||
service.transactional = async <R>(fn: (service: S) => R) =>
|
||||
// Maybe: inTransaction(db, async (trx: Knex.Transaction) => fn(serviceFactory(trx)));
|
||||
// this assumes that the caller didn't start a transaction already and opens a new one.
|
||||
service.transactional = async <R>(
|
||||
fn: (service: S) => R,
|
||||
transactionContext?: TransactionUserParams,
|
||||
) =>
|
||||
db.transaction(async (trx: Knex.Transaction) => {
|
||||
const defaultContext: TransactionUserParams = {
|
||||
type: 'transaction',
|
||||
value: generateNumericTransactionId(),
|
||||
};
|
||||
|
||||
trx.userParams = transactionContext || defaultContext;
|
||||
const transactionalService = serviceFactory(trx);
|
||||
return fn(transactionalService);
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user