Using Transactions
This guide shows you how to use transactions for atomic multi-step operations that either fully succeed or fully roll back.
Prerequisites
Before starting, you should:
Overview
We'll cover:
- Basic transaction usage
- Atomic entity creation
- Multi-entity updates
- Handling rollbacks
- Returning values from transactions
Step 1: Basic Transaction Usage
Wrap multiple operations in a transaction callback. If any operation fails, all changes roll back.
import { createECS, defineComponent } from '@motioneffector/ecs'
import { createDatabase } from '@motioneffector/sql'
const Position = defineComponent('Position', { x: 'number', y: 'number' })
const Health = defineComponent('Health', { current: 'number', max: 'number' })
const db = await createDatabase()
const ecs = createECS(db, [Position, Health])
await ecs.initialize()
// All operations inside succeed or fail together
await ecs.transaction(async (ecs) => {
const entity = ecs.createEntity()
ecs.addComponent(entity, Position, { x: 0, y: 0 })
ecs.addComponent(entity, Health, { current: 100, max: 100 })
})
Step 2: Atomic Entity Creation
Ensure an entity is fully set up or not created at all:
async function createPlayer(name: string) {
return await ecs.transaction(async (ecs) => {
const player = ecs.createEntity(`player-${name}`)
// If any of these fail, the entity is also rolled back
ecs.addComponent(player, Position, { x: 0, y: 0 })
ecs.addComponent(player, Health, { current: 100, max: 100 })
ecs.addComponent(player, Inventory, { capacity: 20, items: [] })
ecs.addComponent(player, PlayerName, { name })
return player
})
}
// Usage
const playerId = await createPlayer('Alice')
This prevents "orphan" entities that exist but lack required components.
Step 3: Multi-Entity Updates
Transfer resources between entities atomically:
const Inventory = defineComponent('Inventory', {
items: 'json'
})
async function transferItem(
fromEntity: string,
toEntity: string,
itemId: string
) {
await ecs.transaction(async (ecs) => {
const fromInv = ecs.getComponent(fromEntity, Inventory)
const toInv = ecs.getComponent(toEntity, Inventory)
if (!fromInv || !toInv) {
throw new Error('Both entities must have Inventory')
}
// Find the item
const itemIndex = fromInv.items.findIndex(i => i.id === itemId)
if (itemIndex === -1) {
throw new Error('Item not found in source inventory')
}
const item = fromInv.items[itemIndex]
// Remove from source
const newFromItems = [...fromInv.items]
newFromItems.splice(itemIndex, 1)
ecs.updateComponent(fromEntity, Inventory, { items: newFromItems })
// Add to destination
const newToItems = [...toInv.items, item]
ecs.updateComponent(toEntity, Inventory, { items: newToItems })
// If either update fails, both roll back
})
}
Step 4: Handle Rollbacks
When a transaction fails, catch the error and handle it:
try {
await ecs.transaction(async (ecs) => {
const entity = ecs.createEntity()
ecs.addComponent(entity, Position, { x: 0, y: 0 })
// This will fail - entity already has Position
ecs.addComponent(entity, Position, { x: 100, y: 100 })
})
} catch (error) {
console.log('Transaction failed:', error.message)
// Entity was NOT created - rolled back
}
// Verify rollback
const allEntities = ecs.query([])
console.log(allEntities.length) // 0 - nothing was created
You can also intentionally trigger rollbacks:
await ecs.transaction(async (ecs) => {
// Do some work...
const entities = ecs.query([Position])
// Check a condition
if (entities.length > 1000) {
throw new Error('Too many entities!') // Intentional rollback
}
// Continue if condition passes...
})
Step 5: Return Values
Transactions can return values:
const result = await ecs.transaction(async (ecs) => {
const entity = ecs.createEntity()
ecs.addComponent(entity, Position, { x: 0, y: 0 })
ecs.addComponent(entity, Health, { current: 100, max: 100 })
// Return data from the transaction
return {
entityId: entity,
initialHealth: 100
}
})
console.log(`Created ${result.entityId} with ${result.initialHealth} HP`)
Complete Example
import { createECS, defineComponent } from '@motioneffector/ecs'
import { createDatabase } from '@motioneffector/sql'
const Position = defineComponent('Position', { x: 'number', y: 'number' })
const Health = defineComponent('Health', { current: 'number', max: 'number' })
const Gold = defineComponent('Gold', { amount: 'number' })
const db = await createDatabase()
const ecs = createECS(db, [Position, Health, Gold])
await ecs.initialize()
// Create two players with gold
const player1 = await ecs.transaction(async (ecs) => {
const id = ecs.createEntity('player-1')
ecs.addComponent(id, Position, { x: 0, y: 0 })
ecs.addComponent(id, Gold, { amount: 100 })
return id
})
const player2 = await ecs.transaction(async (ecs) => {
const id = ecs.createEntity('player-2')
ecs.addComponent(id, Position, { x: 10, y: 10 })
ecs.addComponent(id, Gold, { amount: 50 })
return id
})
// Transfer gold atomically
async function transferGold(from: string, to: string, amount: number) {
await ecs.transaction(async (ecs) => {
const fromGold = ecs.getComponent(from, Gold)
const toGold = ecs.getComponent(to, Gold)
if (!fromGold || !toGold) {
throw new Error('Both players need Gold component')
}
if (fromGold.amount < amount) {
throw new Error('Insufficient gold')
}
ecs.updateComponent(from, Gold, { amount: fromGold.amount - amount })
ecs.updateComponent(to, Gold, { amount: toGold.amount + amount })
})
}
// Transfer 30 gold from player1 to player2
await transferGold(player1, player2, 30)
// Check balances
console.log(ecs.getComponent(player1, Gold)) // { amount: 70 }
console.log(ecs.getComponent(player2, Gold)) // { amount: 80 }
// Try invalid transfer (should fail and rollback)
try {
await transferGold(player1, player2, 1000)
} catch (e) {
console.log('Transfer failed:', e.message) // "Insufficient gold"
}
// Balances unchanged after failed transfer
console.log(ecs.getComponent(player1, Gold)) // { amount: 70 }
console.log(ecs.getComponent(player2, Gold)) // { amount: 80 }
Variations
Batch Entity Creation
async function createEnemyWave(count: number, spawnPoint: { x: number, y: number }) {
await ecs.transaction(async (ecs) => {
for (let i = 0; i < count; i++) {
const enemy = ecs.createEntity()
ecs.addComponent(enemy, Position, {
x: spawnPoint.x + Math.random() * 50,
y: spawnPoint.y + Math.random() * 50
})
ecs.addComponent(enemy, Health, { current: 30, max: 30 })
}
})
}
Conditional Operations
await ecs.transaction(async (ecs) => {
const health = ecs.getComponent(entityId, Health)
if (health && health.current <= 0) {
// Entity is dead, clean it up
ecs.destroyEntity(entityId)
} else if (health) {
// Apply damage
ecs.updateComponent(entityId, Health, {
current: Math.max(0, health.current - damage)
})
}
})
Troubleshooting
Transaction Keeps Rolling Back
Symptom: Your transaction always fails.
Cause: An operation inside is throwing an error.
Solution: Add try/catch inside to log the specific error, or check each operation's preconditions.
Changes Visible Outside Transaction
Symptom: Partial changes appear even when transaction should rollback.
Cause: You're reading state outside the transaction while it's running.
Solution: All reads and writes for the atomic operation should be inside the transaction callback.
See Also
- Transactions Concept - Understanding transactions
- Advanced API - transaction() reference
- Components - Component operations