Atomic Multi-Stream Appends
Write several streams in one transaction
Sometimes one command needs to write more than one stream. Not eventually. Not with a saga that cleans up later. Actually together.
multiStreamAppend does that. It checks every stream version first, then writes all events in one transaction. If one stream conflicts, nothing is written.
Create an order and update the customer stream in the same append:
- Write
order.createdtoorder-123 - Write
customer.orderAddedtocustomer-456 - Commit both or neither
Step 1: Define the events
Section titled “Step 1: Define the events”import type { Event } from '@delta-base/server';
type OrderCreated = Event< 'order.created', { orderId: string; customerId: string; total: number; }>;
type CustomerOrderAdded = Event< 'customer.orderAdded', { customerId: string; orderId: string; }>;
type OrderEvent = OrderCreated | CustomerOrderAdded;Keep the events boring. The interesting part is the append boundary, not clever event shapes.
Step 2: Append to both streams
Section titled “Step 2: Append to both streams”const result = await eventStore.multiStreamAppend<OrderEvent>([ { streamId: 'order-123', events: [ { type: 'order.created', data: { orderId: 'order-123', customerId: 'customer-456', total: 49_00, }, }, ], expectedStreamVersion: 'no_stream', }, { streamId: 'customer-456', events: [ { type: 'customer.orderAdded', data: { customerId: 'customer-456', orderId: 'order-123', }, }, ], expectedStreamVersion: 7, },]);The expected version is per stream:
order-123must not exist yetcustomer-456must currently be at version7
If either check fails, DeltaBase writes zero events.
Step 3: Use the result
Section titled “Step 3: Use the result”console.log(result.lastPosition);console.log(result.eventsWritten); // 2
for (const stream of result.streams) { console.log(stream.streamId, stream.nextExpectedStreamVersion);}
for (const event of result.events) { console.log(event.globalPosition, event.streamId, event.type);}The result gives you two views:
streams: grouped by stream, in request orderevents: flat list, sorted byglobalPosition
Step 4: Handle conflicts
Section titled “Step 4: Handle conflicts”import { isVersionConflictError } from '@delta-base/server';
try { await eventStore.multiStreamAppend([ { streamId: 'order-123', events: [{ type: 'order.created', data: { orderId: 'order-123' } }], expectedStreamVersion: 'no_stream', }, { streamId: 'customer-456', events: [{ type: 'customer.orderAdded', data: { orderId: 'order-123' } }], expectedStreamVersion: 7, }, ]);} catch (error) { if (isVersionConflictError(error)) { // Nothing was written. Re-read the affected streams and decide again. return; }
throw error;}On a version conflict, treat the command as undecided. Read current state and run the decision again.
Gotchas
Section titled “Gotchas”Duplicate stream IDs are rejected
Section titled “Duplicate stream IDs are rejected”This is invalid:
await eventStore.multiStreamAppend([ { streamId: 'order-123', events: [eventA] }, { streamId: 'order-123', events: [eventB] },]);Put all events for a stream in one request item instead.
This is not bulk import
Section titled “This is not bulk import”multiStreamAppend is for consistency, not loading millions of events.
Current limits:
- 25 streams per request
- 100 events per stream
- 250 total events
- 1 MB request body
- 256 KB per serialized event
If you hit these limits, your command probably does too much.
Inline projections are after the event transaction
Section titled “Inline projections are after the event transaction”If inline projections are configured, DeltaBase runs them once per touched stream after the event-store transaction commits.
That means:
- event writes are atomic
- projection writes are not part of the event-store transaction
- projection errors do not undo committed events
This matches normal append behavior. Events are facts. Read models catch up.
When to use this
Section titled “When to use this”Use multiStreamAppend when:
- one command naturally emits events to several known streams
- partial success would corrupt your model
- per-stream optimistic concurrency is enough
Use DCB instead when:
- the rule is query-based, like unique usernames
- you do not know the exact streams ahead of time
- you need to fail if matching events appeared since a position
What you just built
Section titled “What you just built”You wrote two streams in one event-store transaction. No saga. No cleanup. No half-created order.
That’s the whole point.