From ba303129edfd77e10ac7c40a9920b49ff5f3aa08 Mon Sep 17 00:00:00 2001 From: myfunc Date: Mon, 10 Jun 2024 12:02:11 -0700 Subject: [PATCH] Add license --- .gitignore | 11 ++ .prettierrc | 7 ++ LICENSE | 21 ++++ README.md | 108 +++++++++++++++++ examples/nest/.env.example | 1 + examples/nest/.gitignore | 3 + examples/nest/index.ts | 188 +++++++++++++++++++++++++++++ examples/nest/package.json | 20 +++ examples/nest/prisma/schema.prisma | 18 +++ examples/nest/tsconfig.json | 19 +++ package.json | 44 +++++++ src/const.ts | 3 + src/core.ts | 184 ++++++++++++++++++++++++++++ src/decorator.ts | 59 +++++++++ src/index.ts | 5 + src/logger/console-logger.ts | 19 +++ src/logger/empty-logger.ts | 9 ++ src/manager.ts | 33 +++++ src/type.ts | 12 ++ tsconfig.json | 19 +++ 20 files changed, 783 insertions(+) create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 LICENSE create mode 100644 README.md create mode 100644 examples/nest/.env.example create mode 100644 examples/nest/.gitignore create mode 100644 examples/nest/index.ts create mode 100644 examples/nest/package.json create mode 100644 examples/nest/prisma/schema.prisma create mode 100644 examples/nest/tsconfig.json create mode 100644 package.json create mode 100644 src/const.ts create mode 100644 src/core.ts create mode 100644 src/decorator.ts create mode 100644 src/index.ts create mode 100644 src/logger/console-logger.ts create mode 100644 src/logger/empty-logger.ts create mode 100644 src/manager.ts create mode 100644 src/type.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dafbfed --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Dependency directories +node_modules + +dist + +# dotenv environment variable files +.env + +package-lock.json +*.tgz +dev.db \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..a33edd2 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "tabWidth": 2, + "arrayBracketSpacing": true, + "printWidth": 100 +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a1ef6ae --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Denys Myronov (myfunc) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..196984f --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# Prisma Transactional + +Package contains @PrismaTransactional decorator that wraps all prisma queries to a single transaction. In case of overlapping several transactions they will be merged. + +**Use in production at your own risk.** +A decorator is being actively used on production environment with no issues, but I strictly recommend to wait for a stable release. + + +### How to setup in NestJS application + +Install a package +```bash +npm i @myfunc/prisma-transactional +``` + +Patch your PrismaClient with `patchPrismaTx(client, config)` +```tsx +import { patchPrismaTx } from '@myfunc/prisma-transactional'; // Import +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit { + constructor() { + super(); + // Patch and return the substituted version. + return patchPrismaTx(this, { + enableLogging: true, + customLogger: Logger, + }); + } + + async onModuleInit() { + await this.$connect(); + } +} +``` +Now you can use `PrismaTransactional`. + +### Run example +In [Example application](./example/index.ts) described all possible decorator's use cases. +For running example app, please add .env file and provide DB connection string. + +```bash +npm i +npm run dev +``` + +### How to use the decorator + +You can add decorator to any class-method. All queries inside will be wrapped in a single transaction. + +On any unhandled error all changed will be rolled back. + +**BE CAREFUL when using it, all queries inside transaction will be isolated and can lead to deadlock.** + +Example + +```tsx + // Now all queries (including nested queries in methods) will be executed in transaction + @PrismaTransactional() + private async addPoints(userId: string, amount: number) { + const { balance } = await this.getBalance(userId); + const newBalance = await this.prisma.user.update({ + select: { + balance: true, + }, + where: { id: userId }, + data: { balance: roundBalance(balance + amount) }, + }); + return { + newBalance + }; + } +``` + +To handle success commit you can put the following code anywhere in the code. If there is no transaction, a callback will be executed immediately. + +```tsx +PrismaTransactional.onSuccess(() => { + this.notifyBalanceUpdated(balance!, args._notificationDelay); +}); +``` + +Also, you can add many callbacks. All callbacks are stored in a stack under the hood. + +You can execute all in transaction with no decorator. + +```tsx +PrismaTransactional.execute(async () => { + await this.prisma.users.findMany({}); + await this.prisma.users.deleteMany({}); +}); +``` +or +```tsx +const result = await PrismaTransactional.execute(async () => { + const result = await this.prisma.users.findMany({}); + await this.prisma.users.deleteMany({}); + return result; +}); +``` + +## Plans +- [ ] Get rid of hardcoded values and make them configurable. "TRANSACTION_TIMEOUT" +- [ ] Implement ESLint rule for nested prisma queries that might be unintentionally executed in transaction. That means a developer will be aknowledged about possible transaction wrapping and force him to add an eslint-ignore comment. +- [ ] Add tests. +- [ ] Add express.js examples. \ No newline at end of file diff --git a/examples/nest/.env.example b/examples/nest/.env.example new file mode 100644 index 0000000..13c9ed6 --- /dev/null +++ b/examples/nest/.env.example @@ -0,0 +1 @@ +DATABASE_URL=postgres://postgres:password@127.0.0.1:5432/txtest \ No newline at end of file diff --git a/examples/nest/.gitignore b/examples/nest/.gitignore new file mode 100644 index 0000000..cf58980 --- /dev/null +++ b/examples/nest/.gitignore @@ -0,0 +1,3 @@ +node_modules +.env +prisma/migrations \ No newline at end of file diff --git a/examples/nest/index.ts b/examples/nest/index.ts new file mode 100644 index 0000000..10a7caf --- /dev/null +++ b/examples/nest/index.ts @@ -0,0 +1,188 @@ +import { Post, Prisma, PrismaClient } from '@prisma/client'; +import { patchPrismaTx, PrismaTransactional } from '../../src'; +import { clear } from 'console'; + +// Init prisma client +const rawPrisma = new PrismaClient({ + log: [ + { + emit: 'event', + level: 'query', + }, + { + emit: 'stdout', + level: 'error', + }, + { + emit: 'stdout', + level: 'info', + }, + { + emit: 'stdout', + level: 'warn', + }, + ], +}); +rawPrisma.$on('query', (e) => { + console.log(`Query: ${e.query} ${e.duration}ms; Params: ${e.params}`); +}); + +// Apply @PrismaTransactional() +const prisma = patchPrismaTx(rawPrisma, { + enableLogging: true, +}); + +// Test code +// Utils +function randomText(length: number) { + return Math.random().toString(36).slice(-length); +} + +function WaitAsync(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Repo +class PostRepository { + constructor(private prisma: PrismaClient) {} + createPost(data: Prisma.PostCreateInput) { + return this.prisma.post.create({ data }); + } + + readPosts(query: Omit): Promise { + return this.prisma.post.findMany(query); + } + + deletePost(query: Prisma.PostDeleteArgs) { + return this.prisma.post.delete(query); + } + + deleteAll() { + return this.prisma.post.deleteMany(); + } + + async createRandomPosts(count: number) { + const postsPromises = Array.from({ length: count }, () => + this.prisma.post.create({ data: { title: 'Post ' + randomText(5) } }), + ); + const posts = await Promise.all(postsPromises); + return posts; + } + editPost(id: number, content: string) { + return this.prisma.post.update({ + where: { id }, + data: { content }, + }); + } +} + +// Service +class PostService { + constructor(private postRepository: PostRepository) {} + + throwError() { + throw new Error('Test error'); + } + + async readAllPostsAndCreateOneMerged() { + const posts = await this.postRepository.readPosts({}); + const summary = posts.reduce((acc, post) => { + return acc + post.title + ';\n'; + }, ''); + const newPost = await this.postRepository.createPost({ + title: 'New readAllPostsAndCreateOneMerged post', + content: summary, + }); + return newPost; + } + + async deleteAllAndCreateOnePost() { + await this.postRepository.deleteAll(); + const newPost = await this.postRepository.createPost({ + title: 'New deleteAllAndCreateOnePost post', + }); + return newPost; + } + + @PrismaTransactional() + async txCreate5RandomWait5SecAndSummarize() { + PrismaTransactional.onSuccess(async () => { + console.log('txCreate5RandomWait5SecAndSummarize PrismaTransactional.onSuccess'); + }); + await this.postRepository.createRandomPosts(5); + await WaitAsync(5000); + await this.readAllPostsAndCreateOneMerged(); + + return await this.postRepository.readPosts({}); + } + + @PrismaTransactional() + async txCreate30PostsAndThrow() { + PrismaTransactional.onSuccess(async () => { + console.log('txCreate30PostsAndThrow PrismaTransactional.onSuccess'); + }); + const posts = await this.postRepository.createRandomPosts(30); + await this.postRepository.editPost(posts[0].id, 'edited'); + await WaitAsync(1000); + + this.throwError(); + } + + async txReadAllPostsAndCreateOneWithCount() { + PrismaTransactional.onSuccess(async () => { + console.log('txReadAllPostsAndCreateOneWithCount PrismaTransactional.onSuccess'); + }); + + const posts = await this.postRepository.readPosts({}); + await WaitAsync(2500); + + return await this.postRepository.createPost({ + title: 'New txReadAllPostsAndCreateOneMerged post', + content: `Count: ${posts.length}`, + }); + } +} + +// Main +async function resetDB() { + await prisma.$queryRaw`TRUNCATE TABLE "Post" RESTART IDENTITY;`; +} + +async function main() { + await resetDB(); + const postRepository = new PostRepository(prisma); + const postService = new PostService(postRepository); + + const testErrorAction = async () => { + try { + await postService.txCreate30PostsAndThrow(); + } catch {} + return postRepository.readPosts({}); + }; + + const test1Promise = postService.txCreate5RandomWait5SecAndSummarize(); + const testErrorPromise = testErrorAction(); + await WaitAsync(200); + const test2 = await PrismaTransactional.execute( + async () => await postService.txReadAllPostsAndCreateOneWithCount(), + ); + const test1 = await test1Promise; + const test3 = await postService.deleteAllAndCreateOnePost(); + const throwError = await testErrorPromise; + + console.log({ test1, test2, test3, throwError }); + if (test1.length !== 7) { + console.error('test1.length !== 7'); + } + if (test2.content !== 'Count: 0') { + console.error('test2.content !== 0'); + } + if (test3.title !== 'New deleteAllAndCreateOnePost post') { + console.error('test3.content is not correct'); + } + if (throwError.length !== 0) { + console.error('throwError page.length !== 0'); + } +} + +main(); diff --git a/examples/nest/package.json b/examples/nest/package.json new file mode 100644 index 0000000..ae85eb6 --- /dev/null +++ b/examples/nest/package.json @@ -0,0 +1,20 @@ +{ + "name": "example", + "scripts": { + "install": "prisma migrate dev", + "dev": "ts-node --transpile-only index.ts", + "lint": "eslint index.ts" + }, + "dependencies": { + "@prisma/client": "^5.13.0" + }, + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^7.8.0", + "@typescript-eslint/parser": "^7.8.0", + "eslint": "^8.57.0", + "eslint-plugin-local": "^4.2.2", + "prisma": "latest", + "ts-node": "^10.9.2", + "typescript": "^5.0.0" + } +} diff --git a/examples/nest/prisma/schema.prisma b/examples/nest/prisma/schema.prisma new file mode 100644 index 0000000..b049c23 --- /dev/null +++ b/examples/nest/prisma/schema.prisma @@ -0,0 +1,18 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Post { + id Int @id @default(autoincrement()) + title String + content String? + author String? +} diff --git a/examples/nest/tsconfig.json b/examples/nest/tsconfig.json new file mode 100644 index 0000000..464a9f1 --- /dev/null +++ b/examples/nest/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2021", + "lib": ["ES2021"], + "module": "CommonJS", + "moduleResolution": "node", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipDefaultLibCheck": true, + "skipLibCheck": false, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "declaration": true, + "incremental": true, + "outDir": "./dist" + }, + "include": ["**/*.ts"] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a2d6c27 --- /dev/null +++ b/package.json @@ -0,0 +1,44 @@ +{ + "name": "@myfunc/prisma-transactional", + "version": "0.1.0", + "author": "myfunc", + "description": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/myfunc/prisma-transactional.git" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "prisma", + "@prisma/client", + "nest", + "nestjs", + "extension", + "transactional", + "transaction", + "prisma-transactional", + "prisma-transaction" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc" + }, + "peerDependencies": { + "@prisma/client": ">=5.0.0", + "nestjs-cls": "^4.3.0" + }, + "devDependencies": { + "@prisma/client": ">=5.0.0", + "@types/node": "^20.12.11", + "@typescript-eslint/eslint-plugin": "^7.8.0", + "@typescript-eslint/parser": "^7.8.0", + "eslint": "^8.57.0", + "eslint-plugin-local": "^4.2.2", + "prisma": "latest", + "typescript": "4.9.4" + } +} diff --git a/src/const.ts b/src/const.ts new file mode 100644 index 0000000..e2275cf --- /dev/null +++ b/src/const.ts @@ -0,0 +1,3 @@ +export const TX_CLIENT_KEY = 'txClient'; +export const TX_CLIENT_SUCCESS_CALLBACKS = 'txClientSuccessCallbacks'; +export const TRANSACTION_TIMEOUT = 60000; diff --git a/src/core.ts b/src/core.ts new file mode 100644 index 0000000..06b8594 --- /dev/null +++ b/src/core.ts @@ -0,0 +1,184 @@ +import { Prisma, PrismaClient } from '@prisma/client/extension'; +import { ClsService, ClsServiceManager } from 'nestjs-cls'; +import { TX_CLIENT_KEY, TX_CLIENT_SUCCESS_CALLBACKS } from './const'; +import { Manager } from './manager'; +import { PrismaTransactionalConfig } from './type'; + +// That solution can join transactions. +// Found here https://github.com/prisma/prisma/issues/5729 +export { ClsService, ClsServiceManager }; + +// This function needs to be called after the transaction commit +// You should call this at the point where your transaction successfully commits +function executeSuccessCallbacks(): void { + const clsService = ClsServiceManager.getClsService(); + const callbacks = clsService.get<(() => void)[]>(TX_CLIENT_SUCCESS_CALLBACKS) || []; + callbacks.forEach((callback) => { + try { + callback(); + } catch (e) { + Manager.logger.error({ + context: 'Prisma.' + executeSuccessCallbacks.name, + message: 'Error executing success callback', + error: e, + }); + } + }); + clsService.set(TX_CLIENT_SUCCESS_CALLBACKS, []); // Clear the queue after execution +} + +export function patchPrismaTx( + prisma: T, + config?: PrismaTransactionalConfig, +): T { + const _prisma = prisma as any; + const original$transaction = _prisma.$transaction; + _prisma.$transaction = (...args: unknown[]) => { + if (typeof args[0] === 'function') { + const fn = args[0] as (txClient: Prisma.TransactionClient) => Promise; + args[0] = async (txClient: Prisma.TransactionClient) => { + const clsService = ClsServiceManager.getClsService(); + + const maybeExistingTxClient = clsService.get( + TX_CLIENT_KEY, + ); + + if (maybeExistingTxClient) { + Manager.logger.verbose?.({ + context: 'Prisma.' + patchPrismaTx.name, + message: 'Return txClient from ALS', + }); + + return fn(maybeExistingTxClient); + } + + if (clsService.isActive()) { + // covering this for completeness, should rarely happen + Manager.logger.warn({ + context: 'Prisma.' + patchPrismaTx.name, + message: 'Context active without txClient', + }); + + return executeInContext({ + context: clsService, + txClient, + fn, + }); + } + + // this occurs on the top-level + return clsService.run(async () => { + return executeInContext({ + context: clsService, + txClient, + fn, + }); + }); + }; + } + + return original$transaction.apply(_prisma, args as any) as any; + }; + + const proxyPrisma = createPrismaProxy(_prisma); + Manager.setPrismaClient(proxyPrisma); + Manager.setConfig(config); + return proxyPrisma as T; +} + +type ExecutionParams = { + context: ClsService; + txClient: Prisma.TransactionClient; + fn: (txClient: Prisma.TransactionClient) => Promise; +}; + +async function executeInContext({ context, txClient, fn }: ExecutionParams) { + context.set(TX_CLIENT_KEY, txClient); + + Manager.logger.verbose?.({ + context: 'Prisma.' + executeInContext.name, + message: 'Top-level: open context, store txClient and propagate', + }); + try { + const result = await fn(txClient); + executeSuccessCallbacks(); + return result; + } finally { + context.set(TX_CLIENT_KEY, undefined); + + Manager.logger.verbose?.({ + context: 'Prisma.' + executeInContext.name, + message: 'Top-level: ALS context reset', + }); + } +} + +function createPrismaProxy(target: T): T { + const _target = target as any; + return new Proxy(_target, { + get(_, prop, receiver) { + // provide an undocumented escape hatch to access the root PrismaClient and start top-level transactions + if (prop === '$root') { + Manager.logger.verbose?.({ + context: 'Prisma.' + createPrismaProxy.name, + message: '[Proxy] Accessing root Prisma', + }); + + return _target; + } + + const maybeExistingTxClient = ClsServiceManager.getClsService().get< + Prisma.TransactionClient | undefined + >(TX_CLIENT_KEY); + + const prisma = maybeExistingTxClient ?? _target; + + if (prop === '$transaction' && maybeExistingTxClient && typeof _target[prop] === 'function') { + Manager.logger.verbose?.({ + context: 'Prisma.' + createPrismaProxy.name, + message: '[Proxy] $transaction called on a txClient, continue nesting it', + }); + + return function (...args: unknown[]) { + // grab the callback of the native "prisma.$transaction(callback, options)" invocation and invoke it with the txClient from the ALS + if (typeof args[0] === 'function') { + return args[0](maybeExistingTxClient); + } else if (args[0] instanceof Array) { + Manager.logger.warn({ + context: 'Prisma.' + createPrismaProxy.name, + message: + 'Nested $transaction called with an array argument, it is probably works out of transaction', + }); + } else { + throw new Error('prisma.$transaction called with a non-function argument'); + } + }; + } + return Reflect.get(prisma, prop, receiver); + }, + set(_, prop, newValue, receiver) { + if (prop === '$transaction') { + Manager.logger.warn({ + context: 'Prisma.' + createPrismaProxy.name, + message: `Please don't spy on $transaction.`, + }); + return false; + } + + const maybeExistingTxClient = ClsServiceManager.getClsService().get< + Prisma.TransactionClient | undefined + >(TX_CLIENT_KEY); + + const prisma = maybeExistingTxClient ?? _target; + return Reflect.set(prisma, prop, newValue, receiver); + }, + defineProperty(_, prop, attributes) { + const maybeExistingTxClient = ClsServiceManager.getClsService().get< + Prisma.TransactionClient | undefined + >(TX_CLIENT_KEY); + + const prisma = maybeExistingTxClient ?? _target; + return Reflect.defineProperty(prisma, prop, attributes); + }, + }) as T; +} diff --git a/src/decorator.ts b/src/decorator.ts new file mode 100644 index 0000000..aaac817 --- /dev/null +++ b/src/decorator.ts @@ -0,0 +1,59 @@ +import { PrismaClient } from '@prisma/client/extension'; +import { ClsService, ClsServiceManager } from 'nestjs-cls'; +import { TX_CLIENT_KEY, TRANSACTION_TIMEOUT, TX_CLIENT_SUCCESS_CALLBACKS } from './const'; +import { Manager } from './manager'; + +// That solution can join transactions. +// Found here https://github.com/prisma/prisma/issues/5729 +export { ClsService, ClsServiceManager }; + +/** That solution can creates and merge transactions. + BE CAREFUL when using it, all queries inside transaction will be isolated and can lead to deadlock. + + Found here https://github.com/prisma/prisma/issues/5729 + */ +export function PrismaTransactional(isolationLevel?: string): MethodDecorator { + return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { + const originalMethod = descriptor.value; + descriptor.value = function (...args: unknown[]) { + return PrismaTransactional.execute( + () => originalMethod.apply(this, [...args]), + isolationLevel, + ); + }; + }; +} + +// Utility to manage success callback queue +PrismaTransactional.onSuccess = (callback: () => void | Promise) => { + const clsService = ClsServiceManager.getClsService(); + const isActiveTransaction = clsService.get(TX_CLIENT_KEY); + + if (isActiveTransaction) { + const existingCallbacks = clsService.get(TX_CLIENT_SUCCESS_CALLBACKS) || []; + clsService.set(TX_CLIENT_SUCCESS_CALLBACKS, [...existingCallbacks, callback]); + } else { + return callback(); + } +}; + +// Run callback in transaction +PrismaTransactional.execute = ( + callback: () => Promise, + isolationLevel?: string, +): Promise => { + const cls = ClsServiceManager.getClsService(); + if (cls.get(TX_CLIENT_KEY)) { + return callback(); + } else { + return (Manager.prismaClient['$root'] as PrismaClient).$transaction( + async () => { + return callback(); + }, + { + isolationLevel, + timeout: TRANSACTION_TIMEOUT, + }, + ); + } +}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..5f43988 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,5 @@ +export * from './manager'; +export * from './core'; +export * from './decorator'; +export * from './type'; +export * from './const'; diff --git a/src/logger/console-logger.ts b/src/logger/console-logger.ts new file mode 100644 index 0000000..7c6b109 --- /dev/null +++ b/src/logger/console-logger.ts @@ -0,0 +1,19 @@ +import { ILoggerService } from '../type'; + +export class ConsoleLogger implements ILoggerService { + log(message: any, ...optionalParams: any[]) { + console.log(message, ...optionalParams); + } + error(message: any, ...optionalParams: any[]) { + console.error(message, ...optionalParams); + } + warn(message: any, ...optionalParams: any[]) { + console.warn(message, ...optionalParams); + } + debug?(message: any, ...optionalParams: any[]) { + console.debug(message, ...optionalParams); + } + verbose?(message: any, ...optionalParams: any[]) { + console.debug(message, ...optionalParams); + } +} diff --git a/src/logger/empty-logger.ts b/src/logger/empty-logger.ts new file mode 100644 index 0000000..37d7fcf --- /dev/null +++ b/src/logger/empty-logger.ts @@ -0,0 +1,9 @@ +import { ILoggerService } from '../type'; + +export class EmptyLogger implements ILoggerService { + log(message: any, ...optionalParams: any[]) {} + error(message: any, ...optionalParams: any[]) {} + warn(message: any, ...optionalParams: any[]) {} + debug?(message: any, ...optionalParams: any[]) {} + verbose?(message: any, ...optionalParams: any[]) {} +} diff --git a/src/manager.ts b/src/manager.ts new file mode 100644 index 0000000..8e96a73 --- /dev/null +++ b/src/manager.ts @@ -0,0 +1,33 @@ +import { PrismaClient } from '@prisma/client/extension'; +import { ILoggerService, PrismaTransactionalConfig } from './type'; +import { ConsoleLogger } from './logger/console-logger'; +import { EmptyLogger } from './logger/empty-logger'; + +class PrismaTransactionalManager { + private _prismaClient: PrismaClient | null; + private _logger: ILoggerService = new EmptyLogger(); + + setPrismaClient(prismaClient: PrismaClient) { + this._prismaClient = prismaClient; + } + setConfig(config?: PrismaTransactionalConfig) { + if (config?.enableLogging) { + this._logger = config.customLogger ?? new ConsoleLogger(); + } else { + this._logger = new EmptyLogger(); + } + } + + get prismaClient(): PrismaClient { + if (!this._prismaClient) { + throw new Error('PrismaTransactionalManager: Prisma client not set'); + } + return this._prismaClient; + } + + get logger(): ILoggerService { + return this._logger!; + } +} + +export const Manager = new PrismaTransactionalManager(); diff --git a/src/type.ts b/src/type.ts new file mode 100644 index 0000000..399be72 --- /dev/null +++ b/src/type.ts @@ -0,0 +1,12 @@ +export interface ILoggerService { + log(message: any, ...optionalParams: any[]): any; + error(message: any, ...optionalParams: any[]): any; + warn(message: any, ...optionalParams: any[]): any; + debug?(message: any, ...optionalParams: any[]): any; + verbose?(message: any, ...optionalParams: any[]): any; +} + +export type PrismaTransactionalConfig = { + enableLogging?: boolean; + customLogger?: ILoggerService; +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3580238 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2021", + "lib": ["ES2021"], + "module": "CommonJS", + "moduleResolution": "node", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipDefaultLibCheck": true, + "skipLibCheck": false, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "declaration": true, + "incremental": true, + "outDir": "./dist" + }, + "include": ["src/**/*.ts"] +}