Skip to content

Adapter: Transaction auto upgrade

Eugene Lazutkin edited this page Apr 23, 2026 · 1 revision

Adapter: Transaction auto-upgrade

When hooks.checkConsistency(batch) returns BatchDescriptor[] (even an empty array), the Adapter upgrades a single CRUD op to a TransactWriteItems call covering the main op plus the consistency actions.

See also (AWS JS SDK v3): TransactWriteCommand · TransactWriteItems API · Condition expressions. Vocabulary: Concepts.

Why this exists

Partial writes — especially patches — touch only a subset of an item's fields. That raises an obvious worry: does the resulting item still honor the invariants it was supposed to? Referential integrity (the parent row still exists), optimistic locking (the revision hasn't moved), quota enforcement (the tenant's row count is still under the cap), tenant scoping (the row actually belongs to the caller's tenant) — every one of these is a rule whose answer depends on other rows in the database.

The naive approach reads the other rows back to the client, evaluates the invariant in JavaScript, then writes. Two round-trips, and a race window between the read and the write where the invariant could silently become false.

Transaction auto-upgrade inverts the flow. checkConsistency(batch) returns ConditionCheck descriptors that DynamoDB evaluates server-side, atomically with the write, inside a single TransactWriteItems. All checks pass → the write commits. Any check fails → the whole transaction is rejected, the database is unchanged, and the caller sees a single TransactionCanceledException. No invariant data ever leaves the server. This is the canonical shape for invariant enforcement in DynamoDB apps that use the toolkit.

Flow

  1. CRUD method (post / put / patch / delete) builds a BatchDescriptor via the matching make* builder.
  2. await hooks.checkConsistency(batch) runs.
  3. If the result is null or undefined, dispatch as a single PutCommand / UpdateCommand / DeleteCommand.
  4. If the result is an array, bundle the main op + checks into a single TransactWriteCommand.
  5. If the combined batch exceeds TRANSACTION_LIMIT (100), throw TransactionLimitExceededError.

Example: parent existence check

const adapter = new Adapter({
  client: docClient,
  table: 'planets',
  keyFields: ['name'],
  hooks: {
    async checkConsistency(batch) {
      // Only relevant for writes that include a parent reference
      if (batch.action !== 'put' && batch.action !== 'patch') return null;
      const item = batch.params.Item || {};
      if (!item.parent) return null;

      // Demand the parent exists before this write succeeds
      return [{
        action: 'check',
        params: {
          TableName: this.table,
          Key: {name: item.parent},
          ConditionExpression: 'attribute_exists(#k0)',
          ExpressionAttributeNames: {'#k0': 'name'}
        }
      }];
    }
  }
});

// Auto-upgraded: dispatched as TransactWriteItems with [check, put]
await adapter.post({name: 'Mustafar', parent: 'Sol'});

// No parent → not upgraded; dispatched as plain Put
await adapter.post({name: 'Sol'});

Why the ConditionExpression is a raw string here. The toolkit ships buildCondition for composing a ConditionExpression against a single params object — the builder walks a ConditionClause[] tree and mutates ConditionExpression + ExpressionAttributeNames / Values on that one object. Inside checkConsistency you are building a descriptor for a 'check' action that will become one of several TransactWriteItems, each with its own params. The raw-string form is the idiom here because you're populating a fresh params with exactly one condition; buildCondition carries the overhead of merging into an existing expression without an upside at this call site. If the condition gets complex (multiple clauses, path traversal), you can still call buildCondition — just pass {} as the params argument and copy the result into the descriptor.

Returning an empty array

checkConsistency: async () => []

This still upgrades to TransactWriteItems, with just the main op as a single TransactItem. Useful when you want every write to have transactional semantics for observability or downstream consumers.

Limits

  • DynamoDB caps TransactWriteItems at 100 actions per call. The Adapter throws TransactionLimitExceededError (with .actionCount) when the combined main + checks would exceed it.
  • Consistency actions are by definition supposed to be atomic with the main op — silently splitting them across transactions would defeat the purpose.
import {TransactionLimitExceededError} from 'dynamodb-toolkit';

try {
  await adapter.put(item);
} catch (e) {
  if (e instanceof TransactionLimitExceededError) {
    console.error(`Transaction would have ${e.actionCount} actions; cap is 100`);
  }
  throw e;
}

Single-table-design alternative

For consumers building single-table apps where every write fans out into many Put/Update/Delete actions, the auto-upgrade pattern lets you keep the high-level CRUD surface while the consistency hook handles the fan-out. For graphs of related items, dynamodb-onetable and electrodb are dedicated tools.

Clone this wiki locally