Skip to content

Commit

Permalink
Add license
Browse files Browse the repository at this point in the history
  • Loading branch information
myfunc committed Jun 10, 2024
0 parents commit ba30312
Show file tree
Hide file tree
Showing 20 changed files with 783 additions and 0 deletions.
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Dependency directories
node_modules

dist

# dotenv environment variable files
.env

package-lock.json
*.tgz
dev.db
7 changes: 7 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"singleQuote": true,
"trailingComma": "all",
"tabWidth": 2,
"arrayBracketSpacing": true,
"printWidth": 100
}
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
108 changes: 108 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions examples/nest/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DATABASE_URL=postgres://postgres:[email protected]:5432/txtest
3 changes: 3 additions & 0 deletions examples/nest/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
.env
prisma/migrations
188 changes: 188 additions & 0 deletions examples/nest/index.ts
Original file line number Diff line number Diff line change
@@ -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<Prisma.PostFindManyArgs, 'select'>): Promise<Post[]> {
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();
20 changes: 20 additions & 0 deletions examples/nest/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
18 changes: 18 additions & 0 deletions examples/nest/prisma/schema.prisma
Original file line number Diff line number Diff line change
@@ -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?
}
Loading

0 comments on commit ba30312

Please sign in to comment.