Skip to content

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:

  1. Write order.created to order-123
  2. Write customer.orderAdded to customer-456
  3. Commit both or neither

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.

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-123 must not exist yet
  • customer-456 must currently be at version 7

If either check fails, DeltaBase writes zero events.

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 order
  • events: flat list, sorted by globalPosition
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.

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.

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.

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

You wrote two streams in one event-store transaction. No saga. No cleanup. No half-created order.

That’s the whole point.