Transactions Updates for Data Connect

When using a database, you may need to run multiple operations (adding, updating, and/or deleting data) successfully - or none at all. Data Connect already supported transactions, and we’re excited to announce additional support for transactions where now, you can use queried data output from a previous step within a transaction. In other words, you can do the following within a transaction:

  1. Execute a series of operations
  2. Query for data
  3. Use queried data output for another series of operations

For this blog post, let’s say you’re creating a banking app and need to create a transaction log. The transaction log shows the bank account that money was taken from and put into, the amount transferred, and the running balance for both accounts after the transaction. The schema looks like this:

type BankAccount @table(key: "id") {
  id: UUID! @default(expr: "uuidV4()")
  balance: Int! @default(value: 0)
}

type BankingTransaction @table(key: "id") {
  id: UUID! @default(expr: "uuidV4()")
  to: UUID! # BankAccount id of the account receiving money
  balanceTo: Int! # balance after transaction for above account
  from: UUID! # BankAccount id of the account sending money
  balanceFrom: Int! # balance after transaction for above account
  amount: Int! # transferred amount
  timestamp: Timestamp!
}

Previously

You could execute a transaction on mutations by using the @transaction directive. These mutations are guaranteed to either fully succeed or fully fail, and if any of the fields within the transaction fails, the entire transaction is rolled back. From a client standpoint, any transaction failure behaves as if all fields had failed with a request error.

With the banking example, the app needs to ensure that the amount of money in one account decreases while the other increases in exactly the same (absolute) amount - and they can only both succeed. If either one fails, both fail. To do so, use a transaction where a) money gets taken out of one account and b) money gets put into another account. In the same transaction, we also need to query the balance of both accounts so we can log them later.

mutation TransferMoney(
  $from: UUID!
  $to: UUID!
  $amount: Int!
) @transaction {
  # Step 1: take money out from $from account
  takeMoney: bankAccount_update(id: $from,
    data: {
      balance_update: {dec: $amount}
    }
  )

  # Step 2: add money to $to account
  addMoney: bankAccount_update(id: $to,
    data: {
      balance_update: {inc: $amount}
    }
  )

  # Step 3: get balance for $to account
  balanceTo: query {
    bankAccount(id: $to) { balance }
  }

  # Step 4: get balance for $from account
  balanceFrom: query {
    bankAccount(id: $from) { balance }
  }
}

Once the entire transaction begins, it will only succeed if all steps complete successfully. Otherwise, it will fail and return execution errors.

Now that we have transferred money between the accounts, the app needs to create the transaction log. To do so, we’ll have to use the balanceTo and balanceFrom from the first mutation, which we have to get from client-side code:

export const handleGetBalanceToAndFrom = async (
  balanceFrom: int, balanceTo: int,
) => {
  try {
    const response = await transferMoney({ from, to, amount, time: Date.now() });
    const balanceFrom = response.data.balanceFrom
    const balanceTo = response.data.balanceTo
    // use balanceFrom and balanceTo
  } catch (error) {
    console.error("Error fetching balances:", error);
  }
};

With the balance of both both accounts, we can add an entry for logging purposes:

mutation AddBankingTransaction(
  $to: UUID!
  $balanceTo: Int!
  $from: UUID!
  $balanceFrom: Int!
  $amount: Int!) {
    bankingTransaction_insert(data: {
        to: $to,
        balanceTo: $balanceTo
        from: $from,
        balanceFrom: $balanceFrom
        amount: $amount
        timestamp_expr: "request.time"
    }
  )
}

New Updates

Taking a look at the example above: what if instead of needing to get the balances from two queries in the client code then passing it into another mutation, you can get the balances and directly use them to insert for the transactions log in the same transaction?

You can now do so, thanks to the new Data Connect updates where you can use information from one step inside a transaction in the next step.

The above example now becomes:

mutation TransferMoney(
  $from: UUID!
  $to: UUID!
  $amount: Int!
) @transaction {
  # Step 1: take money out from $from account
  takeMoney: bankAccount_update(id: $from,
    data: {
      balance_update: {dec: $amount}
    }
  )

  # Step 2: add money to $to account
  addMoney: bankAccount_update(id: $to,
    data: {
      balance_update: {inc: $amount}
    }
  )

  # Step 3: get balance for $to account
  balanceTo: query {
    bankAccount(id: $to) { balance }
  }

  # Step 4: get balance for $from account
  balanceFrom: query {
    bankAccount(id: $from) { balance }
  }

  # Step 5: add banking transaction information
  bankingTransaction_insert(data: {
    to: $to,
    balanceTo_expr: "response.balanceTo.bankAccount.balance"
    from: $from,
    balanceFrom_expr: "response.balanceFrom.bankAccount.balance"
    amount: $amount
    timestamp_expr: "request.time"
  })
}

Because you can now access and use data received from previous steps in the same transaction, all of this can be done with one transaction, simplifying the client-side code. This also makes the system more secure because before, the client could possibly manipulate logging information when sending both account balances back to the server.

In the example above, response is a new top-level binding in expressions that represents the partial response object, which includes all top-level mutation fields before the current one. We also support <field>_expr for most scalar types now, which can come in handy when you need a dynamic value from auth.token, a previous step, or computed.

Hide sensitive information with @redact

A potential security problem with the example before this new transactions feature is that the bank account amounts probably shouldn’t be exposed to the client - the client doesn’t need to know how much money is in either account.

To fix this problem, you can hide the information with @redact to remove it from the wire (and generated SDKs). In the example above, you can add @redact on the two query fields.

The above code becomes:

mutation TransferMoney(
  $from: UUID
  $to: UUID
  $amount: Int
) @transaction {
  # Steps 1 and 2 are the same 

  # Step 3: get balance for $to account
  balanceTo: query @redact {
    bankAccount(id: $to) { balance }
  }

  # Step 4: get balance for $from account
  balanceFrom: query @redact {
    bankAccount(id: $from) { balance }
  }

  # Step 5 is the same
  bankingTransaction_insert(data: {
    # other fields
    balanceTo_expr: "response.balanceTo.bankAccount.balance"
    balanceFrom_expr: "response.balanceFrom.bankAccount.balance"
  })
}

Even though the id field was redacted, the fields are still available in response for step 5. In other words, you can still use fields in later steps even if you use @redact on it.

Using response in @check

There’s another way to use response: in @check(expr: "...")! Combined with query { … } operations in mutations, you can achieve a lot more without any additional client-server roundtrips, and you can use it to build even more complex server-side logic. Best of all, these can happen within the same @transaction, so you still get the atomicity guarantees as you’d expect from a relational database.

For the banking example, the app should make sure the balance of the account sending money does not go negative. We can add an @check to ensure this:

mutation TransferMoney(
  $from: UUID
  $to: UUID
  $amount: Int
) @transaction {
  # Steps 1-3 are the same 

  # Step 4: get balance for $from account
  balanceFrom: query @check(
      expr: "response.balanceFrom.bankAccount.balance < 0", 
      message: "Balance is negative, not enough money to transfer!") 
      @redact {
    bankAccount($from) { balance }
  }

  # Step 5 is the same
}

As a side note, @check has access to response.balanceFrom because @check always runs after the step to which it’s attached.

Try it out!

These new features and improvements are designed to unlock new use cases and make Data Connect a more capable database service for your apps. We’re excited to see what you build with it, happy coding!