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.
The picture
Section titled “The picture” 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.releasedReserving 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.
Prereqs
Section titled “Prereqs”Do the banking tutorial first so you already have
DeltaBase, streams, and Studio open.
Start the local stack:
pnpx @delta-base/cli devnpx @delta-base/cli devAdd deps in a small project (same idea as banking):
pnpm add @delta-base/server @delta-base/toolkitnpm install @delta-base/server @delta-base/toolkitOne client, three logical stores
Section titled “One client, three logical stores”You can use one DeltaBase client and separate event store names for each bounded context:
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.
Inventory: facts on the product stream
Section titled “Inventory: facts on the product stream”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.
Cart: reserve first, then append
Section titled “Cart: reserve first, then append”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()], };}The race you actually care about
Section titled “The race you actually care about”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);pnpx tsx src/race-last-unit.tsnpx tsx src/race-last-unit.tsOne 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.
Orders (thin slice)
Section titled “Orders (thin slice)”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.
What you actually learned
Section titled “What you actually learned”- 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 → appendon the same stream still needs optimistic concurrency in production. For a learning script, the last writer error is enough to see the idea.
What is next
Section titled “What is next”- DCB unique username for global rules without sagas.
- CQRS implementation when reads and writes split for real.
Open Studio at http://localhost:3000 and watch inventory and carts
streams side by side. That is the whole movie.