mirror of
https://github.com/Unleash/unleash.git
synced 2025-09-24 17:51:14 +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 { Knex } from 'knex';
|
||||||
import type { IUnleashConfig } from '../types/index.ts';
|
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 KnexTransaction = Knex.Transaction;
|
||||||
|
|
||||||
export type MockTransaction = null;
|
export type MockTransaction = null;
|
||||||
@ -38,7 +49,10 @@ export type ServiceFactory<S> = (
|
|||||||
) => DeferredServiceFactory<S>;
|
) => DeferredServiceFactory<S>;
|
||||||
|
|
||||||
export type WithTransactional<S> = 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 & {
|
export type WithRollbackTransaction<S> = S & {
|
||||||
@ -75,10 +89,17 @@ export function withTransactional<S>(
|
|||||||
): WithTransactional<S> {
|
): WithTransactional<S> {
|
||||||
const service = serviceFactory(db) as WithTransactional<S>;
|
const service = serviceFactory(db) as WithTransactional<S>;
|
||||||
|
|
||||||
service.transactional = async <R>(fn: (service: S) => R) =>
|
service.transactional = async <R>(
|
||||||
// Maybe: inTransaction(db, async (trx: Knex.Transaction) => fn(serviceFactory(trx)));
|
fn: (service: S) => R,
|
||||||
// this assumes that the caller didn't start a transaction already and opens a new one.
|
transactionContext?: TransactionUserParams,
|
||||||
|
) =>
|
||||||
db.transaction(async (trx: Knex.Transaction) => {
|
db.transaction(async (trx: Knex.Transaction) => {
|
||||||
|
const defaultContext: TransactionUserParams = {
|
||||||
|
type: 'transaction',
|
||||||
|
value: generateNumericTransactionId(),
|
||||||
|
};
|
||||||
|
|
||||||
|
trx.userParams = transactionContext || defaultContext;
|
||||||
const transactionalService = serviceFactory(trx);
|
const transactionalService = serviceFactory(trx);
|
||||||
return fn(transactionalService);
|
return fn(transactionalService);
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user