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
asyncEngine is a factory function, not an engine: call it to get one. Install the async
state provider first — without one, asyncEngine() returns { error } instead of an engine.
import { asyncEngine, globalState, governors } from 'tods-competition-factory';
import asyncGlobalState from './asyncGlobalState'; // your copy of the reference implementation
// Once, at startup: install the provider BEFORE any asyncEngine() call
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 engine = asyncEngine(); // binds a fresh instance state to this request's context
if (engine.error) return { error: engine.error };
engine.importMethods(governors, true, 1);
const tournamentRecord = await loadTournament(req.params.id);
engine.setState(tournamentRecord);
const outcome = await engine.addEvent({ event: req.body.event }); // imported methods return promises
if (!outcome.error) await saveTournament(engine.getTournament().tournamentRecord);
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 { globalState, tournamentEngine } from 'tods-competition-factory';
const { setSubscriptions } = globalState;
// One call registers a map of topic -> handler. Each handler receives the notice ARRAY for its
// topic, and runs after the engine method completes rather than during it.
setSubscriptions({
addMatchUps: (payload) => {
console.log('MatchUps added:', payload);
// Update UI, trigger webhooks, etc.
},
modifyMatchUp: (payload) => {
console.log('MatchUp modified:', payload);
},
});
// Now mutations notify subscribers
tournamentEngine.generateDrawDefinition({/* ... */});
// Triggers the addMatchUps subscription
For multiple handlers per topic, unsubscribe closures and a promise-based waitFor, use the typed
event bus — engine.on / once / off / waitFor. See Subscriptions.
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 { globalState, tournamentEngine } from 'tods-competition-factory';
import { broadcastToWebSocketClients } from './websocket';
const { setSubscriptions } = globalState;
// Broadcast score changes to connected clients
setSubscriptions({
modifyMatchUp: (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
rollbackOnError snapshots the records before a call or a queue and restores them if it returns an
error. Its job is multi-directive atomicity: making several directives succeed or fail together.
It is not what makes a single method's error clean. A method that returns an error leaves the
record unchanged on a direct call, with or without the flag — each method is responsible for
refusing before it writes. Where a method still returns an error after writing, that is a defect: the
known cases are tracked, and the exit-propagation census property ERROR_IMPLIES_NO_MUTATION
(measured on direct calls) polices it. Do not pass rollbackOnError as the fix for one.
Basic Rollback
API Reference: automatedPositioning
Engine methods do not throw on failure: an error is returned as result.error. (Only a
truthy devContext lets an exception escape; with
rollbackOnError the state is restored before it does.) Check the result rather than wrapping the call
in try/catch.
A rollback restores more than the records: the selected tournament, the buffered notices (a refused
write's notices are never delivered; an enclosing operation's are kept) and the modification flag.
When the state cannot be snapshotted faithfully, a call with rollbackOnError is refused before it
writes anything, with ROLLBACK_UNAVAILABLE.
import { tournamentEngine } from 'tods-competition-factory';
tournamentEngine.setState(tournamentRecord);
const result = tournamentEngine.automatedPositioning({
drawId: 'draw-1',
rollbackOnError: true, // accepted on any method; restores from a snapshot on { error }
});
if (result.error) {
// the refusal is the thing to surface; the records are as they were before the call
console.error('Operation refused:', result.error);
}
Transaction Pattern
API Reference: generateDrawDefinition
rollbackOnError on separate calls is not one transaction: each call restores only its own
changes, so a failure in the third call leaves the first two applied.
The same holds inside an executionQueue submitted without rollback: when a directive fails, the writes of the
directives before it stay in the records, and no notices are delivered for any of them, then or on any later call. The
record is stamped as modified at the queue's own instant, and the result carries rolledBack: false and results, the
directives that were applied before the refusal. A caller that persists
from notices, or that lent the engine its own objects (setDeepCopy(false)), must then discard the in-memory records
— for example by aborting its database transaction — because they no longer match what was announced. To make several mutations
succeed or fail together, submit them as one executionQueue with rollback enabled — the second
argument — and check the result:
tournamentEngine.setState(tournamentRecord);
const result = tournamentEngine.executionQueue(
[
{ method: 'addEvent', params: { event } },
{ method: 'generateDrawDefinition', params: { eventId: event.eventId, drawSize: 32 } },
// `pipe` copies drawDefinition from the previous directive's result into these params
{ method: 'addDrawDefinition', params: { eventId: event.eventId }, pipe: { drawDefinition: true } },
{ method: 'attachPolicies', params: { policyDefinitions } },
],
true, // rollbackOnError: any { error } restores the records as they were before the queue
);
if (result.error) {
// nothing in the queue was kept; result.rolledBack is true
console.error('Transaction failed:', result.error);
} else {
// result is { success, results } — one entry per directive
const { tournamentRecord: updated } = tournamentEngine.getTournament();
await saveToDatabase(updated);
}
To restore by hand from a saved state, keep in mind that getState() returns a wrapper,
{ tournamentId, tournamentRecords }, not a record. Passing that wrapper back to setState loads it
as one malformed record. Load the records and the active tournament separately:
const state = tournamentEngine.getState();
// ... later
tournamentEngine.setState(state.tournamentRecords);
tournamentEngine.setTournamentId(state.tournamentId);
When to Use Rollback
Use rollbackOnError: true when:
- Several directives in one
executionQueuemust succeed or fail together - Earlier directives in a queue must not survive a later one's refusal
- A persistence layer writes the whole record after the queue and must not store a half-applied batch
It is not a substitute for a method's own guarantee: a single method's error leaves the record unchanged without it.
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 () => {
// Instantiate inside the scope: construction binds a fresh instance state to the context,
// so do it before setState or setSubscriptions, never after.
const engine = asyncEngine();
if (engine.error) return { error: engine.error };
engine.setState(req.tournament);
// methods registered globally above resolve by name in every request context
return engine.executionQueue([{ method: 'addEvent', params: { event: req.body } }], true);
});
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