Mutation Engines
Mutation engines provide state-modifying operations with built-in change tracking, notifications, and error handling. They can operate synchronously or asynchronously depending on the execution context.
Key Features:
- Automatic mutation logging and audit trails
- Subscription-based notification system
- Rollback on error capabilities
- Asynchronous state management for multi-client scenarios
- Integration with middleware for automatic resolution
Synchronous vs Asynchronous Engines
Synchronous Engines
Use syncEngine for single-threaded, single-client applications:
import { tournamentEngine } from 'tods-competition-factory';
tournamentEngine.setState(tournamentRecord);
tournamentEngine.addEvent({ event: { eventName: 'Singles', eventType: 'SINGLES' } });
When to Use:
- Single-user desktop applications
- Command-line tools
- Test suites
- Simple server endpoints with isolated state per request
Asynchronous Engines
Use asyncEngine for multi-client server applications:
API Reference: addEvent
import { asyncEngine, globalState } from 'tods-competition-factory';
import asyncGlobalState from './asyncGlobalState';
// Configure async state provider once at startup
globalState.setStateProvider(asyncGlobalState);
// Each client request gets isolated state
app.post('/api/tournament/:id/event', async (req, res) => {
const result = await asyncGlobalState.runWithInstanceState(async () => {
const tournamentRecord = await loadTournament(req.params.id);
await asyncEngine.setState(tournamentRecord);
const outcome = await asyncEngine.addEvent({ event: req.body.event });
await saveTournament(asyncEngine.getState());
return outcome;
});
res.json(result);
});
When to Use:
- Multi-user web servers
- REST APIs serving multiple clients
- WebSocket servers with concurrent connections
- Any scenario with concurrent state modifications
State Isolation:
Async engines keep separate state per async execution context via AsyncLocalStorage,
preventing state collision between concurrent requests. Bind a fresh state to each
request with runWithInstanceState — see Asynchronous State Provider
for the provider contract and the failure modes it is designed around.
Notifications
Mutation engines emit notifications for state changes, enabling reactive updates across your application.
Subscribing to Notifications
import { tournamentEngine, addNotification } from 'tods-competition-factory';
// Subscribe to specific notification topics
addNotification({
topic: 'addMatchUps',
payload: (payload) => {
console.log('MatchUps added:', payload.matchUps);
// Update UI, trigger webhooks, etc.
},
});
addNotification({
topic: 'modifyMatchUp',
payload: (payload) => {
console.log('MatchUp modified:', payload.matchUp);
},
});
// Now mutations trigger notifications
tournamentEngine.generateDrawDefinition({/* ... */});
// Triggers 'addMatchUps' notification
Common Notification Topics
addMatchUps- New matchUps createdmodifyMatchUp- MatchUp properties changedpublishEvent- Event published/unpublisheddeletedMatchUpIds- MatchUps removedmodifyDrawDefinition- Draw structure changedaudit- Any mutation for audit trail
Real-World Example: Live Scoring Updates
API Reference: generateDrawDefinition
import { tournamentEngine, addNotification } from 'tods-competition-factory';
import { broadcastToWebSocketClients } from './websocket';
// Broadcast score changes to connected clients
addNotification({
topic: 'modifyMatchUp',
payload: (payload) => {
if (payload.matchUp.score) {
broadcastToWebSocketClients({
type: 'SCORE_UPDATE',
matchUpId: payload.matchUp.matchUpId,
score: payload.matchUp.score,
matchUpStatus: payload.matchUp.matchUpStatus,
});
}
},
});
// Recording a score triggers notification
tournamentEngine.setMatchUpStatus({
matchUpId: 'match-1',
outcome: {
score: {
sets: [
{ side1Score: 6, side2Score: 4 },
{ side1Score: 6, side2Score: 3 },
],
},
},
});
// WebSocket clients receive live update
See Subscriptions for complete notification documentation.
Rollback on Error
Protect tournament integrity by automatically reverting changes when operations fail.
Basic Rollback
API Reference: setMatchUpStatus
import { tournamentEngine } from 'tods-competition-factory';
tournamentEngine.setState(tournamentRecord);
try {
const result = await tournamentEngine.automatedPositioning({
drawId: 'draw-1',
rollbackOnError: true, // Enable automatic rollback
});
} catch (error) {
// State automatically reverted to pre-operation state
console.error('Operation failed, state rolled back:', error);
}
Transaction Pattern
API Reference: automatedPositioning
// Complex operation with multiple mutations
tournamentEngine.setState(tournamentRecord);
const originalState = tournamentEngine.getState();
try {
// Multiple operations that must all succeed
await tournamentEngine.addEvent({ event, rollbackOnError: true });
await tournamentEngine.generateDrawDefinition({ drawSize: 32, rollbackOnError: true });
await tournamentEngine.attachPolicy({ policyDefinitions, rollbackOnError: true });
// All succeeded, persist state
await saveToDatabase(tournamentEngine.getState());
} catch (error) {
// Any failure rolls back entire transaction
console.error('Transaction failed:', error);
tournamentEngine.setState(originalState);
}
When to Use Rollback
Use rollbackOnError: true when:
- Operating on production data
- Complex multi-step operations
- User-initiated actions that must be atomic
- Data integrity is critical
Skip rollback when:
- In test suites (let failures be visible)
- Debugging (you want to see the failed state)
- Bulk operations where partial success is acceptable
- Performance is critical and errors are rare
Global State Provider
Synchronous State (Default)
Synchronous engines maintain state in memory without special configuration:
API Reference: addEvent
import { tournamentEngine } from 'tods-competition-factory';
// No setup required for sync engines
tournamentEngine.setState(tournamentRecord);
tournamentEngine.addEvent({ event });
A sync engine holds one state for the whole process. That is correct for a client, where there is one user and one tournament in view. On a server handling concurrent requests it is not — every request would mutate the same records.
Asynchronous State Provider
For a server, supply a state provider that gives each request its own engine state.
A provider is not a getState/setState pair. It implements the full internal
state surface the engines call into — getTournamentRecords, setTournamentRecord,
addNotice, callListener, getMethods, and so on.
The provider is not exported from the package — only dist is published, under a
single "." entry. Copy the reference implementation into your own server and register
it at startup, which is exactly what competition-factory-server does:
// server.js
import { globalState, asyncEngine, governors } from 'tods-competition-factory';
import asyncGlobalState from './asyncGlobalState'; // your copy of the reference implementation
// Configure once at app startup
globalState.setStateMethods(governors, /* traverse */ true, /* depth */ 1, /* global */ true);
globalState.setStateProvider(asyncGlobalState);
The provider is built on AsyncLocalStorage, which propagates deterministically across
every await shape. Bind a fresh state per request by wrapping the request in
runWithInstanceState — the store is scoped to the callback, so it cannot outlive the
request or bleed into a sibling:
app.post('/api/event', async (req, res) => {
const result = await asyncGlobalState.runWithInstanceState(async () => {
await asyncEngine.setState(req.tournament);
return asyncEngine.addEvent({ event: req.body });
});
res.json(result);
});
If engine state is touched outside runWithInstanceState, the provider lazily binds a
new state to the current async context and logs a warning — it never falls back to a
shared default and never throws.
That is a safety net, not isolation. Unwrapped siblings launched from a common
parent context still share state, because the first access binds a store to that shared
parent which the sibling then inherits. asyncGlobalState.implicitContextCreations()
returns how many times state was created implicitly; treat a non-zero count in
production as an unwrapped entry point to find, not as noise.
Two shapes were tried against real server traffic and rejected — do not reimplement them:
getStore() || {}— returns a throwaway object when no context is bound. Reads and writes silently go nowhere, and it degrades to one shared process-wide state, which is the defect a provider exists to fix.- Throwing when no context is bound — assumes every entry point is statically
enumerable. It is not: governors are not uniformly pure, and
mocksGovernor.generateTournamentRecord()dispatches notices, so a direct governor call touches instance state without going near an engine. A strict throw trades a silent correctness bug for a loud outage.
An earlier implementation keyed state by executionAsyncId() with an init hook
copying the parent's entry. That propagation is call-shape dependent — it isolates
under one await shape and leaks under another. AsyncLocalStorage does not have
this failure mode. See competition-factory#4564.
Reference Implementation:
src/examples/asyncEngine/asyncGlobalState.ts in the source code, which re-exports the
single implementation used by competition-factory-server.
Debugging and Logging
Enable detailed logging for debugging and monitoring:
import { tournamentEngine, globalState } from 'tods-competition-factory';
// Enable detailed logging
globalState.setDevContext({
errors: true, // Log errors
params: true, // Log method parameters
result: true, // Log method results
perf: 100, // Log methods taking >100ms
});
tournamentEngine.setState(tournamentRecord);
tournamentEngine.addEvent({ event: { eventName: 'Singles' } });
// Console: [addEvent] params: {...} result: {...} time: 5ms
tournamentEngine.generateDrawDefinition({ drawSize: 32 });
// Console: [generateDrawDefinition] params: {...} result: {...} time: 25ms
Dev Context Options:
errors: true- Log all errorsparams: true | ['methodName']- Log parameters for all or specific methodsresult: true | ['methodName']- Log results for all or specific methodsperf: number- Log methods exceeding threshold (ms)exclude: ['methodName']- Exclude specific methods from logging