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 a get method, in that you must provide identifying attributes, but unlike get you can use the where clause to apply a condition expression.

ElectroDB NameDynamoDB Name
checkConditionCheck
deleteDelete
removeDelete
putPut
createPut
upsertUpdate
updateUpdate
patchUpdate

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;
};
PropertyTypeDescription
itemEntityItem, nullWhen 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.
codeTransactionItemCodeThe code property is construct of DynamoDB, you can read the docs here to learn more. ElectroDB exports this type under the name TransactionItemCode.
rejectedbooleanIf 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.
messagestring, undefinedThe 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;
}
OptionDefaultDescription
tokennoneAdds 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