diff --git a/packages/transactional-adapters/transactional-adapter-kysely/.gitignore b/packages/transactional-adapters/transactional-adapter-kysely/.gitignore new file mode 100644 index 0000000..884e3de --- /dev/null +++ b/packages/transactional-adapters/transactional-adapter-kysely/.gitignore @@ -0,0 +1 @@ +test.db \ No newline at end of file diff --git a/packages/transactional-adapters/transactional-adapter-kysely/README.md b/packages/transactional-adapters/transactional-adapter-kysely/README.md new file mode 100644 index 0000000..bd6d922 --- /dev/null +++ b/packages/transactional-adapters/transactional-adapter-kysely/README.md @@ -0,0 +1,5 @@ +# @nestjs-cls/transactional-adapter-kysely + +Kysely adapter for the `@nestjs-cls/transactional` plugin. + +### ➡️ [Go to the documentation website](https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional/kysely-adapter) 📖 diff --git a/packages/transactional-adapters/transactional-adapter-kysely/jest.config.js b/packages/transactional-adapters/transactional-adapter-kysely/jest.config.js new file mode 100644 index 0000000..61cb57e --- /dev/null +++ b/packages/transactional-adapters/transactional-adapter-kysely/jest.config.js @@ -0,0 +1,17 @@ +module.exports = { + moduleFileExtensions: ['js', 'json', 'ts'], + rootDir: '.', + testRegex: '.*\\.spec\\.ts$', + transform: { + '^.+\\.ts$': 'ts-jest', + }, + collectCoverageFrom: ['src/**/*.ts'], + coverageDirectory: '../coverage', + testEnvironment: 'node', + globals: { + 'ts-jest': { + isolatedModules: true, + maxWorkers: 1, + }, + }, +}; diff --git a/packages/transactional-adapters/transactional-adapter-kysely/package.json b/packages/transactional-adapters/transactional-adapter-kysely/package.json new file mode 100644 index 0000000..1c30f55 --- /dev/null +++ b/packages/transactional-adapters/transactional-adapter-kysely/package.json @@ -0,0 +1,72 @@ +{ + "name": "@nestjs-cls/transactional-adapter-kysely", + "version": "1.0.0", + "description": "A Kysely adapter for @nestjs-cls/transactional", + "author": "papooch", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Papooch/nestjs-cls.git" + }, + "homepage": "https://papooch.github.io/nestjs-cls/", + "keywords": [ + "nest", + "nestjs", + "cls", + "continuation-local-storage", + "als", + "AsyncLocalStorage", + "async_hooks", + "request context", + "async context", + "transaction", + "transactional", + "transactional decorator", + "aop", + "kysely" + ], + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "files": [ + "dist/src/**/!(*.spec).d.ts", + "dist/src/**/!(*.spec).js" + ], + "scripts": { + "prepack": "cp ../../../LICENSE ./LICENSE", + "prebuild": "rimraf dist", + "build": "tsc", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage" + }, + "peerDependencies": { + "@nestjs-cls/transactional": "workspace:^2.0.0", + "nestjs-cls": "workspace:^4.0.1", + "kysely": "^0.27" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.2", + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/testing": "^10.0.0", + "@types/jest": "^28.1.2", + "@types/node": "^18.0.0", + "better-sqlite3": "^9.3.0", + "jest": "^28.1.1", + "kysely": "^0.27.2", + "reflect-metadata": "^0.1.13", + "rimraf": "^3.0.2", + "rxjs": "^7.5.5", + "ts-jest": "^28.0.5", + "ts-loader": "^9.3.0", + "ts-node": "^10.8.1", + "tsconfig-paths": "^4.0.0", + "typescript": "~4.8.0" + } +} \ No newline at end of file diff --git a/packages/transactional-adapters/transactional-adapter-kysely/src/index.ts b/packages/transactional-adapters/transactional-adapter-kysely/src/index.ts new file mode 100644 index 0000000..f75e6ef --- /dev/null +++ b/packages/transactional-adapters/transactional-adapter-kysely/src/index.ts @@ -0,0 +1 @@ +export * from './lib/transactional-adapter-kysely'; diff --git a/packages/transactional-adapters/transactional-adapter-kysely/src/lib/transactional-adapter-kysely.ts b/packages/transactional-adapters/transactional-adapter-kysely/src/lib/transactional-adapter-kysely.ts new file mode 100644 index 0000000..39a3bc3 --- /dev/null +++ b/packages/transactional-adapters/transactional-adapter-kysely/src/lib/transactional-adapter-kysely.ts @@ -0,0 +1,33 @@ +import { TransactionalAdapter } from '@nestjs-cls/transactional'; +import { Knex } from 'knex'; + +export interface KnexTransactionalAdapterOptions { + /** + * The injection token for the Knex instance. + */ + knexInstanceToken: any; +} + +export class TransactionalAdapterKnex + implements TransactionalAdapter +{ + connectionToken: any; + + constructor(options: KnexTransactionalAdapterOptions) { + this.connectionToken = options.knexInstanceToken; + } + + optionsFactory = (knexInstance: Knex) => ({ + wrapWithTransaction: async ( + options: Knex.TransactionConfig, + fn: (...args: any[]) => Promise, + setClient: (client?: Knex) => void, + ) => { + return knexInstance.transaction((trx) => { + setClient(trx); + return fn(); + }, options); + }, + getFallbackInstance: () => knexInstance, + }); +} diff --git a/packages/transactional-adapters/transactional-adapter-kysely/test/docker-compose.yml b/packages/transactional-adapters/transactional-adapter-kysely/test/docker-compose.yml new file mode 100644 index 0000000..d28f3da --- /dev/null +++ b/packages/transactional-adapters/transactional-adapter-kysely/test/docker-compose.yml @@ -0,0 +1,10 @@ +services: + db: + image: postgres:15 + ports: + - 5432:5432 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + \ No newline at end of file diff --git a/packages/transactional-adapters/transactional-adapter-kysely/test/transactional-adapter-knex.spec.ts b/packages/transactional-adapters/transactional-adapter-kysely/test/transactional-adapter-knex.spec.ts new file mode 100644 index 0000000..0c05cf0 --- /dev/null +++ b/packages/transactional-adapters/transactional-adapter-kysely/test/transactional-adapter-knex.spec.ts @@ -0,0 +1,181 @@ +import { + ClsPluginTransactional, + Transactional, + TransactionHost, +} from '@nestjs-cls/transactional'; +import { Inject, Injectable, Module } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ClsModule } from 'nestjs-cls'; +import Knex from 'knex'; +import { TransactionalAdapterKnex } from '../src'; + +const KNEX = 'KNEX'; + +@Injectable() +class UserRepository { + constructor( + private readonly txHost: TransactionHost, + ) {} + + async getUserById(id: number) { + return this.txHost.tx('user').where({ id }).first(); + } + + async createUser(name: string) { + const created = await this.txHost + .tx('user') + .insert({ name: name, email: `${name}@email.com` }) + .returning('*'); + return created[0] ?? null; + } +} + +@Injectable() +class UserService { + constructor( + private readonly userRepository: UserRepository, + private readonly txHost: TransactionHost, + @Inject(KNEX) + private readonly knex: Knex.Knex, + ) {} + + @Transactional() + async transactionWithDecorator() { + const r1 = await this.userRepository.createUser('John'); + const r2 = await this.userRepository.getUserById(r1.id); + return { r1, r2 }; + } + + @Transactional({ + isolationLevel: 'serializable', + }) + async transactionWithDecoratorWithOptions() { + const r1 = await this.userRepository.createUser('James'); + const r2 = + (await this.knex('user').where({ id: r1.id }).first()) ?? null; + const r3 = await this.userRepository.getUserById(r1.id); + return { r1, r2, r3 }; + } + + async transactionWithFunctionWrapper() { + return this.txHost.withTransaction( + { + isolationLevel: 'serializable', + }, + async () => { + const r1 = await this.userRepository.createUser('Joe'); + const r2 = + (await this.knex('user').where({ id: r1.id }).first()) ?? + null; + const r3 = await this.userRepository.getUserById(r1.id); + return { r1, r2, r3 }; + }, + ); + } + + @Transactional() + async transactionWithDecoratorError() { + await this.userRepository.createUser('Nobody'); + throw new Error('Rollback'); + } +} + +const knex = Knex({ + client: 'sqlite', + connection: { + filename: 'test.db', + }, + useNullAsDefault: true, + pool: { min: 1, max: 2 }, +}); + +@Module({ + providers: [ + { + provide: KNEX, + useValue: knex, + }, + ], + exports: [KNEX], +}) +class KnexModule {} + +@Module({ + imports: [ + KnexModule, + ClsModule.forRoot({ + plugins: [ + new ClsPluginTransactional({ + imports: [KnexModule], + adapter: new TransactionalAdapterKnex({ + knexInstanceToken: KNEX, + }), + }), + ], + }), + ], + providers: [UserService, UserRepository], +}) +class AppModule {} + +describe('Transactional', () => { + let module: TestingModule; + let callingService: UserService; + + beforeAll(async () => { + await knex.schema.dropTableIfExists('user'); + await knex.schema.createTable('user', (table) => { + table.increments('id'); + table.string('name'); + table.string('email'); + }); + }); + + beforeEach(async () => { + module = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + await module.init(); + callingService = module.get(UserService); + }); + + afterAll(async () => { + await knex.destroy(); + }); + + describe('TransactionalAdapterKnex', () => { + it('should run a transaction with the default options with a decorator', async () => { + const { r1, r2 } = await callingService.transactionWithDecorator(); + expect(r1).toEqual(r2); + const users = await knex('user'); + expect(users).toEqual(expect.arrayContaining([r1])); + }); + + it('should run a transaction with the specified options with a decorator', async () => { + const { r1, r2, r3 } = + await callingService.transactionWithDecoratorWithOptions(); + expect(r1).toEqual(r3); + expect(r2).toBeNull(); + const users = await knex('user'); + expect(users).toEqual(expect.arrayContaining([r1])); + }); + it('should run a transaction with the specified options with a function wrapper', async () => { + const { r1, r2, r3 } = + await callingService.transactionWithFunctionWrapper(); + expect(r1).toEqual(r3); + expect(r2).toBeNull(); + const users = await knex('user'); + expect(users).toEqual(expect.arrayContaining([r1])); + }); + + it('should rollback a transaction on error', async () => { + await expect( + callingService.transactionWithDecoratorError(), + ).rejects.toThrow(new Error('Rollback')); + const users = await knex('user'); + expect(users).toEqual( + expect.not.arrayContaining([{ name: 'Nobody' }]), + ); + }); + }); +}); diff --git a/packages/transactional-adapters/transactional-adapter-kysely/tsconfig.json b/packages/transactional-adapters/transactional-adapter-kysely/tsconfig.json new file mode 100644 index 0000000..bbc28fb --- /dev/null +++ b/packages/transactional-adapters/transactional-adapter-kysely/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "." + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/yarn.lock b/yarn.lock index fdc7163..ad43ac6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4680,6 +4680,33 @@ __metadata: languageName: unknown linkType: soft +"@nestjs-cls/transactional-adapter-kysely@workspace:packages/transactional-adapters/transactional-adapter-kysely": + version: 0.0.0-use.local + resolution: "@nestjs-cls/transactional-adapter-kysely@workspace:packages/transactional-adapters/transactional-adapter-kysely" + dependencies: + "@nestjs/cli": "npm:^10.0.2" + "@nestjs/common": "npm:^10.0.0" + "@nestjs/core": "npm:^10.0.0" + "@nestjs/testing": "npm:^10.0.0" + "@types/jest": "npm:^28.1.2" + "@types/node": "npm:^18.0.0" + better-sqlite3: "npm:^9.3.0" + jest: "npm:^28.1.1" + kysely: "npm:^0.27.2" + reflect-metadata: "npm:^0.1.13" + rimraf: "npm:^3.0.2" + rxjs: "npm:^7.5.5" + ts-jest: "npm:^28.0.5" + ts-loader: "npm:^9.3.0" + ts-node: "npm:^10.8.1" + tsconfig-paths: "npm:^4.0.0" + typescript: "npm:~4.8.0" + peerDependencies: + "@nestjs-cls/transactional": "workspace:^2.0.0" + nestjs-cls: "workspace:^4.0.1" + languageName: unknown + linkType: soft + "@nestjs-cls/transactional-adapter-prisma@workspace:packages/transactional-adapters/transactional-adapter-prisma": version: 0.0.0-use.local resolution: "@nestjs-cls/transactional-adapter-prisma@workspace:packages/transactional-adapters/transactional-adapter-prisma" @@ -8033,6 +8060,17 @@ __metadata: languageName: node linkType: hard +"better-sqlite3@npm:^9.3.0": + version: 9.3.0 + resolution: "better-sqlite3@npm:9.3.0" + dependencies: + bindings: "npm:^1.5.0" + node-gyp: "npm:latest" + prebuild-install: "npm:^7.1.1" + checksum: 08943620079dd3f7de7e12a7cb63b9a6ca5b399ca3e06363f120b2589c86cadef2eca56558243bfaf930fb28b4d956ab0b3910bc8b09a35276670efe7b7c516c + languageName: node + linkType: hard + "big.js@npm:^5.2.2": version: 5.2.2 resolution: "big.js@npm:5.2.2" @@ -13971,6 +14009,13 @@ __metadata: languageName: node linkType: hard +"kysely@npm:^0.27.2": + version: 0.27.2 + resolution: "kysely@npm:0.27.2" + checksum: c9bf222d66896bb1310c3c179cfbfb9a8159a2a381fbfe3658c9def6572a19f7d427c4fd39b26263fe19093a3eb43ff135e55d5c59714e637b6dec69dc8e7f87 + languageName: node + linkType: hard + "latest-version@npm:^7.0.0": version: 7.0.0 resolution: "latest-version@npm:7.0.0"