From 5c340721d1342c8645b2755ac418e97433616570 Mon Sep 17 00:00:00 2001 From: sjaanus Date: Thu, 26 Jun 2025 09:52:42 +0300 Subject: [PATCH] Make it a wrapper --- src/lib/db/transaction.ts | 35 +++- src/lib/util/transactionContext.test.ts | 247 ++++++++---------------- src/lib/util/transactionContext.ts | 48 ++--- 3 files changed, 125 insertions(+), 205 deletions(-) diff --git a/src/lib/db/transaction.ts b/src/lib/db/transaction.ts index ef383afadc..559c0400d1 100644 --- a/src/lib/db/transaction.ts +++ b/src/lib/db/transaction.ts @@ -1,6 +1,6 @@ import type { Knex } from 'knex'; import type { IUnleashConfig } from '../types/index.ts'; -import { transactionContext } from '../util/transactionContext.js'; +import { TransactionContext } from '../util/transactionContext.js'; export type KnexTransaction = Knex.Transaction; @@ -42,6 +42,12 @@ export type WithTransactional = S & { transactional: (fn: (service: S) => R) => Promise; }; +export type WithTrackedTransactional = S & { + trackedTransactional: ( + fn: (transactionContext: TransactionContext) => R, + ) => Promise; +}; + export type WithRollbackTransaction = S & { rollbackTransaction: (fn: (service: S) => R) => Promise; }; @@ -76,12 +82,29 @@ export function withTransactional( ): WithTransactional { const service = serviceFactory(db) as WithTransactional; - service.transactional = async (fn: (service: S) => R): Promise => { + 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); + }); + + return service; +} + +export function withContextualTransactional( + serviceFactory: (db: Knex) => S, + db: Knex, +): WithTrackedTransactional { + const service = serviceFactory(db) as WithTrackedTransactional; + + service.trackedTransactional = async ( + fn: (transactionContext: TransactionContext) => R, + ): Promise => { return db.transaction(async (trx) => { - return transactionContext.run(async () => { - const transactionalService = serviceFactory(trx); - return fn(transactionalService); - }); + const transactionContext = new TransactionContext(trx); + return fn(transactionContext); }); }; diff --git a/src/lib/util/transactionContext.test.ts b/src/lib/util/transactionContext.test.ts index c599489921..433873de24 100644 --- a/src/lib/util/transactionContext.test.ts +++ b/src/lib/util/transactionContext.test.ts @@ -1,191 +1,108 @@ -import { transactionContext } from './transactionContext.js'; +import { + TransactionContext, + type OperationContext, +} from './transactionContext.js'; +import { vi } from 'vitest'; -describe('transactionContext', () => { - describe('run', () => { - it('should execute callback with transaction context', async () => { - const result = await transactionContext.run(async () => { - return 'callback-result'; - }); +describe('TransactionContext', () => { + let mockTransaction: any; - 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); - }); + beforeEach(() => { + mockTransaction = { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + where: vi.fn(), + commit: vi.fn(), + rollback: vi.fn(), + isTransaction: true, + }; }); - describe('getOperation', () => { - it('should return undefined when called outside of transaction context', () => { - expect(transactionContext.getOperation()).toBeUndefined(); + describe('constructor', () => { + it('should create transaction context with default operation', () => { + const txContext = new TransactionContext(mockTransaction); + + expect(txContext.operationContext.type).toBe('transaction'); + expect(txContext.operationContext.id).toBeDefined(); + expect(typeof txContext.operationContext.id).toBe('number'); + expect(txContext.transaction).toBe(mockTransaction); }); - 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'); + it('should create transaction context with custom operation', () => { + const customOperation: OperationContext = { + type: 'change-request', + id: 42, + }; + + const txContext = new TransactionContext( + mockTransaction, + customOperation, + ); + + expect(txContext.operationContext.type).toBe('change-request'); + expect(txContext.operationContext.id).toBe(42); + expect(txContext.transaction).toBe(mockTransaction); + }); + + it('should create transaction context with partial operation context', () => { + const txContext = new TransactionContext(mockTransaction, { + type: 'change-request', }); - }); - }); - describe('getOperationType', () => { - it('should return undefined when called outside of transaction context', () => { - expect(transactionContext.getOperationType()).toBeUndefined(); + expect(txContext.operationContext.type).toBe('change-request'); + expect(txContext.operationContext.id).toBeDefined(); + expect(typeof txContext.operationContext.id).toBe('number'); + expect(txContext.transaction).toBe(mockTransaction); }); - it('should return "transaction" when called inside context', async () => { - await transactionContext.run(async () => { - expect(transactionContext.getOperationType()).toBe( - 'transaction', - ); - }); - }); - }); + it('should generate unique IDs for different contexts', () => { + const txContext1 = new TransactionContext(mockTransaction); + const txContext2 = new TransactionContext(mockTransaction); - 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', + expect(txContext1.operationContext.id).not.toBe( + txContext2.operationContext.id, ); }); + }); - it('should set operation context in active context', async () => { - await transactionContext.run(async () => { - expect(transactionContext.getOperationType()).toBe( - 'transaction', - ); + describe('operationContext', () => { + it('should allow updating operation context', () => { + const txContext = new TransactionContext(mockTransaction); - transactionContext.setOperation({ - type: 'change-request', - id: 456, - }); + expect(txContext.operationContext.type).toBe('transaction'); - expect(transactionContext.getOperationType()).toBe( - 'change-request', - ); - expect(transactionContext.getOperationId()).toBe(456); - }); + txContext.operationContext = { + type: 'change-request', + id: 123, + }; + + expect(txContext.operationContext.type).toBe('change-request'); + expect(txContext.operationContext.id).toBe(123); }); - it('should update existing operation context', async () => { - await transactionContext.run(async () => { - expect(transactionContext.getOperationType()).toBe( - 'transaction', - ); - const originalId = transactionContext.getOperationId(); + it('should allow partial updates to operation context', () => { + const txContext = new TransactionContext(mockTransaction); + const originalId = txContext.operationContext.id; - transactionContext.setOperation({ - type: 'change-request', - id: 789, - }); - - expect(transactionContext.getOperationType()).toBe( - 'change-request', - ); - expect(transactionContext.getOperationId()).toBe(789); - expect(transactionContext.getOperationId()).not.toBe( - originalId, - ); + Object.assign(txContext.operationContext, { + type: 'change-request', }); + + expect(txContext.operationContext.type).toBe('change-request'); + expect(txContext.operationContext.id).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); - }); + describe('transaction access', () => { + it('should provide access to the underlying transaction', () => { + const txContext = new TransactionContext(mockTransaction); - expect(transactionContext.getOperation()).toBeUndefined(); + expect(txContext.transaction).toBe(mockTransaction); + expect(txContext.transaction.select).toBe(mockTransaction.select); + expect(txContext.transaction.insert).toBe(mockTransaction.insert); + expect(txContext.transaction.commit).toBe(mockTransaction.commit); }); }); }); diff --git a/src/lib/util/transactionContext.ts b/src/lib/util/transactionContext.ts index 5c5df1cd92..ffbb5b4017 100644 --- a/src/lib/util/transactionContext.ts +++ b/src/lib/util/transactionContext.ts @@ -1,4 +1,4 @@ -import { AsyncLocalStorage } from 'async_hooks'; +import type { Knex } from 'knex'; export interface OperationContext { type: 'change-request' | 'transaction'; @@ -11,38 +11,18 @@ function generateNumericTransactionId(): number { return timestamp * 1000 + random; } -const storage = new AsyncLocalStorage(); +export class TransactionContext { + public readonly transaction: Knex.Transaction; + public operationContext: OperationContext; -export const transactionContext = { - run(callback: () => Promise): Promise { - const data: OperationContext = { - type: 'transaction', - id: generateNumericTransactionId(), + constructor( + transaction: Knex.Transaction, + operationContext?: Partial, + ) { + this.transaction = transaction; + this.operationContext = { + type: operationContext?.type || 'transaction', + id: operationContext?.id || generateNumericTransactionId(), }; - return storage.run(data, callback) as Promise; - }, - - getOperation(): OperationContext | undefined { - return storage.getStore(); - }, - - getOperationType(): OperationContext['type'] | undefined { - return storage.getStore()?.type; - }, - - getOperationId(): 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', - ); - } - }, -}; + } +}