diff --git a/packages/transactional-adapters/transactional-adapter-drizzle-orm/README.md b/packages/transactional-adapters/transactional-adapter-drizzle-orm/README.md new file mode 100644 index 0000000..98bc612 --- /dev/null +++ b/packages/transactional-adapters/transactional-adapter-drizzle-orm/README.md @@ -0,0 +1,5 @@ +# @nestjs-cls/transactional-adapter-knex + +Drizzle ORM adapter for the `@nestjs-cls/transactional` plugin. + +### ➡️ [Go to the documentation website](https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional/knex-adapter) 📖 diff --git a/packages/transactional-adapters/transactional-adapter-drizzle-orm/jest.config.js b/packages/transactional-adapters/transactional-adapter-drizzle-orm/jest.config.js new file mode 100644 index 0000000..14ae88e --- /dev/null +++ b/packages/transactional-adapters/transactional-adapter-drizzle-orm/jest.config.js @@ -0,0 +1,17 @@ +module.exports = { + moduleFileExtensions: ['js', 'json', 'ts'], + rootDir: '.', + testRegex: '.*\\.spec\\.ts$', + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + isolatedModules: true, + maxWorkers: 1, + }, + ], + }, + collectCoverageFrom: ['src/**/*.ts'], + coverageDirectory: '../coverage', + testEnvironment: 'node', +}; diff --git a/packages/transactional-adapters/transactional-adapter-drizzle-orm/package.json b/packages/transactional-adapters/transactional-adapter-drizzle-orm/package.json new file mode 100644 index 0000000..512a47b --- /dev/null +++ b/packages/transactional-adapters/transactional-adapter-drizzle-orm/package.json @@ -0,0 +1,74 @@ +{ + "name": "@nestjs-cls/transactional-adapter-drizzle-orm", + "version": "1.0.0", + "description": "A Drizzle ORM 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", + "drizzle", + "drizzle-orm" + ], + "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.4.2", + "drizzle-orm": "^0", + "nestjs-cls": "workspace:^4.4.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.2", + "@nestjs/common": "^10.3.7", + "@nestjs/core": "^10.3.7", + "@nestjs/testing": "^10.3.7", + "@types/jest": "^28.1.2", + "@types/node": "^18.0.0", + "@types/pg": "^8", + "drizzle-orm": "^0.34.1", + "jest": "^29.7.0", + "pg": "^8.11.3", + "reflect-metadata": "^0.1.13", + "rimraf": "^3.0.2", + "rxjs": "^7.5.5", + "ts-jest": "^29.1.2", + "ts-loader": "^9.3.0", + "ts-node": "^10.8.1", + "tsconfig-paths": "^4.0.0", + "typescript": "5.0" + } +} diff --git a/packages/transactional-adapters/transactional-adapter-drizzle-orm/src/index.ts b/packages/transactional-adapters/transactional-adapter-drizzle-orm/src/index.ts new file mode 100644 index 0000000..a698d77 --- /dev/null +++ b/packages/transactional-adapters/transactional-adapter-drizzle-orm/src/index.ts @@ -0,0 +1 @@ +export * from './lib/transactional-adapter-drizzle-orm'; diff --git a/packages/transactional-adapters/transactional-adapter-drizzle-orm/src/lib/transactional-adapter-drizzle-orm.ts b/packages/transactional-adapters/transactional-adapter-drizzle-orm/src/lib/transactional-adapter-drizzle-orm.ts new file mode 100644 index 0000000..f031ea7 --- /dev/null +++ b/packages/transactional-adapters/transactional-adapter-drizzle-orm/src/lib/transactional-adapter-drizzle-orm.ts @@ -0,0 +1,59 @@ +import { TransactionalAdapter } from '@nestjs-cls/transactional'; + +type AnyDrizzleClient = { + transaction: ( + fn: (tx: AnyDrizzleClient) => Promise, + options?: any, + ) => Promise; +}; + +type DrizzleTransactionOptions = T extends AnyDrizzleClient + ? Parameters[1] + : never; + +export interface DrizzleOrmTransactionalAdapterOptions< + TClient extends AnyDrizzleClient, +> { + /** + * The injection token for the Drizzle instance. + */ + drizzleInstanceToken: any; + + /** + * Default options for the transaction. These will be merged with any transaction-specific options + * passed to the `@Transactional` decorator or the `TransactionHost#withTransaction` method. + */ + defaultTxOptions?: Partial>; +} + +export class TransactionalAdapterDrizzleOrm + implements + TransactionalAdapter< + TClient, + TClient, + DrizzleTransactionOptions + > +{ + connectionToken: any; + + defaultTxOptions?: Partial>; + + constructor(options: DrizzleOrmTransactionalAdapterOptions) { + this.connectionToken = options.drizzleInstanceToken; + this.defaultTxOptions = options.defaultTxOptions; + } + + optionsFactory = (drizzleInstance: TClient) => ({ + wrapWithTransaction: async ( + options: DrizzleTransactionOptions, + fn: (...args: any[]) => Promise, + setClient: (client?: TClient) => void, + ) => { + return drizzleInstance.transaction(async (tx) => { + setClient(tx as TClient); + return fn(); + }, options); + }, + getFallbackInstance: () => drizzleInstance, + }); +} diff --git a/packages/transactional-adapters/transactional-adapter-drizzle-orm/test/docker-compose.yml b/packages/transactional-adapters/transactional-adapter-drizzle-orm/test/docker-compose.yml new file mode 100644 index 0000000..fc818f5 --- /dev/null +++ b/packages/transactional-adapters/transactional-adapter-drizzle-orm/test/docker-compose.yml @@ -0,0 +1,14 @@ +services: + drizzle-orm-test-db: + image: postgres:15 + ports: + - 5447:5432 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U postgres'] + interval: 1s + timeout: 1s + retries: 5 diff --git a/packages/transactional-adapters/transactional-adapter-drizzle-orm/test/transactional-adapter-drizzle-orm.spec.ts b/packages/transactional-adapters/transactional-adapter-drizzle-orm/test/transactional-adapter-drizzle-orm.spec.ts new file mode 100644 index 0000000..5d3f775 --- /dev/null +++ b/packages/transactional-adapters/transactional-adapter-drizzle-orm/test/transactional-adapter-drizzle-orm.spec.ts @@ -0,0 +1,264 @@ +import { + ClsPluginTransactional, + InjectTransaction, + Transaction, + Transactional, + TransactionHost, +} from '@nestjs-cls/transactional'; +import { Inject, Injectable, Module } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { drizzle } from 'drizzle-orm/node-postgres'; +import { ClsModule, UseCls } from 'nestjs-cls'; +import { Pool } from 'pg'; + +import { eq } from 'drizzle-orm'; +import { pgTable, serial, text } from 'drizzle-orm/pg-core'; +import { TransactionalAdapterDrizzleOrm } from '../src'; +import { execSync } from 'child_process'; + +const users = pgTable('users', { + id: serial('id').primaryKey(), + name: text().notNull(), + email: text().notNull(), +}); + +const DRIZZLE = 'DRIZZLE'; + +const drizzleClient = drizzle( + new Pool({ + connectionString: 'postgres://postgres:postgres@localhost:5447', + max: 2, + }), + { + schema: { + users, + }, + }, +); + +type DrizzleClient = typeof drizzleClient; + +@Injectable() +class UserRepository { + constructor( + @InjectTransaction() + private readonly tx: Transaction< + TransactionalAdapterDrizzleOrm + >, + ) {} + + async getUserById(id: number) { + const user = await this.tx.query.users.findFirst({ + where: eq(users.id, id), + }); + return user ?? null; + } + + async createUser(name: string) { + const created = await this.tx + .insert(users) + .values({ + name: name, + email: `${name}@email.com`, + }) + .returning() + .execute(); + return created[0] ?? null; + } +} + +@Injectable() +class UserService { + constructor( + private readonly userRepository: UserRepository, + private readonly txHost: TransactionHost< + TransactionalAdapterDrizzleOrm + >, + @Inject(DRIZZLE) + private readonly drizzleClient: DrizzleClient, + ) {} + + @UseCls() + async withoutTransaction() { + const r1 = await this.userRepository.createUser('Jim'); + const r2 = await this.userRepository.getUserById(r1.id); + return { r1, r2 }; + } + + @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.drizzleClient.query.users.findFirst({ + where: eq(users.id, r1.id), + })) ?? 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.drizzleClient.query.users.findFirst({ + where: eq(users.id, r1.id), + })) ?? 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'); + } +} + +@Module({ + providers: [ + { + provide: DRIZZLE, + useValue: drizzleClient, + }, + ], + exports: [DRIZZLE], +}) +class KnexModule {} + +@Module({ + imports: [ + KnexModule, + ClsModule.forRoot({ + plugins: [ + new ClsPluginTransactional({ + imports: [KnexModule], + adapter: new TransactionalAdapterDrizzleOrm({ + drizzleInstanceToken: DRIZZLE, + defaultTxOptions: {}, + }), + enableTransactionProxy: true, + }), + ], + }), + ], + providers: [UserService, UserRepository], +}) +class AppModule {} + +describe('Transactional', () => { + let module: TestingModule; + let callingService: UserService; + + beforeAll(async () => { + execSync( + 'docker compose -f test/docker-compose.yml up -d --quiet-pull --wait', + { + stdio: 'inherit', + cwd: process.cwd(), + }, + ); + + await drizzleClient.$client.query('DROP TABLE IF EXISTS users'); + await drizzleClient.$client.query(` + CREATE TABLE users ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL + ) + `); + }, 60_000); + + beforeEach(async () => { + module = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + await module.init(); + callingService = module.get(UserService); + }); + + afterAll(async () => { + await drizzleClient.$client.end(); + execSync('docker compose -f test/docker-compose.yml down', { + stdio: 'inherit', + cwd: process.cwd(), + }); + }, 60_000); + + describe('TransactionalAdapterDrizzleOrm', () => { + it('should work without an active transaction', async () => { + const { r1, r2 } = await callingService.withoutTransaction(); + expect(r1).toEqual(r2); + const users = await drizzleClient.query.users.findMany(); + expect(users).toEqual(expect.arrayContaining([r1])); + }); + + 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 drizzleClient.query.users.findMany(); + 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 drizzleClient.query.users.findMany(); + 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 drizzleClient.query.users.findMany(); + 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 drizzleClient.query.users.findMany(); + expect(users).toEqual( + expect.not.arrayContaining([{ name: 'Nobody' }]), + ); + }); + }); +}); + +describe('Default options', () => { + it('Should correctly set default options on the adapter instance', async () => { + const adapter = new TransactionalAdapterDrizzleOrm({ + drizzleInstanceToken: DRIZZLE, + defaultTxOptions: { + isolationLevel: 'read uncommitted', + accessMode: 'read write', + }, + }); + + expect(adapter.defaultTxOptions).toEqual({ + isolationLevel: 'read uncommitted', + accessMode: 'read write', + }); + }); +}); diff --git a/packages/transactional-adapters/transactional-adapter-drizzle-orm/tsconfig.json b/packages/transactional-adapters/transactional-adapter-drizzle-orm/tsconfig.json new file mode 100644 index 0000000..bbc28fb --- /dev/null +++ b/packages/transactional-adapters/transactional-adapter-drizzle-orm/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 138b98a..e1af226 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5165,6 +5165,35 @@ __metadata: languageName: node linkType: hard +"@nestjs-cls/transactional-adapter-drizzle-orm@workspace:packages/transactional-adapters/transactional-adapter-drizzle-orm": + version: 0.0.0-use.local + resolution: "@nestjs-cls/transactional-adapter-drizzle-orm@workspace:packages/transactional-adapters/transactional-adapter-drizzle-orm" + dependencies: + "@nestjs/cli": "npm:^10.0.2" + "@nestjs/common": "npm:^10.3.7" + "@nestjs/core": "npm:^10.3.7" + "@nestjs/testing": "npm:^10.3.7" + "@types/jest": "npm:^28.1.2" + "@types/node": "npm:^18.0.0" + "@types/pg": "npm:^8" + drizzle-orm: "npm:^0.34.1" + jest: "npm:^29.7.0" + pg: "npm:^8.11.3" + reflect-metadata: "npm:^0.1.13" + rimraf: "npm:^3.0.2" + rxjs: "npm:^7.5.5" + ts-jest: "npm:^29.1.2" + ts-loader: "npm:^9.3.0" + ts-node: "npm:^10.8.1" + tsconfig-paths: "npm:^4.0.0" + typescript: "npm:5.0" + peerDependencies: + "@nestjs-cls/transactional": "workspace:^2.4.2" + drizzle-orm: ^0 + nestjs-cls: "workspace:^4.4.1" + languageName: unknown + linkType: soft + "@nestjs-cls/transactional-adapter-knex@workspace:packages/transactional-adapters/transactional-adapter-knex": version: 0.0.0-use.local resolution: "@nestjs-cls/transactional-adapter-knex@workspace:packages/transactional-adapters/transactional-adapter-knex" @@ -11079,6 +11108,98 @@ __metadata: languageName: node linkType: hard +"drizzle-orm@npm:^0.34.1": + version: 0.34.1 + resolution: "drizzle-orm@npm:0.34.1" + peerDependencies: + "@aws-sdk/client-rds-data": ">=3" + "@cloudflare/workers-types": ">=3" + "@electric-sql/pglite": ">=0.1.1" + "@libsql/client": ">=0.10.0" + "@neondatabase/serverless": ">=0.1" + "@op-engineering/op-sqlite": ">=2" + "@opentelemetry/api": ^1.4.1 + "@planetscale/database": ">=1" + "@prisma/client": "*" + "@tidbcloud/serverless": "*" + "@types/better-sqlite3": "*" + "@types/pg": "*" + "@types/react": ">=18" + "@types/sql.js": "*" + "@vercel/postgres": ">=0.8.0" + "@xata.io/client": "*" + better-sqlite3: ">=7" + bun-types: "*" + expo-sqlite: ">=13.2.0" + knex: "*" + kysely: "*" + mysql2: ">=2" + pg: ">=8" + postgres: ">=3" + react: ">=18" + sql.js: ">=1" + sqlite3: ">=5" + peerDependenciesMeta: + "@aws-sdk/client-rds-data": + optional: true + "@cloudflare/workers-types": + optional: true + "@electric-sql/pglite": + optional: true + "@libsql/client": + optional: true + "@neondatabase/serverless": + optional: true + "@op-engineering/op-sqlite": + optional: true + "@opentelemetry/api": + optional: true + "@planetscale/database": + optional: true + "@prisma/client": + optional: true + "@tidbcloud/serverless": + optional: true + "@types/better-sqlite3": + optional: true + "@types/pg": + optional: true + "@types/react": + optional: true + "@types/sql.js": + optional: true + "@vercel/postgres": + optional: true + "@xata.io/client": + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + react: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + checksum: 067b360d41994953bb958d216b827d78b8a9550517e12b3cdf85be7cb361e982817f511a9ac317c84184aaa096321e23b43481318ee8b3c4c80f77d243628730 + languageName: node + linkType: hard + "dset@npm:^3.1.2": version: 3.1.2 resolution: "dset@npm:3.1.2"