-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #249 from kitsuyui/intended-rollback
Implement: @kitsuyui/intended-rollback
- Loading branch information
Showing
11 changed files
with
533 additions
and
173 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -22,6 +22,7 @@ jobs: | |
mymath, | ||
string, | ||
luxon-ext, | ||
intended-rollback | ||
] | ||
|
||
steps: | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
# @kitsuyui/intended-rollback | ||
|
||
Provide intended rollback feature for testing. | ||
|
||
## Installation | ||
|
||
### NPM | ||
|
||
```sh | ||
$ npm install @kitsuyui/intended-rollback | ||
``` | ||
|
||
### Yarn | ||
|
||
```sh | ||
$ yarn add @kitsuyui/intended-rollback | ||
``` | ||
|
||
### pnpm | ||
|
||
```sh | ||
$ pnpm add @kitsuyui/intended-rollback | ||
``` | ||
|
||
## Usage | ||
|
||
Following example is for [prisma](https://www.prisma.io/). But you can use this library for any other libraries. | ||
|
||
```typescript | ||
import { Prisma, PrismaClient } from '...' | ||
import { intendedRollback } from '@kitsuyui/intended-rollback' | ||
|
||
export const prismaIntendedRollback = async <T>( | ||
prisma: PrismaClient, | ||
rollback: boolean, | ||
innerBlock: (prisma: Prisma.TransactionClient) => Promise<T>, | ||
): Promise<Result<T>> => { | ||
const wrapped = wrapWithRollback<PrismaClient, Prisma.TransactionClient, T>( | ||
async (prisma, callback) => await prisma.$transaction(callback), | ||
) | ||
return await wrapped(prisma, rollback, innerBlock) | ||
} | ||
``` | ||
|
||
```typescript | ||
import { Prisma, PrismaClient } from '...' | ||
import { prismaIntendedRollback } from '...' | ||
|
||
const rollback = process.env.NODE_ENV === 'test' | ||
const result = await prismaIntendedRollback( | ||
prisma, | ||
rollback, | ||
async (prisma) => await createUser(prisma, { email, password }), | ||
) | ||
if (result.success) { | ||
const user = result.content | ||
console.log(`User created successfully. ID: ${user.id}`) | ||
if (result.rollback.intended) { | ||
console.log( | ||
'Rollback for testing purposes. No actual data changes were made.', | ||
) | ||
} | ||
} else { | ||
// When the error is not from this library, you can throw it. | ||
throw result.error | ||
} | ||
``` | ||
|
||
## License | ||
|
||
MIT |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
{ | ||
"name": "@kitsuyui/intended-rollback", | ||
"version": "0.0.0", | ||
"license": "MIT", | ||
"author": "Yui Kitsu <[email protected]>", | ||
"description": "Provide rollback for transactional operations", | ||
"scripts": { | ||
"build": "tsup src/index.ts --clean", | ||
"dev": "pnpm build --watch" | ||
}, | ||
"bin": { | ||
"ts-playground-main": "./dist/main.js" | ||
}, | ||
"exports": { | ||
".": { | ||
"import": "./dist/index.mjs", | ||
"require": "./dist/index.js", | ||
"types": "./dist/index.d.ts" | ||
} | ||
}, | ||
"main": "dist/index.js", | ||
"module": "dist/index.js", | ||
"types": "dist/index.d.ts", | ||
"files": [ | ||
"dist", | ||
"package.json" | ||
], | ||
"devDependencies": {} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
import { describe, it, expect, jest } from '@jest/globals' | ||
import { wrapWithRollback } from './base' | ||
import type { Result } from './types' | ||
|
||
|
||
class DummyClient { | ||
async transaction<T>(callback: (tran: DummyClientTransaction) => Promise<T>): Promise<T> { | ||
const tran = new DummyClientTransaction() | ||
return await tran.run(callback) | ||
} | ||
} | ||
|
||
class DummyClientTransaction { | ||
async run<T>(callback: (client: DummyClientTransaction) => Promise<T>): Promise<T> { | ||
try { | ||
const result = await callback(this) | ||
await this.commit() | ||
return result | ||
} catch (e) { | ||
await this.rollback() | ||
throw e | ||
} | ||
} | ||
async commit() {} | ||
async rollback() {} | ||
} | ||
|
||
|
||
const dummyClientIntendedRollback = async <T>( | ||
dummyClient: DummyClient, | ||
rollback: boolean, | ||
innerBlock: (tran: DummyClientTransaction) => Promise<T>, | ||
): Promise<Result<T>> => { | ||
const wrapped = wrapWithRollback<DummyClient, DummyClientTransaction, T>( | ||
async (dummyClient, callback) => await dummyClient.transaction(callback), | ||
) | ||
return await wrapped(dummyClient, rollback, innerBlock) | ||
} | ||
|
||
describe('dummyClientIntendedRollback', () => { | ||
it('should return success with rollback', async () => { | ||
const dummyClient = new DummyClient() | ||
const result = await dummyClientIntendedRollback(dummyClient, true, async (tran) => { | ||
return 'result' | ||
}) | ||
expect(result).toEqual({ | ||
success: true, | ||
content: 'result', | ||
rollback: { | ||
occurred: true, | ||
intended: true, | ||
}, | ||
}) | ||
}) | ||
it('should return success without rollback', async () => { | ||
const dummyClient = new DummyClient() | ||
const result = await dummyClientIntendedRollback(dummyClient, false, async (tran) => { | ||
return 'result' | ||
}) | ||
expect(result).toEqual({ | ||
success: true, | ||
content: 'result', | ||
rollback: { | ||
occurred: false, | ||
intended: false, | ||
}, | ||
}) | ||
}) | ||
it('should return failure with rollback', async () => { | ||
const dummyClient = new DummyClient() | ||
const result = await dummyClientIntendedRollback(dummyClient, true, async (tran) => { | ||
throw new Error('error') | ||
}) | ||
expect(result).toEqual({ | ||
success: false, | ||
error: new Error('error'), | ||
rollback: { | ||
occurred: true, | ||
intended: false, | ||
}, | ||
}) | ||
}) | ||
it('should return failure without rollback', async () => { | ||
const dummyClient = new DummyClient() | ||
const result = await dummyClientIntendedRollback(dummyClient, false, async (tran) => { | ||
throw new Error('error') | ||
}) | ||
expect(result).toEqual({ | ||
success: false, | ||
error: new Error('error'), | ||
rollback: { | ||
occurred: true, | ||
intended: false, | ||
}, | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
import type { | ||
Result, TranInnerFn, TranOuterFn, WrappedTransactionalFn | ||
} from './types' | ||
import { | ||
IntendedRollback, Unreachable | ||
} from './errors' | ||
|
||
/** | ||
* Perform intended rollback (used when you want to rollback without actually changing data, such as in tests) | ||
* @param outer Function that executes the transaction in provided callback | ||
* @returns {WrappedTransactionalFn<TClient, TClientTran, TContent>} Function that wrapped the transaction with rollback handling | ||
*/ | ||
export const wrapWithRollback = <TClient, TClientTran, TContent>( | ||
outer: TranOuterFn<TClient, TClientTran, TContent>, | ||
): WrappedTransactionalFn<TClient, TClientTran, TContent> => { | ||
const wrapped = async ( | ||
client: TClient, | ||
rollback: boolean, | ||
func: TranInnerFn<TClientTran, TContent>, | ||
): Promise<Result<TContent>> => | ||
await handleRollback(client, outer, func, rollback) | ||
return wrapped | ||
} | ||
|
||
/** | ||
* Handle rollback | ||
* @param client Client instance (i.e. database client like PrismaClient) | ||
* @param outer Function that executes the transaction in provided callback | ||
* @param func Function that performs inside the transaction | ||
* @param rollback Whether to rollback the transaction | ||
* @returns Result of the transaction | ||
*/ | ||
const handleRollback = async <TClient, TClientTran, TContent>( | ||
client: TClient, | ||
outer: TranOuterFn<TClient, TClientTran, TContent>, | ||
func: TranInnerFn<TClientTran, TContent>, | ||
rollback: boolean, | ||
): Promise<Result<TContent>> => { | ||
let content: TContent | null = null | ||
try { | ||
content = await outer(client, async (something) => { | ||
const content_ = await func(something) | ||
// keep the content prepared for intended rollback | ||
content = content_ | ||
if (rollback) { | ||
throw new IntendedRollback() | ||
} | ||
return content_ | ||
}) | ||
return { | ||
success: true, | ||
content, | ||
rollback: { | ||
occurred: false, | ||
intended: false, | ||
}, | ||
} | ||
} catch (e) { | ||
if (e instanceof IntendedRollback) { | ||
if (content !== null) { | ||
return { | ||
success: true, | ||
content, | ||
rollback: { | ||
occurred: true, | ||
intended: true, | ||
}, | ||
} | ||
} | ||
throw new Unreachable('Intended rollback without content.') | ||
} | ||
return { | ||
success: false, | ||
error: e, | ||
rollback: { | ||
occurred: true, | ||
intended: false, | ||
}, | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
/** | ||
* IntendedRollback as an error | ||
* Error is needed for jumping to the catch block | ||
*/ | ||
export class IntendedRollback extends Error { | ||
constructor() { | ||
super('Intended Rollback') | ||
} | ||
} | ||
|
||
/** | ||
* Unreachable error | ||
*/ | ||
export class Unreachable extends Error { | ||
constructor(val: string) { | ||
super(`Unreachable: ${val}`) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
export { wrapWithRollback } from './base' | ||
export { IntendedRollback, Unreachable } from './errors' | ||
export type { Result } from './types' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
interface ResultFailure { | ||
success: false | ||
error: unknown | ||
rollback: { | ||
occurred: true | ||
intended: false | ||
} | ||
} | ||
|
||
interface ResultSuccess<T> { | ||
success: true | ||
content: T | ||
rollback: { | ||
occurred: false | ||
intended: false | ||
} | ||
} | ||
|
||
interface ResultSuccessAndIntendedRollback<T> { | ||
success: true | ||
content: T | ||
rollback: { | ||
occurred: true | ||
intended: true | ||
} | ||
} | ||
|
||
export type Result<T> = | ||
| ResultFailure | ||
| ResultSuccess<T> | ||
| ResultSuccessAndIntendedRollback<T> | ||
|
||
export type TranInnerFn<TClientTran, TContent> = ( | ||
client: TClientTran, | ||
) => Promise<TContent> | ||
export type TranOuterFn<TClient, TClientTran, TContent> = ( | ||
client: TClient, | ||
callback: TranInnerFn<TClientTran, TContent>, | ||
) => Promise<TContent> | ||
export type WrappedTransactionalFn<TClient, TClientTran, TContent> = ( | ||
client: TClient, | ||
rollback: boolean, | ||
func: TranInnerFn<TClientTran, TContent>, | ||
) => Promise<Result<TContent>> |
Oops, something went wrong.