Transact Write
Example Setup
Table Definition
{ "TableName": "electro", "KeySchema": [ { "AttributeName": "pk", "KeyType": "HASH" }, { "AttributeName": "sk", "KeyType": "RANGE" } ], "AttributeDefinitions": [ { "AttributeName": "pk", "AttributeType": "S" }, { "AttributeName": "sk", "AttributeType": "S" }, { "AttributeName": "gsi1pk", "AttributeType": "S" }, { "AttributeName": "gsi1sk", "AttributeType": "S" } ], "GlobalSecondaryIndexes": [ { "IndexName": "gsi1pk-gsi1sk-index", "KeySchema": [ { "AttributeName": "gsi1pk", "KeyType": "HASH" }, { "AttributeName": "gsi1sk", "KeyType": "RANGE" } ], "Projection": { "ProjectionType": "ALL" } } ], "BillingMode": "PAY_PER_REQUEST" }
In cases where you must keep multiple records in sync, enforce constraints across entities, and/or ensure request idempotency you can use ElectroDB’s Transact Get/Write methods. There are many articles and guides on use-cases for DynamoDB’s Transaction APIs, resource links can be found at the bottom of this document. This page will focus on how to use ElectroDB’s transaction API.
Performing Write Transactions
To perform write
transactions with ElectroDB you will first need to create a Service. Available on the Service
exist two methods: transaction.get()
and transaction.write()
. These methods accept a callback function, which is then provided with the entities of the service, should return an array of mutations. Creating mutations within a transaction is nearly identical to other mutations except instead of terminating your query with .go()
or .params()
you use .commit()
instead.
await yourService.transaction
.write(({ entity1, entity2 }) => [
entity1
.create({ prop1: "value1", prop2: "value2" })
.commit({ response: "all_old" }),
entity2
.update({ prop1: "value1", prop2: "value2" })
.set({ prop3: "value3" })
.commit({ response: "all_old" }),
])
.go();
When a transaction is canceled, due to conflict or other failure, ElectroDB will return information about the nature of the failure for each individual operation. By default, ElectroDB will return a reason for the failure if one exists, however if you provide the Execution Option { response: 'all_old' }
to the mutation .commit()
function, ElectroDB will also return the currently stored item on failure.
Mutations
The mutations available within a transaction are identical to the mutations available on all entities individually, with the addition of the method check
. Below are the mutations available on the injected entities and the corresponding DynamoDB parlance for each.
The
check
method exists only within a transaction. It is similar to aget
method, in that you must provide identifying attributes, but unlikeget
you can use thewhere
clause to apply a condition expression.
ElectroDB Name | DynamoDB Name |
---|---|
check | ConditionCheck |
delete | Delete |
remove | Delete |
put | Put |
create | Put |
upsert | Update |
update | Update |
patch | Update |
Response Format
Unlike the DocumentClient, if your transaction fails to write, ElectroDB will resolve and signal failure through a top-level boolean canceled
, and with additional detail for each operation provided in the transaction.
TransactionItem
For each operation provided in your transaction, you can expect the following interface to be returned, which is also exported for TypeScript users. ElectroDB will return an array of the same size as what was provided, with results for each operation in the same order as they were provided.
type TransactionItem<T> = {
item: null | T;
rejected: boolean;
code?: TransactionItemCode; // 'None' | 'ConditionalCheckFailed' | 'ItemCollectionSizeLimitExceeded' | 'TransactionConflict' | 'ProvisionedThroughputExceeded' | 'ThrottlingError' | 'ValidationError';
message?: string | undefined;
};
Property | Type | Description |
---|---|---|
item | EntityItem , null | When committing your mutation, if you use the execution option { response: 'all_old' } DynamoDB will include the targeted record as it exists in your table if the transaction fails. In the documentation, this param is called ReturnValuesOnConditionCheckFailure . |
code | TransactionItemCode | The code property is construct of DynamoDB, you can read the docs here to learn more. ElectroDB exports this type under the name TransactionItemCode . |
rejected | boolean | If your write was rejected, this value will be true . Because transactions are an all-or-nothing mutation, it only takes one rejection in a group to cancel the whole transaction. |
message | string , undefined | The message property is construct of DynamoDB, you can read the docs here to learn more. |
Returned
Calling the async .go()
method on a transaction returns the following interface. If your transaction failed, expect the canceled
boolean to be true. The array data
contains the results of each operation provided to the transaction in exactly the order it was provided.
{
data: TransactionItem < T > [];
canceled: boolean;
}
Examples
The following code creates two Entities, and a Service to join them, that will be used in our Transact Write examples. This Service
contains entities/information the top-secret British military intelligence organization: MI6.
Setup
For the examples below, use the following imports and dependencies at the top of your file.
Imports
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { Entity, Service } from 'electrodb';
const table = 'electro';
const client = new DynamoDBClient({});
Agent entity
The agent
entity models records for each MI6 agents and personnel
const agent = new Entity(
{
model: {
entity: "agent",
version: "1",
service: "MI6",
},
attributes: {
id: {
type: "string",
},
designation: {
type: "string",
},
email: {
type: "string",
required: true,
},
firstName: {
type: "string",
},
lastName: {
type: "string",
},
alive: {
type: "boolean",
required: true,
},
kills: {
type: "number",
default: 0,
},
},
indexes: {
operatives: {
pk: {
field: "pk",
composite: ["designation"],
},
sk: {
field: "sk",
composite: ["id"],
},
},
},
},
{ table, client },
);
Constraint entity
The constraint
entity is a utility available to all entities within the MI6
service. It can be used to enforce uniqueness for any property within a namespace.
// entity that owns unique constraints
const constraint = new Entity(
{
model: {
entity: "constraint",
version: "1",
service: "MI6",
},
attributes: {
name: {
type: "string",
required: true,
},
value: {
type: "string",
required: true,
},
entity: {
type: "string",
required: true,
},
},
indexes: {
value: {
pk: {
field: "pk",
composite: ["value"],
},
sk: {
field: "sk",
composite: ["name", "entity"],
},
},
name: {
index: "gsi1pk-gsi2sk-index",
pk: {
field: "gsi1pk",
composite: ["name", "entity"],
},
sk: {
field: "gsi1sk",
composite: ["value"],
},
},
},
},
{ table, client },
);
MI6 service
Services allow you to build namespaces with a single table, in this case we will create a service called mi6
with our two entities.
const mi6 = new Service({ constraint, agent });
Example - Unique Constraint
The following is an example of how you might implement a simple unique constraint mechanism to ensure a property (in this case email) remains unique across your service.
import { CreateEntityItem } from "electrodb";
type NewAgent = CreateEntityItem<typeof agent>;
async function createNewAgent(newAgent: NewAgent) {
return mi6.transaction
.write(({ agent, constraint }) => [
agent.create(newAgent).commit({ response: "all_old" }),
constraint
.create({
name: "email",
value: newAgent.email,
entity: agent.schema.model.entity,
})
.commit(),
])
.go();
}
Example - Idempotent writes
Architecting with idempotency in mind is one of the best ways to reduce complexity in your applications. The more you can reduce your app’s dependencies on timing and/or improve its tolerance for duplicative operations the better. Making use of DynamoDB’s atomic write capabilities for operations like incrementing numbers is a powerful tool when you need to manage changing state.
Unfortunately some operations, like incrementing a number, can easily cause our app to get out of sync. In failure scenarios where retrying or replaying is necessary, incrementing a number could result in duplicate operations. Transactions can be helpful in the situation by allowing you to provide a ClientRequestToken to ensure an operation is only performed once. At the time of writing, request tokens are valid for 10 minutes, though always consult the latest documentation for more information about this feature.
Below is an example of how you might use a ClientRequestToken
type IncrementAgentKillsOptions = {
id: string;
kills: number;
token: string;
designation: string;
};
async function incrementAgentKills(options: IncrementAgentKillsOptions) {
const { id, designation, kills, token } = options;
return mi6.transaction
.write(({ agent }) => [
agent.patch({ id, designation }).add({ kills }).commit(),
])
.go({ token });
}
Token
The token
(a ClientRequestToken
in DynamoDB parlance) should be unique for a given command. It can be helpful to use a deterministic value (like a composite string) to ensure it is always the same for a given command.
const token = "daily-headcount-count-2022-03-16";
Regardless of how many times incrementAgentKills
is called, so long as the value for token
remains the same, and you remain within DynamoDB’s timing window, your operation will not duplicate your incrementation. Using this functionality can greatly simply your retry logic in the case of failure.
// kills `0` -> `2`
await incrementAgentKills({
token,
id: "7",
kills: 2,
designation: "00",
});
// still results in `2`
await incrementAgentKills({
token,
id: "7",
kills: 2,
designation: "00",
});
Execution Options
The transaction itself has execution options but so does each call to
.commit()
within a transaction.
Execution options can be provided to the .params()
and .go()
terminal functions to change query behavior or add customer parameters to a query.
By default, ElectroDB enables you to work with records as the names and properties defined in the model. Additionally, it removes the need to deal directly with the docClient parameters which can be complex for a team without as much experience with DynamoDB. The Query Options object can be passed to both the .params()
and .go()
methods when building you query. Below are the options available:
{
token?: string;
}
Option | Default | Description |
---|---|---|
token | none | Adds the provided value as a ClientRequestToken along with your request to DynamoDB. |
Resources
The following are some links to help you understand the mechanics behind DynamoDB Transacts