All notable changes to DomoTactical-TS will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
New StoreTypeMapper class provides bidirectional mapping between type/class names and symbolic storage names. This enables storage abstraction and protects against class renaming.
Key Features:
- Single registration creates bidirectional mapping (type ↔ symbolic)
- Convention-based fallback (PascalCase ↔ kebab-case) when no mapping registered
- Works for both Entry types (events/commands) and State types (documents)
- Fluent API for chaining registrations
Usage:
import { StoreTypeMapper } from 'domo-tactical'
const mapper = StoreTypeMapper.instance()
// Register explicit bidirectional mappings
mapper
.mapping('AccountOpened', 'account-opened')
.mapping('FundsDeposited', 'funds-deposited')
.mapping('AccountSummary', 'account-summary')
// Convert type name to symbolic name (for writing)
mapper.toSymbolicName('AccountOpened') // 'account-opened'
// Convert symbolic name to type name (for reading)
mapper.toTypeName('account-opened') // 'AccountOpened'Convention-Based Fallback (no registration needed):
mapper.toSymbolicName('UserRegistered') // 'user-registered'
mapper.toTypeName('user-registered') // 'UserRegistered'
mapper.toSymbolicName('XMLParser') // 'xml-parser'
mapper.toTypeName('name') // 'Name'New Exports:
StoreTypeMapperclass fromdomo-tacticalanddomo-tactical/store
DefaultTextEntryAdapter and DefaultTextStateAdapter now automatically use StoreTypeMapper for bidirectional type name conversion:
toEntry()/toRawState(): Converts PascalCase type names to kebab-case symbolic names for storagefromEntry()/fromRawState(): Converts kebab-case symbolic names back to PascalCase type names for adapter lookup and upcasting
Why This Matters:
- Entries stored in the journal now use consistent kebab-case type names (e.g.,
user-registeredinstead ofUserRegistered) - States stored in the document store use kebab-case type names (e.g.,
account-stateinstead ofAccountState) - Schema evolution logic receives the proper PascalCase type name for adapter lookup
Custom Adapters:
Custom adapters are NOT required to use StoreTypeMapper. However, if you want consistent naming with the default adapters, you can use it:
class UserRegisteredAdapter extends DefaultTextEntryAdapter<UserRegistered> {
override toEntry(source: UserRegistered, streamVersion: number, metadata: Metadata): TextEntry {
// Map type name to symbolic name for storage (best practice)
const symbolicType = StoreTypeMapper.instance().toSymbolicName('UserRegistered')
return new TextEntry(
source.id(),
symbolicType, // 'user-registered'
2, // typeVersion
JSON.stringify({ ... }),
streamVersion,
JSON.stringify(metadata)
)
}
}The State class (and subclasses TextState, ObjectState, BinaryState) constructor signature has been simplified to align with how Entry works:
Before:
new TextState(id, stateType: Function, typeVersion, data, dataVersion, metadata?, symbolicType?)After:
new TextState(id, type: string, typeVersion, data, dataVersion, metadata?)Key Changes:
- The
typeparameter is now astringinstead of aFunction(class constructor) - The
symbolicTypeparameter has been removed - the adapter passes the type name directly - The
stateTypefield has been removed from State (it was never used)
Migration:
If you have custom StateAdapter implementations, update toRawState() to pass a string type:
// Before
return new TextState(id, AccountState, 2, data, stateVersion, metadata, 'account-state')
// After - pass type string directly (adapter decides the name)
return new TextState(id, 'account-state', 2, data, stateVersion, metadata)This change makes State consistent with Entry, where the adapter is fully responsible for deciding what type name to use (symbolic or concrete).
InMemoryDocumentStore now uses StateAdapterProvider for state serialization and deserialization, matching the pattern used by InMemoryJournal with EntryAdapterProvider.
Benefits:
- Consistent adapter pattern across Journal and DocumentStore
- Enables custom state adapters with schema evolution
- Uses
DefaultTextStateAdapteras fallback when no custom adapter registered
Impact:
- No breaking changes - default behavior unchanged
- Custom
StateAdapterimplementations are now applied duringread()andwrite()operations
The Bank example (examples/bank/bank.ts) now demonstrates StoreTypeMapper usage:
function registerTypeMappings(): void {
const typeMapper = StoreTypeMapper.instance()
// Source/Entry type mappings (domain events → journal entries)
typeMapper
.mapping('AccountOpened', 'account-opened')
.mapping('FundsDeposited', 'funds-deposited')
.mapping('FundsWithdrawn', 'funds-withdrawn')
.mapping('FundsRefunded', 'funds-refunded')
// State type mappings (documents → document store)
typeMapper
.mapping('AccountSummary', 'account-summary')
.mapping('TransactionHistory', 'transaction-history')
.mapping('BankStatistics', 'bank-statistics')
}- Added
StoreTypeMappersection todocs/DomoTactical.md - Updated import documentation to include
StoreTypeMapper - Added Bank example code showing type mapping registration
- Added Stream Evolution Patterns section to
docs/DomoTactical.md:- Stream Branching (Splitting): Soft delete + replay, truncate + continue patterns
- Stream Merging (Joining): Replay to new stream, redirect pattern (no data movement)
- Best practices table for different scenarios
- Metadata for traceability examples
New built-in supervisors with comprehensive error handling for different actor types, following the "let it crash" philosophy.
DefaultJournalSupervisor - For journal-backed actors (event/command sourced entities):
- Resume for concurrency conflicts (optimistic locking violations)
- Resume for business logic errors (validation, not found, insufficient funds)
- Restart for state corruption or internal consistency errors
- Resume for storage failures (external recovery by k8s, admins, etc.)
DefaultDocumentStoreSupervisor - For document store-backed actors (stateful entities, projections):
- Resume for storage failures (external recovery)
- Restart for serialization/schema/JSON errors
- Restart for state corruption
- Resume for concurrency conflicts
- Resume for business logic errors
Convenience Functions:
import {
defaultJournalSupervisor,
DEFAULT_JOURNAL_SUPERVISOR,
defaultDocumentStoreSupervisor,
DEFAULT_DOCUMENT_STORE_SUPERVISOR,
defaultProjectionSupervisor,
DEFAULT_PROJECTION_SUPERVISOR
} from 'domo-tactical'
// Create supervisors with standard names
defaultJournalSupervisor()
defaultDocumentStoreSupervisor()
defaultProjectionSupervisor()
// Create actors under these supervisors
const journal = stage().actorFor<Journal<string>>(
journalProtocol,
undefined,
DEFAULT_JOURNAL_SUPERVISOR
)
const projection = stage().actorFor<Projection>(
projectionProtocol,
undefined,
DEFAULT_PROJECTION_SUPERVISOR
)New Exports:
DefaultJournalSupervisorclassdefaultJournalSupervisor()functionDEFAULT_JOURNAL_SUPERVISORconstant ('default-journal-supervisor')DefaultDocumentStoreSupervisorclassdefaultDocumentStoreSupervisor()functionDEFAULT_DOCUMENT_STORE_SUPERVISORconstant ('default-document-store-supervisor')defaultProjectionSupervisor()functionDEFAULT_PROJECTION_SUPERVISORconstant ('default-projection-supervisor')
Note: defaultProjectionSupervisor() creates a DefaultDocumentStoreSupervisor with the name 'default-projection-supervisor', allowing users to create custom projection supervisors if needed.
Fixed a bug in SourcedEntity.ts where Applicable was always instantiated with null for state instead of passing the actual snapshot. This affected error handling in afterApplyFailed() when snapshots were present.
- Added comprehensive documentation for default supervisors in
docs/DomoTactical.md - Added directive decision tables showing error handling behavior
- Added "Note on Storage Failures" explaining Resume rationale for storage errors
- Added complete usage examples for supervisor creation
- Updated
afterApplyFailed()documentation to recommend using custom Supervisors
All TypeScript source files now use explicit .js extensions in imports for proper ECMAScript Module (ESM) compatibility. This enables the compiled JavaScript to run directly in Node.js without bundlers.
What Changed:
- All relative imports now include
.jsextensions (e.g.,import { Foo } from './Foo.js') - Directory imports now use explicit
/index.jspaths (e.g.,import { Bar } from './store/index.js') - Updated
domo-actorsdependency to^1.2.0(which also has ESM fixes)
Why This Matters:
- Compiled JavaScript now works with direct
nodeexecution - No longer requires bundlers (webpack, esbuild, etc.) or tsx for ESM resolution
- Better compatibility with modern Node.js ESM requirements
No Breaking Changes:
- Import paths in your code remain the same (e.g.,
import { EventSourcedEntity } from 'domo-tactical') - Only internal imports were updated
New stream lifecycle management features based on EventStoreDB/KurrentDB patterns.
New Stream Operations:
journal.tombstone(streamName)- Permanently delete a stream (hard delete). Stream cannot be reopened.journal.softDelete(streamName)- Mark stream as deleted but allow reopening by appending.journal.truncateBefore(streamName, beforeVersion)- Hide events before a version.journal.streamInfo(streamName)- Get stream state information.
New Types:
StreamStateenum - Expected version states for optimistic concurrency:StreamState.Any(-2) - Skip version checkStreamState.NoStream(-1) - Expect stream doesn't existStreamState.StreamExists(-4) - Expect stream exists
StreamInfointerface - Stream state informationTombstoneResult- Result of tombstone operationsDeleteResult- Result of soft delete operationsTruncateResult- Result of truncate operations
Optimistic Concurrency:
Expected version validation is now enforced on all append operations:
- Concrete version (e.g.,
5) expects stream to be at version 4 StreamState.Anybypasses version checkingStreamState.NoStreamrequires stream to not existStreamState.StreamExistsrequires stream to have at least one event
EntryStream Enhancements:
isTombstonedflag - true if stream is permanently deletedisSoftDeletedflag - true if stream is soft-deletedisDeleted()method - true if either deleted flag is set- Static factory methods:
tombstoned(),softDeleted(),empty()
AppendResult Enhancements:
isConcurrencyViolation()- Check if append failed due to version mismatchisStreamDeleted()- Check if append failed because stream was deletedisSuccess()now returns false for concurrency violations and stream deleted
Result Enum:
- Added
Result.StreamDeletedvalue
Usage Examples:
// Tombstone (hard delete)
const result = await journal.tombstone('user-123')
if (result.isSuccess()) {
console.log('Stream permanently deleted')
}
// Soft delete
await journal.softDelete('order-456')
// Reopen by appending
await journal.append('order-456', nextVersion, event, metadata)
// Truncate old events
await journal.truncateBefore('account-789', 100)
// Check stream state
const info = await journal.streamInfo('stream-name')
if (info.isTombstoned) {
console.log('Stream was permanently deleted')
}
// Optimistic concurrency
const result = await journal.append('stream', StreamState.NoStream, event, metadata)
if (result.isConcurrencyViolation()) {
console.log('Stream already exists')
}New ContextProfile class provides context-scoped EntryAdapterProvider instances with a fluent registration API. This solves two problems:
- Boilerplate Reduction: Simple fluent API for registering Source types
- Test Isolation: Each context has its own adapter registry, avoiding singleton issues
New Classes:
ContextProfile- Context-scoped EntryAdapterProvider with fluent registration APIEntryRegistry- Simple global registry (delegates toContextProfile.forContext('default'))
New Types:
PropertyTransforms- Record type for property transformation functionsSourceTypeSpec- Configuration for a Source type with optional transformsContextSourceTypes- Configuration for context factory functions
New Source Date Utilities:
Source.asDate(value)- Static helper to convert number/string to Date (use as transform)source.dateSourced()- Instance method to get dateTimeSourced as Datesource.dateOf(propertyName)- Instance method to get any property as Date
Updated Context Functions:
eventSourcedContextFor(contextName, config?)- Now registers sources to context-specific EntryAdapterProvidercommandSourcedContextFor(contextName, config?)- Now registers sources to context-specific EntryAdapterProvider
EntryRegistry.register()now delegates toContextProfile.forContext('default')SourcedEntity.entryAdapterProvider()now returns context-specific provider if availableEntryAdapterProviderconstructor is now public for context-scoped instantiation- Added
EntryAdapterProvider.defaultProvider()convenience method for accessing the default context's provider - Renamed
EntryAdapterProvider.getInstance()toEntryAdapterProvider.instance() - Added
StateAdapterProvider.instance()(getInstance()deprecated but available for backward compatibility) StateAdapterProvideris now exported as a public API for custom state serialization
SourceConfig→SourceTypeSpecContextConfiguration→ContextSourceTypes
Simple Global Registration:
EntryRegistry.register(AccountOpened)
EntryRegistry.register(FundsDeposited, { depositedAt: Source.asDate })Context-Scoped Registration (Fluent API):
ContextProfile.forContext('bank')
.register(AccountOpened)
.register(FundsDeposited, { depositedAt: Source.asDate })
.register(AccountClosed, { closedAt: Source.asDate })
// Or with registerAll for simple types
ContextProfile.forContext('bank')
.registerAll(AccountOpened, FundsTransferred)
.register(FundsDeposited, { depositedAt: Source.asDate })Context Factory with Sources:
const BankEventSourcedEntity = eventSourcedContextFor('bank', {
sources: [
{ type: AccountOpened },
{ type: FundsDeposited, transforms: { depositedAt: Source.asDate } }
]
})Test Isolation:
beforeEach(() => {
ContextProfile.reset()
EntryAdapterProvider.reset()
})- Updated
docs/DomoTactical.mdwith ContextProfile documentation - Added fluent API examples
- Added test isolation workflow
All storage interfaces (Journal, DocumentStore, JournalReader, StreamReader) now extend ActorProtocol from domo-actors. Storage components must be created via stage().actorFor() instead of direct instantiation.
Before (0.1.x):
// Direct instantiation - NO LONGER WORKS
const journal = new InMemoryJournal<string>()
const documentStore = new InMemoryDocumentStore()After (0.2.0):
import { stage, Protocol } from 'domo-actors'
import { Journal, InMemoryJournal, DocumentStore, InMemoryDocumentStore } from 'domo-tactical'
// Create journal as an actor
const journalProtocol: Protocol = {
type: () => 'Journal',
instantiator: () => ({ instantiate: () => new InMemoryJournal<string>() })
}
const journal = stage().actorFor<Journal<string>>(journalProtocol, undefined, 'default')
// Create document store as an actor
const storeProtocol: Protocol = {
type: () => 'DocumentStore',
instantiator: () => ({ instantiate: () => new InMemoryDocumentStore() })
}
const documentStore = stage().actorFor<DocumentStore>(storeProtocol, undefined, 'default')JournalReader.position() and JournalReader.name() are now async methods that return Promise:
Before (0.1.x):
const pos = reader.position() // number
const name = reader.name() // stringAfter (0.2.0):
const pos = await reader.position() // Promise<number>
const name = await reader.name() // Promise<string>New factory functions to create context-specific entity base classes:
import { eventSourcedEntityTypeFor, commandSourcedEntityTypeFor } from 'domo-tactical/model/sourcing'
// Create a base class for the "bank" context
const BankEventSourcedEntity = eventSourcedEntityTypeFor('bank')
const BankCommandSourcedEntity = commandSourcedEntityTypeFor('bank')
// Use as the base for your entities
class AccountActor extends BankEventSourcedEntity implements Account {
// This entity uses the journal at 'domo-tactical:bank.journal'
}
class TransferCoordinator extends BankCommandSourcedEntity {
// This entity uses the journal at 'domo-tactical:bank.journal'
}The SourcedEntity base class now includes:
contextName()- Override to specify context (default:'default')journalKey()- Returns'domo-tactical:<contextName>.journal'
Register journals for contexts:
stage().registerValue('domo-tactical:bank.journal', journal)
stage().registerValue('domo-tactical:bank.documentStore', documentStore)New TestSupervisor interface extends Supervisor with error tracking methods for testing:
import { TestSupervisor, TestJournalSupervisor } from 'domo-tactical/testkit'
export interface TestSupervisor extends Supervisor {
errorRecoveryCount(): Promise<number> // Number of errors handled
lastError(): Promise<string | null> // Message of last error
reset(): Promise<void> // Reset tracking state
}New supervisor implementation for tracking error recovery in tests:
const SUPERVISOR_NAME = 'test-supervisor'
// IMPORTANT: type() must match the supervisor name
const supervisorProtocol: Protocol = {
type: () => SUPERVISOR_NAME,
instantiator: () => ({ instantiate: () => new TestJournalSupervisor() })
}
const supervisor = stage().actorFor<TestSupervisor>(supervisorProtocol, undefined, 'default')
// Create actors under this supervisor
const journal = stage().actorFor<Journal<string>>(journalProtocol, undefined, SUPERVISOR_NAME)
// Wait for error recovery in tests
async function waitForErrorRecovery(supervisor: TestSupervisor, expectedCount: number) {
while (await supervisor.errorRecoveryCount() < expectedCount) {
await new Promise(resolve => setTimeout(resolve, 10))
}
}Important: The supervisor's protocol type() must match the supervisor name used when creating other actors, because Environment.supervisor() looks up supervisors by type in the actor directory.
JournalReader and StreamReader actors now inherit the supervisor from their parent Journal. This means errors in child actors are handled by the same supervisor that handles the Journal.
// InMemoryJournal propagates its supervisor to child actors
private supervisorName(): string {
return this.environment().supervisorName()
}JournalReaderis now exported from the maindomo-tacticalpackageTestSupervisorandTestJournalSupervisorare exported fromdomo-tactical/testkit
-
Updated
docs/DomoTactical.mdwith:- Actor-based storage documentation
- Custom supervisor usage
- Context support
- TestSupervisor/TestJournalSupervisor documentation
- Updated JournalReader interface (async methods)
- Updated project structure
- Updated test count (177 tests)
-
API documentation now includes testkit types:
interfaces/testkit.TestSupervisor.htmlclasses/testkit.TestJournalSupervisor.htmlclasses/testkit.TestConfirmer.htmlinterfaces/JournalReader.html
Replace direct instantiation with actor creation:
// Old
const journal = new InMemoryJournal<string>()
// New
const journalProtocol: Protocol = {
type: () => 'Journal',
instantiator: () => ({ instantiate: () => new InMemoryJournal<string>() })
}
const journal = stage().actorFor<Journal<string>>(journalProtocol, undefined, 'default')Add await to position() and name() calls:
// Old
const pos = reader.position()
// New
const pos = await reader.position()If using context-specific entities:
// Create and register journal for your context
stage().registerValue('domo-tactical:mycontext.journal', journal)
// Create entity using context-specific base class
const MyContextEntity = eventSourcedEntityTypeFor('mycontext')
class MyEntity extends MyContextEntity {
// ...
}If you need to track error recovery in tests:
import { TestJournalSupervisor, TestSupervisor } from 'domo-tactical/testkit'
const SUPERVISOR_NAME = 'test-supervisor'
const supervisorProtocol: Protocol = {
type: () => SUPERVISOR_NAME, // Must match supervisor name
instantiator: () => ({ instantiate: () => new TestJournalSupervisor() })
}
const supervisor = stage().actorFor<TestSupervisor>(supervisorProtocol, undefined, 'default')
// Create actors under this supervisor
const journal = stage().actorFor<Journal<string>>(journalProtocol, undefined, SUPERVISOR_NAME)- Fixed off-by-one total transactions count
- Patch to fix npm release information
- Corrected badges
- Initial release
- Event Sourcing with
EventSourcedEntity - Command Sourcing with
CommandSourcedEntity InMemoryJournalwith stream readersInMemoryDocumentStorefor query models- Complete CQRS projection pipeline
- Entry adapters for schema evolution
- Test utilities (
TestJournal,TestDocumentStore,TestConfirmer) - Snapshot support
- Pattern-based projection matching
- At-least-once projection delivery
- Actor-based entities with DomoActors integration