opossum-js
v0.1.0
Published
File system-based event store for TypeScript/Bun implementing the DCB pattern
Maintainers
Readme
opossum-js
A file system-based event store for TypeScript/Bun implementing the DCB (Dynamic Consistency Boundaries) pattern.
TypeScript port of Opossum by Martin Tibor Major.
What is opossum-js?
opossum-js is a lightweight, file system-based event store that requires no external database or infrastructure. It stores domain events as JSON files on disk with tag-based indexing, optimistic concurrency control, and built-in projection support.
- Zero infrastructure — no database, no message broker, just the file system
- Full audit trail — every state change is captured as an immutable event
- Local data ownership — all data stays on the local machine
- Optimistic concurrency — append conditions prevent conflicting writes
- Tag-based indexing — flexible event categorization and querying
- Projections — materialized views with incremental updates and background polling
- DCB pattern — Dynamic Consistency Boundaries via
AppendConditionandQuery
When to Use
- Point-of-sale (POS) systems — offline-capable with local event storage
- Field service apps — works without network connectivity
- Small/medium business tools — simple deployment, no ops overhead
- Data sovereignty — keep data on-premises or on the user's device
- Development and testing — fast iteration without database setup
- Prototyping event-sourced systems — validate domain models before scaling
When NOT to Use
- Distributed systems — no built-in replication or clustering
- High throughput — file I/O has inherent limits compared to dedicated databases
- Cloud-native architectures — better served by managed event stores
- Multi-region deployments — no cross-node coordination
Quick Start
import { createEventStore, toNewEvent, Query } from 'opossum-js';
// Create a store (writes to ./OpossumStore/my-store/)
const store = await createEventStore({ storeName: 'my-store' });
// Append events
await store.append([
toNewEvent('ItemAdded', { itemId: 'item-1', name: 'Widget', price: 9.99 }, [
{ key: 'itemId', value: 'item-1' },
]),
toNewEvent('ItemAdded', { itemId: 'item-2', name: 'Gadget', price: 19.99 }, [
{ key: 'itemId', value: 'item-2' },
]),
]);
// Query events by type
const query = Query.fromEventTypes('ItemAdded');
const events = await store.read(query);
console.log(`Found ${events.length} events`);
for (const e of events) {
console.log(` [${e.position}] ${e.event.eventType}:`, e.event.event);
}Core Concepts
Events
Events are the fundamental unit of storage. There are three event shapes:
DomainEvent— the event payload with a type name and tags:interface DomainEvent { readonly eventType: string; readonly event: IEvent; // { [key: string]: unknown } readonly tags: readonly Tag[]; }NewEvent— what you pass toappend(), wrapping aDomainEventwith optional metadata:interface NewEvent { readonly event: DomainEvent; readonly metadata?: Metadata; }SequencedEvent— what you get back fromread(), with a global sequence position:interface SequencedEvent { readonly event: DomainEvent; readonly position: number; readonly metadata: Metadata; }
Tags
Tags are key-value pairs attached to events for categorization and querying:
interface Tag {
readonly key: string;
readonly value: string;
}Tags enable filtering events by entity, aggregate, or any custom dimension without needing dedicated streams.
Queries
Queries define which events to match. They support event type filtering, tag filtering, or both:
// All events
Query.all()
// Events by type
Query.fromEventTypes('OrderPlaced', 'OrderShipped')
// Events by tag
Query.fromTags({ key: 'orderId', value: 'order-123' })
// Combined: specific types with specific tags
Query.fromItems(
{ eventTypes: ['OrderPlaced'], tags: [{ key: 'customerId', value: 'c-1' }] },
{ eventTypes: ['PaymentReceived'] },
)Multiple query items are OR-combined: an event matches if it satisfies any item.
AppendCondition
Append conditions implement optimistic concurrency control. They specify a query and a sequence position — the append fails if any matching event has been written after that position:
interface AppendCondition {
readonly failIfEventsMatch: Query;
readonly afterSequencePosition?: number;
}This is the core mechanism behind the DCB pattern.
Configuration
Create a store with createEventStore(). Only storeName is required; everything else has sensible defaults:
const store = await createEventStore({
storeName: 'my-store',
rootPath: 'OpossumStore', // default
flushEventsImmediately: true, // default
writeProtectEventFiles: false, // default
writeProtectProjectionFiles: false, // default
crossProcessLockTimeoutMs: 5000, // default
});| Option | Type | Default | Description |
|---|---|---|---|
| storeName | string | (required) | Name of the store (used as directory name) |
| rootPath | string | 'OpossumStore' | Root directory for all store data |
| flushEventsImmediately | boolean | true | Flush events to disk after each write |
| writeProtectEventFiles | boolean | false | Set event files to read-only after writing |
| writeProtectProjectionFiles | boolean | false | Set projection files to read-only after writing |
| crossProcessLockTimeoutMs | number | 5000 | Timeout for cross-process file locks |
API Reference
EventStore
The main interface for reading and writing events:
interface EventStore {
append(events: NewEvent[], condition?: AppendCondition, signal?: AbortSignal): Promise<void>;
read(query: Query, options?: {
readOptions?: ReadOption[];
fromPosition?: number;
maxCount?: number;
}): Promise<SequencedEvent[]>;
readLast(query: Query, signal?: AbortSignal): Promise<SequencedEvent | undefined>;
}EventStoreAdmin
Administrative operations:
interface EventStoreAdmin {
deleteStore(signal?: AbortSignal): Promise<void>;
}EventStoreMaintenance
Maintenance operations for evolving tag schemas:
interface EventStoreMaintenance {
addTags(
eventType: string,
tagFactory: (event: SequencedEvent) => readonly Tag[],
signal?: AbortSignal,
): Promise<TagMigrationResult>;
}Helper Functions
Convenience functions from opossum-js:
// Create a NewEvent from parts
toNewEvent(eventType: string, event: IEvent, tags?: Tag[], metadata?: Metadata): NewEvent
// Append a single event
appendOne(store: EventStore, event: NewEvent, condition?: AppendCondition, signal?: AbortSignal): Promise<void>
// Append events without a condition
appendEvents(store: EventStore, events: NewEvent[], signal?: AbortSignal): Promise<void>
// Read all matching events
readAll(store: EventStore, query: Query): Promise<SequencedEvent[]>
// Read events from a position
readFrom(store: EventStore, query: Query, fromPosition: number): Promise<SequencedEvent[]>DomainEventBuilder
Fluent builder for constructing events with metadata:
import { DomainEventBuilder } from 'opossum-js';
const event = new DomainEventBuilder(
{ orderId: 'order-1', total: 42.00 },
'OrderPlaced',
)
.withTag('orderId', 'order-1')
.withCorrelationId('req-abc')
.withUserId('user-42')
.build();Decision Models
Decision models combine a read (projection) and a write (append with condition) into an atomic decision. They are the primary way to enforce business rules with the DCB pattern.
DecisionProjection
A decision projection defines what state to build from events:
import { createDecisionProjection, Query } from 'opossum-js';
const orderTotal = createDecisionProjection({
initialState: 0,
query: Query.fromEventTypes('ItemAdded', 'ItemRemoved'),
apply: (state, event) => {
if (event.event.eventType === 'ItemAdded') return state + (event.event.event as any).price;
if (event.event.eventType === 'ItemRemoved') return state - (event.event.event as any).price;
return state;
},
});Building Decision Models
import { buildDecisionModel } from 'opossum-js';
const { state, appendCondition } = await buildDecisionModel(store, orderTotal);
// state = current total
// appendCondition = ensures no conflicting events since we read
if (state < 100) {
await store.append(
[toNewEvent('ItemAdded', { price: 25.00 })],
appendCondition,
);
}For multiple projections over a single event read:
import { buildDecisionModel2, buildDecisionModel3, buildDecisionModelN } from 'opossum-js';
// Two projections
const { first, second, appendCondition } = await buildDecisionModel2(store, projA, projB);
// Three projections
const { first, second, third, appendCondition } = await buildDecisionModel3(store, projA, projB, projC);
// N projections (same type)
const { states, appendCondition } = await buildDecisionModelN(store, [projA, projB, projC]);executeDecision
Retry helper that automatically retries on AppendConditionFailedError with exponential backoff:
import { executeDecision } from 'opossum-js';
const result = await executeDecision(store, async (s) => {
const { state, appendCondition } = await buildDecisionModel(s, orderTotal);
if (state >= 100) return { success: false, reason: 'limit reached' };
await s.append(
[toNewEvent('ItemAdded', { price: 25.00 })],
appendCondition,
);
return { success: true };
}, {
maxRetries: 3, // default
initialDelayMs: 50, // default, doubles each retry
});Projections
Projections build materialized views from events, stored as JSON files on disk.
ProjectionDefinition
Define how events map to read-model state:
import type { ProjectionDefinition } from 'opossum-js';
interface OrderSummary {
orderId: string;
itemCount: number;
total: number;
}
const orderSummaryProjection: ProjectionDefinition<OrderSummary> = {
name: 'order-summary',
eventTypes: ['ItemAdded', 'ItemRemoved'],
keySelector: (event) => event.event.tags.find(t => t.key === 'orderId')?.value ?? 'unknown',
apply: (state, event) => {
const current = state ?? { orderId: 'unknown', itemCount: 0, total: 0 };
if (event.event.eventType === 'ItemAdded') {
return {
...current,
orderId: (event.event.event as any).orderId ?? current.orderId,
itemCount: current.itemCount + 1,
total: current.total + ((event.event.event as any).price ?? 0),
};
}
return current;
},
};ProjectionManager
Manages registration and incremental updates of projections:
import { ProjectionManager } from 'opossum-js';
const manager = new ProjectionManager({
eventStore: store,
basePath: './OpossumStore/my-store',
});
manager.register(orderSummaryProjection);
// Process new events
const events = await store.read(Query.all(), { fromPosition: lastPosition });
await manager.update(events);
// Read projection state
const projectionStore = manager.getStore<OrderSummary>('order-summary');
const order = await projectionStore.get('order-123');ProjectionDaemon
Background polling that automatically updates projections as new events arrive:
import { ProjectionDaemon } from 'opossum-js';
const daemon = new ProjectionDaemon(manager, {
pollingIntervalMs: 5000, // default
batchSize: 1000, // default
onError: (err) => console.error('Projection error:', err),
});
daemon.start();
// ... later
daemon.stop();ProjectionRebuilder
Rebuild projections from scratch (e.g., after schema changes):
import { ProjectionRebuilder } from 'opossum-js';
const rebuilder = new ProjectionRebuilder(manager, {
batchSize: 5000,
flushInterval: 10000,
});
// Rebuild a single projection
const result = await rebuilder.rebuild('order-summary');
console.log(`Rebuilt in ${result.durationMs}ms, processed ${result.eventsProcessed} events`);
// Rebuild all projections
await rebuilder.rebuildAll({ force: true });ProjectionStore
Read and query projection state:
interface ProjectionStore<TState> {
get(key: string): Promise<TState | undefined>;
getAll(): Promise<Map<string, TState>>;
save(key: string, state: TState): Promise<void>;
delete(key: string): Promise<void>;
queryByTag(tag: Tag): Promise<Map<string, TState>>;
queryByTags(tags: readonly Tag[]): Promise<Map<string, TState>>;
}The DCB Pattern
opossum-js implements the Dynamic Consistency Boundaries pattern. Instead of grouping events into fixed aggregates, you define consistency boundaries dynamically through queries:
- Read relevant events using a
Query(by event type, tags, or both) - Decide based on the current state (built by folding events)
- Append new events with an
AppendConditionthat references the same query and the last-seen position
If a conflicting event was written between your read and append, the store throws AppendConditionFailedError. Use executeDecision() to automatically retry.
This approach means a single event can participate in multiple consistency boundaries, and boundaries can evolve without restructuring your event streams.
Storage Format
opossum-js stores data under {rootPath}/{storeName}/:
OpossumStore/
my-store/
events/
000000001.json # sequenced event files
000000002.json
...
indices/
event-types/ # event type index
tags/ # tag index
positions/ # position index
projections/
order-summary/ # projection state files
_checkpoints/ # projection checkpoint files
ledger.json # global sequence counterEvent files are human-readable JSON containing the domain event, metadata, and sequence position.
Limitations
- Bun runtime only — uses Bun-specific APIs; does not run on Node.js or Deno
- Not distributed — single-machine, single-process (with cross-process locking)
- No OpenTelemetry — no built-in observability instrumentation yet
- File system performance — throughput is bounded by disk I/O
License
Acknowledgments
opossum-js is a TypeScript/Bun port of Opossum by Martin Tibor Major. The original project is a .NET file system-based event store implementing the DCB pattern. All credit for the architecture and design goes to the original author.
Learn more about the DCB pattern at dcb.events.
