1
0
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:
Jaanus Sellin 2025-06-27 11:04:52 +03:00 committed by GitHub
parent 16c0f3167a
commit 8c1c9076b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 293 additions and 4 deletions

View 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);
});
});
});

View File

@ -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);
});