Skip to content

Commit

Permalink
feat: add TransactionManager (#7)
Browse files Browse the repository at this point in the history
Co-authored-by: Swain Molster <swain.molster@lifeomic.com>
  • Loading branch information
aecorredor and Swain Molster authored Aug 30, 2023
1 parent 3378eec commit f5c1e0c
Show file tree
Hide file tree
Showing 9 changed files with 458 additions and 58 deletions.
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -564,3 +564,62 @@ const condition = {
],
};
```

## Transactions

Transactions are supported via the `TransactionManager` class, which is nicely
integrated with the methods that the `DynamoTable` exposes.

### Usage

```typescript
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb';
import { DynamoTable } from '@lifeomic/dynamost';

const client = new DynamoDBClient({});
const tableClient = DynamoDBDocument.from(client)

const userTable = new DynamoTable(tableClient, /* user table definition */);
const membershipTable = new DynamoTable(tableClient, /* membership table definition */);
const transactionManager = new TransactionManager(client);

// Run any custom logic that requires a transaction inside the callback passed
// to "transactionManager.run". This was inspired by the sequelize transaction
// API. The callback (i.e. the transaction runner) should be synchronous. The
// reason for this is so that the compiler can catch incorrect uses of
// non-transactional methods.
await transactionManager.run((transaction) => {
// Write any custom logic here. Leverage transactional writes by passing in
// the transaction object to any of the DynamoTable methods that accept it.

const newUser = { id: 'user-1', name: 'John Doe' };
// This won't actually commit the write at this point. It'll gather all writes
// and execute all the callback's logic first. After that, it will try to
// commit all the write transactions at once.
userTable.putTransact(newUser, { transaction });

userTable.patchTransact(
{ id: 'user-2' },
{ set: { name: 'John Snow' } },
{
condition: {
equals: { name: 'John S.' },
},
transaction,
},
);

const newMembership = { userId: 'user-1', type: 'basic' };
// You can use multiple tables when executing a transaction.
membershipTable.putTransact(newMembership, { transaction });

// Some more custom logic, it can be anything as long as it's synchronous.
});
```

### Caveats

The `TransactionManager` currently only supports write transactions. Transaction
support can progressively be added to each of the methods inside `DynamoTable`
by passing in an optional `Transaction` parameter.
8 changes: 8 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,12 @@ module.exports = {
preset: 'ts-jest',
clearMocks: true,
collectCoverage: true,
coverageThreshold: {
global: {
statements: 66,
branches: 31,
lines: 65,
functions: 58,
},
},
};
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
},
"scripts": {
"build": "node build.js",
"lint": "eslint .",
"lint": "eslint . && tsc --noEmit",
"test": "jest"
},
"dependencies": {
Expand All @@ -22,7 +22,8 @@
"@types/async-retry": "^1.4.5",
"async-retry": "^1.3.3",
"lodash": "^4.17.21",
"p-map": "^4.0.0"
"p-map": "^4.0.0",
"type-fest": "^4.3.1"
},
"devDependencies": {
"@aws-sdk/client-dynamodb": "^3.363.0",
Expand Down
17 changes: 11 additions & 6 deletions src/dynamo-expressions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
QueryCommandInput,
UpdateCommandInput,
} from '@aws-sdk/lib-dynamodb';
import { SetRequired } from 'type-fest';

import { KeySchema } from './dynamo-table';

type BaseDynamoDBCondition<Entity> = {
Expand Down Expand Up @@ -314,12 +316,15 @@ export type SerializeUpdateParams<Entity> = {
export const serializeUpdate = <Entity>({
update,
condition,
}: SerializeUpdateParams<Entity>): Pick<
UpdateCommandInput,
| 'UpdateExpression'
| 'ConditionExpression'
| 'ExpressionAttributeNames'
| 'ExpressionAttributeValues'
}: SerializeUpdateParams<Entity>): SetRequired<
Pick<
UpdateCommandInput,
| 'UpdateExpression'
| 'ConditionExpression'
| 'ExpressionAttributeNames'
| 'ExpressionAttributeValues'
>,
'UpdateExpression'
> => {
const {
getSubjectRef,
Expand Down
113 changes: 113 additions & 0 deletions src/dynamo-table.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { z } from 'zod';

import { testUserTableName, useDynamoDB } from './test/utils/dynamodb';
import { DynamoTable } from './dynamo-table';
import { TransactionManager } from './transaction-manager';

const dynamo = useDynamoDB();

describe('DynamoTable', () => {
const UserSchema = z.object({
createdAt: z.string().datetime(),
account: z.string(),
id: z.string(),
});

it('can execute transactional writes', async () => {
const userTable = new DynamoTable(dynamo.documentClient, UserSchema, {
tableName: testUserTableName,
keys: { hash: 'id', range: 'createdAt' },
secondaryIndexes: {
'account-index': { hash: 'account', range: 'createdAt' },
},
});
const transactionManager = new TransactionManager(dynamo.documentClient);

expect.assertions(6);

const user = await userTable.put({
id: 'user-1',
account: 'account-1',
createdAt: new Date().toISOString(),
});

const fetchedUser = await userTable.get({
id: user.id,
});

expect(fetchedUser?.id).toBe(user.id);

await transactionManager.run((transaction) => {
userTable.putTransact(
{
id: 'user-2',
account: 'account-1',
createdAt: new Date().toISOString(),
},
{ transaction },
);

userTable.patchTransact(
{ id: user.id },
{
set: {
account: 'account-2',
},
},
{ transaction },
);
});

let [user1, user2] = await Promise.all([
userTable.get({ id: 'user-1' }),
userTable.get({ id: 'user-2' }),
]);

expect(user1?.account).toBe('account-2');
expect(user2?.account).toBe('account-1');

// Test that a failed transaction does not make any changes.
await expect(async () => {
await transactionManager.run((transaction) => {
// The first two actions should not go through because the patch on a
// non existent user will fail.
userTable.deleteTransact({ id: 'user-1' }, { transaction });

userTable.patchTransact(
{ id: 'user-2' },
{
set: {
account: 'account-3',
},
},
{ transaction },
);

// This is the operation that causes the transaction to fail.
userTable.patchTransact(
{ id: 'non-existent-user' },
{
set: {
account: 'account-2',
},
},
{ transaction },
);
});
}).rejects.toThrow(
new Error(
'Transaction cancelled, please refer cancellation reasons for specific reasons [None, None, ConditionalCheckFailed]',
),
);

[user1, user2] = await Promise.all([
userTable.get({ id: 'user-1' }),
userTable.get({ id: 'user-2' }),
]);

// Validate that the first user is still present, and that the second user
// still has the same account.
expect(user1?.account).toBe('account-2');
expect(user2?.account).toBe('account-1');
});
});
Loading

0 comments on commit f5c1e0c

Please sign in to comment.