Skip to content

Shopping cart

Inventory, reservations, and what happens when two carts want the last unit

You already know how one stream behaves. This page is about two streams talking: inventory lives on product-{id}, the cart lives on cart-{id}, and the awkward bit is the last item on the shelf.

inventory stream (per product) cart stream (per shopper)
───────────────────────────── ──────────────────────────
product.added cart.created
stock.replenished item.added ─── reserves first
stock.reserved ◀── cart asked item.removed ── releases
stock.released

Reserving stock before writing item.added is the whole trick. If you only updated a stock column, two tabs could both read 1 and both think they won.

Do the banking tutorial first so you already have DeltaBase, streams, and Studio open.

Start the local stack:

Terminal window
pnpx @delta-base/cli dev

Add deps in a small project (same idea as banking):

Terminal window
pnpm add @delta-base/server @delta-base/toolkit

You can use one DeltaBase client and separate event store names for each bounded context:

src/event-stores.ts
import { DeltaBase } from '@delta-base/server';
const baseUrl = process.env.DELTABASE_BASE_URL || 'http://localhost:8787';
export const deltabase = new DeltaBase({
baseUrl,
apiKey: process.env.DELTABASE_API_KEY || 'dev-key',
});
export const inventoryStore = deltabase.getEventStore('inventory');
export const cartStore = deltabase.getEventStore('carts');
export const orderStore = deltabase.getEventStore('orders');

Create those three event stores once (Studio or deltabase.config.ts), same as you did for banking.

src/inventory.ts
import type { Event, ReadEvent } from '@delta-base/toolkit';
import { inventoryStore } from './event-stores';
type InventoryEvent =
| Event<
'product.added',
{ name: string; price: number; quantity: number }
>
| Event<'stock.replenished', { quantity: number }>
| Event<
'stock.reserved',
{ quantity: number; reservationId: string }
>
| Event<
'stock.released',
{ quantity: number; reservationId: string }
>;
function availableUnits(events: ReadEvent<InventoryEvent>[]): number {
let onHand = 0;
for (const e of events) {
switch (e.type) {
case 'product.added':
case 'stock.replenished':
onHand += e.data.quantity;
break;
case 'stock.reserved':
onHand -= e.data.quantity;
break;
case 'stock.released':
onHand += e.data.quantity;
break;
}
}
return onHand;
}
export async function addProduct(
productId: string,
name: string,
price: number,
initialStock: number
) {
const ev: InventoryEvent = {
type: 'product.added',
data: { name, price, quantity: initialStock },
};
await inventoryStore.appendToStream(productId, [ev], {
expectedStreamVersion: 'no_stream',
});
}
export async function reserveStock(
productId: string,
quantity: number,
reservationId: string
) {
const { events } = await inventoryStore.readStream<InventoryEvent>(productId);
if (availableUnits(events) < quantity) {
throw new Error(
`Insufficient stock for ${productId}. Requested ${quantity}.`
);
}
const ev: InventoryEvent = {
type: 'stock.reserved',
data: { quantity, reservationId },
};
await inventoryStore.appendToStream(productId, [ev]);
}
export async function releaseStock(
productId: string,
reservationId: string
) {
const { events } = await inventoryStore.readStream<InventoryEvent>(productId);
const reserved = events.find(
(e) =>
e.type === 'stock.reserved' && e.data.reservationId === reservationId
);
if (!reserved || reserved.type !== 'stock.reserved') {
throw new Error(`Reservation ${reservationId} not found on ${productId}`);
}
const ev: InventoryEvent = {
type: 'stock.released',
data: {
quantity: reserved.data.quantity,
reservationId,
},
};
await inventoryStore.appendToStream(productId, [ev]);
}
export async function getProductStock(productId: string) {
const { events } = await inventoryStore.readStream<InventoryEvent>(productId);
const added = events.find((e) => e.type === 'product.added');
return {
productId,
name: added?.type === 'product.added' ? added.data.name : 'Unknown',
price: added?.type === 'product.added' ? added.data.price : 0,
available: availableUnits(events),
eventCount: events.length,
};
}

readStream returns { events, currentStreamVersion, streamExists }. The events carry data, not a flat bag of optional fields. That is the shape the HTTP client actually gives you.

addProduct uses expectedStreamVersion: 'no_stream' so you only create each product stream once. Re-run with a new productId, clear the stream in Studio, or switch to 'any' for disposable local scripts.

src/cart.ts
import type { Event, ReadEvent } from '@delta-base/toolkit';
import { cartStore } from './event-stores';
import { releaseStock, reserveStock } from './inventory';
type CartEvent =
| Event<'cart.created', { customerId: string }>
| Event<
'item.added',
{ productId: string; quantity: number; reservationId: string }
>
| Event<
'item.removed',
{ productId: string; reservationId: string }
>;
export async function createCart(cartId: string, customerId: string) {
const ev: CartEvent = {
type: 'cart.created',
data: { customerId },
};
await cartStore.appendToStream(cartId, [ev], {
expectedStreamVersion: 'no_stream',
});
}
export async function addToCart(
cartId: string,
productId: string,
quantity: number
) {
const reservationId = `${cartId}:${productId}:${Date.now()}`;
try {
await reserveStock(productId, quantity, reservationId);
const ev: CartEvent = {
type: 'item.added',
data: { productId, quantity, reservationId },
};
await cartStore.appendToStream(cartId, [ev]);
return { ok: true as const, reservationId };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { ok: false as const, error: message };
}
}
export async function removeFromCart(cartId: string, productId: string) {
const { events } = await cartStore.readStream<CartEvent>(cartId);
const added = [...events]
.reverse()
.find((e) => e.type === 'item.added' && e.data.productId === productId);
if (!added || added.type !== 'item.added') {
throw new Error(`No line item for ${productId} in ${cartId}`);
}
await releaseStock(productId, added.data.reservationId);
const ev: CartEvent = {
type: 'item.removed',
data: {
productId,
reservationId: added.data.reservationId,
},
};
await cartStore.appendToStream(cartId, [ev]);
}
export async function getCart(cartId: string) {
const { events } = await cartStore.readStream<CartEvent>(cartId);
const items = new Map<
string,
{ productId: string; quantity: number; reservationId: string }
>();
for (const e of events) {
if (e.type === 'item.added') {
items.set(e.data.productId, {
productId: e.data.productId,
quantity: e.data.quantity,
reservationId: e.data.reservationId,
});
}
if (e.type === 'item.removed') {
items.delete(e.data.productId);
}
}
const created = events.find((e) => e.type === 'cart.created');
return {
cartId,
customerId:
created?.type === 'cart.created' ? created.data.customerId : undefined,
items: [...items.values()],
};
}
src/race-last-unit.ts
import { addProduct, getProductStock } from './inventory';
import { createCart, addToCart } from './cart';
async function main() {
await addProduct('limited-sneakers', 'Limited Sneakers', 299_99, 1);
console.log('Stock:', await getProductStock('limited-sneakers'));
await createCart('cart-a', 'alice');
await createCart('cart-b', 'bob');
const [a, b] = await Promise.all([
addToCart('cart-a', 'limited-sneakers', 1),
addToCart('cart-b', 'limited-sneakers', 1),
]);
console.log('Alice:', a);
console.log('Bob:', b);
console.log('Stock after:', await getProductStock('limited-sneakers'));
}
main().catch(console.error);
Terminal window
pnpx tsx src/race-last-unit.ts

One append wins, the other throws before writing cart events. That is boring and correct. If you need stronger guarantees across multiple streams, that is where tags and DCB reads show up. See Unique username with DCB.

You can record checkout on an order-{id} stream the same way banking records money.deposited. The cart already holds reservationIds so your order command can copy line items into order.created and later emit order.paid or order.cancelled. Wire HTTP with Hono the same way as the banking tutorial.

  • Two aggregates, one rule: inventory is global per SKU, cart is per shopper.
  • Ordering matters: reserve inventory before you celebrate with cart events.
  • Race visibility: concurrent read → decide → append on the same stream still needs optimistic concurrency in production. For a learning script, the last writer error is enough to see the idea.

Open Studio at http://localhost:3000 and watch inventory and carts streams side by side. That is the whole movie.