Kura
Early Access: Documentation is actively evolving and may change without notice.

Transactions

Atomic operations with raw-query and builder-friendly transaction APIs.

KuraDB provides two transaction styles:

  • kura.db.transaction for imperative transaction callbacks
  • kura.db.raw.transaction for declarative lists of raw SQL queries

The imperative callback now supports both:

  • the original raw query(statement, values) function
  • a second tx argument with transaction-scoped builders

Preferred Lua style

Use kura.db.transaction.await(...) when you want an awaitable transaction flow:

local transfer = kura.db.transaction.await(function(query, tx)
  local accounts = kura.db.tables.accounts

  local from = tx.select({ accounts.id, accounts.balance })
    :from(accounts)
    :where(op.eq(accounts.id, fromId))
    :forUpdate()
    :single()
    :await()

  local to = tx.select({ accounts.id, accounts.balance })
    :from(accounts)
    :where(op.eq(accounts.id, toId))
    :forUpdate()
    :single()
    :await()

  if from.balance < amount then
    return false
  end

  tx.update(accounts)
    :set({ balance = from.balance - amount })
    :where(op.eq(accounts.id, fromId))
    :await()

  tx.update(accounts)
    :set({ balance = to.balance + amount })
    :where(op.eq(accounts.id, toId))
    :await()

  return {
    fromBalance = from.balance - amount,
    toBalance = to.balance + amount,
  }
end)

The imperative callback uses payload-or-false return semantics:

  • return false rolls the transaction back and returns false
  • return nil commits the transaction and returns true
  • return <payload> commits the transaction and returns that payload

accounts is just an example table. The generated table names available inside kura.db.tables always come from your own schema.ts.

forUpdate requires tx

:forUpdate() is only allowed on builders created from the transaction-scoped tx object. Calling kura.db.select():forUpdate() outside a transaction raises an error so row locks are not used accidentally in non-transactional code.

Backwards-compatible callback transactions

The original callable style still works:

local result = kura.db.transaction(function(query)
  local rows = query(
    'SELECT balance FROM "public"."accounts" WHERE id = $1',
    { fromId }
  )

  if rows[1].balance < amount then
    return false
  end

  query('UPDATE "public"."accounts" SET balance = balance - $1 WHERE id = $2', { amount, fromId })
  query('UPDATE "public"."accounts" SET balance = balance + $1 WHERE id = $2', { amount, toId })
  return { transferred = amount }
end)

If you want builder ergonomics, accept the second tx argument:

local result = kura.db.transaction(function(query, tx)
  local accounts = kura.db.tables.accounts

  local row = tx.select({ accounts.id, accounts.balance })
    :from(accounts)
    :where(op.eq(accounts.id, fromId))
    :single()
    :await()

  return row ~= nil
end)

If you do not need a payload, simply omit the return value and the transaction will commit with true:

local ok = kura.db.transaction.await(function(query, tx)
  local accounts = kura.db.tables.accounts

  tx.update(accounts)
    :set({ lastSeenAt = os.time() })
    :where(op.eq(accounts.id, accountId))
    :await()
end)

Declarative raw transactions

Use kura.db.raw.transaction.await(...) to pass a list of raw SQL queries that should all succeed or all fail:

local ok = kura.db.raw.transaction.await({
  {
    query = 'UPDATE "public"."accounts" SET balance = balance - $1 WHERE id = $2',
    values = { amount, fromId },
  },
  {
    query = 'UPDATE "public"."accounts" SET balance = balance + $1 WHERE id = $2',
    values = { amount, toId },
  },
})

What the transaction builder supports

Inside the tx object you can currently use:

  • tx.select(...)
  • tx.insert(...)
  • tx.update(...)
  • tx.delete(...)

Those builders compile SQL the same way as normal kura.db.* builders, but execute against the current transaction connection instead of the global pool.

TypeScript

import { kuradb } from '@your-scope/kuradb';

const transfer = await kuradb.startTransaction(async (query) => {
  const rows = await query(
    'SELECT balance FROM "public"."accounts" WHERE id = $1',
    [fromId]
  );

  if (rows[0].balance < amount) return false;

  await query('UPDATE "public"."accounts" SET balance = balance - $1 WHERE id = $2', [amount, fromId]);
  await query('UPDATE "public"."accounts" SET balance = balance + $1 WHERE id = $2', [amount, toId]);
  return { amount };
});

kuradb.startTransaction(...) follows the same contract as Lua:

  • return false rolls back and resolves to false
  • return undefined commits and resolves to true
  • return payload commits and resolves to that payload

Transaction options

Both transaction APIs accept an options table:

kura.db.transaction.await(function(query, tx)
  return true
end, {
  isolationLevel = 'SERIALIZABLE',
  readOnly = false,
})
OptionTypeDescription
isolationLevelstringREAD COMMITTED, REPEATABLE READ, READ UNCOMMITTED, SERIALIZABLE
readOnlybooleanRead-only transaction
deferrablebooleanDeferrable transaction, typically with SERIALIZABLE

Current limitations

The transaction builder does not yet expose PostgreSQL-specific expression helpers such as increment, decrement, JSON path updates, or generic SQL fragments. For advanced write expressions inside a transaction, use the raw query(...) callback directly.

On this page