From 2570fe5e976162e24ea3e1985dbe3b0ba54528e5 Mon Sep 17 00:00:00 2001 From: sjaanus Date: Wed, 25 Jun 2025 15:48:19 +0300 Subject: [PATCH] feat: add transaction context store --- src/lib/db/transaction.ts | 14 +- src/lib/util/transactionContext.test.ts | 191 ++++++++++++++++++++++++ src/lib/util/transactionContext.ts | 49 ++++++ 3 files changed, 248 insertions(+), 6 deletions(-) create mode 100644 src/lib/util/transactionContext.test.ts create mode 100644 src/lib/util/transactionContext.ts diff --git a/src/lib/db/transaction.ts b/src/lib/db/transaction.ts index ac59b18b49..ef383afadc 100644 --- a/src/lib/db/transaction.ts +++ b/src/lib/db/transaction.ts @@ -1,5 +1,6 @@ import type { Knex } from 'knex'; import type { IUnleashConfig } from '../types/index.ts'; +import { transactionContext } from '../util/transactionContext.js'; export type KnexTransaction = Knex.Transaction; @@ -75,13 +76,14 @@ export function withTransactional( ): WithTransactional { const service = serviceFactory(db) as WithTransactional; - service.transactional = async (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. - db.transaction(async (trx: Knex.Transaction) => { - const transactionalService = serviceFactory(trx); - return fn(transactionalService); + service.transactional = async (fn: (service: S) => R): Promise => { + return db.transaction(async (trx) => { + return transactionContext.run(async () => { + const transactionalService = serviceFactory(trx); + return fn(transactionalService); + }); }); + }; return service; } diff --git a/src/lib/util/transactionContext.test.ts b/src/lib/util/transactionContext.test.ts new file mode 100644 index 0000000000..c599489921 --- /dev/null +++ b/src/lib/util/transactionContext.test.ts @@ -0,0 +1,191 @@ +import { transactionContext } from './transactionContext.js'; + +describe('transactionContext', () => { + describe('run', () => { + it('should execute callback with transaction context', async () => { + const result = await transactionContext.run(async () => { + return 'callback-result'; + }); + + expect(result).toBe('callback-result'); + }); + + it('should make transaction context available inside the callback', async () => { + await transactionContext.run(async () => { + expect(transactionContext.getOperationType()).toBe( + 'transaction', + ); + expect(transactionContext.getOperationId()).toBeDefined(); + expect(typeof transactionContext.getOperationId()).toBe( + 'number', + ); + }); + }); + + it('should generate unique numeric transaction IDs', async () => { + const ids: (string | number | undefined)[] = []; + + await transactionContext.run(async () => { + ids.push(transactionContext.getOperationId()); + }); + + await transactionContext.run(async () => { + ids.push(transactionContext.getOperationId()); + }); + + expect(ids).toHaveLength(2); + expect(ids[0]).not.toBe(ids[1]); + expect(typeof ids[0]).toBe('number'); + expect(typeof ids[1]).toBe('number'); + }); + + it('should handle rejected promises', async () => { + const error = new Error('Test error'); + + await expect( + transactionContext.run(async () => { + throw error; + }), + ).rejects.toThrow('Test error'); + }); + + it('should handle nested transaction contexts', async () => { + let outerOperationId: string | number | undefined; + let innerOperationId: string | number | undefined; + + await transactionContext.run(async () => { + outerOperationId = transactionContext.getOperationId(); + expect(transactionContext.getOperationType()).toBe( + 'transaction', + ); + + await transactionContext.run(async () => { + innerOperationId = transactionContext.getOperationId(); + expect(transactionContext.getOperationType()).toBe( + 'transaction', + ); + }); + + expect(transactionContext.getOperationId()).toBe( + outerOperationId, + ); + }); + + expect(outerOperationId).toBeDefined(); + expect(innerOperationId).toBeDefined(); + expect(outerOperationId).not.toBe(innerOperationId); + }); + }); + + describe('getOperation', () => { + it('should return undefined when called outside of transaction context', () => { + expect(transactionContext.getOperation()).toBeUndefined(); + }); + + it('should return the operation context when called inside context', async () => { + await transactionContext.run(async () => { + const operation = transactionContext.getOperation(); + expect(operation).toBeDefined(); + expect(operation?.type).toBe('transaction'); + expect(operation?.id).toBeDefined(); + expect(typeof operation?.id).toBe('number'); + }); + }); + }); + + describe('getOperationType', () => { + it('should return undefined when called outside of transaction context', () => { + expect(transactionContext.getOperationType()).toBeUndefined(); + }); + + it('should return "transaction" when called inside context', async () => { + await transactionContext.run(async () => { + expect(transactionContext.getOperationType()).toBe( + 'transaction', + ); + }); + }); + }); + + describe('getOperationId', () => { + it('should return undefined when called outside of transaction context', () => { + expect(transactionContext.getOperationId()).toBeUndefined(); + }); + + it('should return numeric ID when called inside context', async () => { + await transactionContext.run(async () => { + const id = transactionContext.getOperationId(); + expect(id).toBeDefined(); + expect(typeof id).toBe('number'); + }); + }); + }); + + describe('setOperation', () => { + it('should throw error when called outside of transaction context', () => { + expect(() => { + transactionContext.setOperation({ + type: 'change-request', + id: 123, + }); + }).toThrow( + 'No active transaction context found when setting operation', + ); + }); + + it('should set operation context in active context', async () => { + await transactionContext.run(async () => { + expect(transactionContext.getOperationType()).toBe( + 'transaction', + ); + + transactionContext.setOperation({ + type: 'change-request', + id: 456, + }); + + expect(transactionContext.getOperationType()).toBe( + 'change-request', + ); + expect(transactionContext.getOperationId()).toBe(456); + }); + }); + + it('should update existing operation context', async () => { + await transactionContext.run(async () => { + expect(transactionContext.getOperationType()).toBe( + 'transaction', + ); + const originalId = transactionContext.getOperationId(); + + transactionContext.setOperation({ + type: 'change-request', + id: 789, + }); + + expect(transactionContext.getOperationType()).toBe( + 'change-request', + ); + expect(transactionContext.getOperationId()).toBe(789); + expect(transactionContext.getOperationId()).not.toBe( + originalId, + ); + }); + }); + + it('should not affect transaction context after callback completes', async () => { + await transactionContext.run(async () => { + transactionContext.setOperation({ + type: 'change-request', + id: 999, + }); + expect(transactionContext.getOperationType()).toBe( + 'change-request', + ); + expect(transactionContext.getOperationId()).toBe(999); + }); + + expect(transactionContext.getOperation()).toBeUndefined(); + }); + }); +}); diff --git a/src/lib/util/transactionContext.ts b/src/lib/util/transactionContext.ts new file mode 100644 index 0000000000..260104884f --- /dev/null +++ b/src/lib/util/transactionContext.ts @@ -0,0 +1,49 @@ +import { AsyncLocalStorage } from 'async_hooks'; + +export interface OperationContext { + type: 'change-request' | 'transaction'; + id: number; +} + +// Generate a numeric transaction ID based on timestamp + random component +function generateNumericTransactionId(): number { + const timestamp = Date.now(); // 13 digits + const random = Math.floor(Math.random() * 1000); // 3 digits max + return timestamp * 1000 + random; // Ensures uniqueness +} + +const storage = new AsyncLocalStorage(); + +export const transactionContext = { + run(callback: () => Promise): Promise { + const data: OperationContext = { + type: 'transaction', + id: generateNumericTransactionId(), + }; + return storage.run(data, callback) as Promise; + }, + + getOperation(): OperationContext | undefined { + return storage.getStore(); + }, + + getOperationType(): OperationContext['type'] | undefined { + return storage.getStore()?.type; + }, + + getOperationId(): string | number | undefined { + return storage.getStore()?.id; + }, + + setOperation(operation: OperationContext): void { + const store = storage.getStore(); + if (store) { + store.id = operation.id; + store.type = operation.type; + } else { + throw new Error( + 'No active transaction context found when setting operation', + ); + } + }, +};