The Problem
An e-commerce platform processes $10M/month. The shopping cart is a CRUD model — a single carts table with a JSON items column. Problems: when a customer disputes a charge ("the price was $29 when I added it, not $39"), there's no proof. Abandoned cart emails are based on guesswork (no history of when items were added/removed). Cart recovery after browser crash loses everything. A/B testing cart features is impossible because there's no event stream to analyze.
Step 1: Cart Event Definitions
typescript
// src/cart/events.ts
import { z } from 'zod';
const CartEvent = z.discriminatedUnion('type', [
z.object({
type: z.literal('ItemAdded'),
productId: z.string(),
productName: z.string(),
quantity: z.number().int().positive(),
pricePerUnitCents: z.number().int().positive(),
currency: z.string().length(3),
imageUrl: z.string().url().optional(),
}),
z.object({
type: z.literal('ItemRemoved'),
productId: z.string(),
}),
z.object({
type: z.literal('QuantityChanged'),
productId: z.string(),
oldQuantity: z.number().int(),
newQuantity: z.number().int().positive(),
}),
z.object({
type: z.literal('CouponApplied'),
couponCode: z.string(),
discountType: z.enum(['percentage', 'fixed']),
discountValue: z.number().positive(),
}),
z.object({
type: z.literal('CouponRemoved'),
couponCode: z.string(),
}),
z.object({
type: z.literal('PriceUpdated'),
productId: z.string(),
oldPriceCents: z.number().int(),
newPriceCents: z.number().int(),
reason: z.string(),
}),
z.object({
type: z.literal('CartAbandoned'),
lastActivityAt: z.string().datetime(),
}),
z.object({
type: z.literal('CheckoutStarted'),
}),
z.object({
type: z.literal('CheckoutCompleted'),
orderId: z.string(),
totalCents: z.number().int(),
}),
]);
export type CartEvent = z.infer<typeof CartEvent>;
export const CartEventEnvelope = z.object({
eventId: z.string().uuid(),
cartId: z.string().uuid(),
userId: z.string(),
timestamp: z.string().datetime(),
version: z.number().int().positive(),
event: CartEvent,
});
Step 2: Event Store and Projection
typescript
// src/cart/event-store.ts
import { Pool } from 'pg';
import type { CartEvent, CartEventEnvelope } from './events';
const db = new Pool({ connectionString: process.env.DATABASE_URL });
export async function appendEvent(
cartId: string,
userId: string,
event: CartEvent,
expectedVersion: number
): Promise<number> {
const eventId = crypto.randomUUID();
const newVersion = expectedVersion + 1;
try {
await db.query(`
INSERT INTO cart_events (event_id, cart_id, user_id, version, event_type, event_data, timestamp)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
`, [eventId, cartId, userId, newVersion, event.type, JSON.stringify(event)]);
return newVersion;
} catch (err: any) {
if (err.code === '23505') { // unique violation on (cart_id, version)
throw new Error('Concurrent modification — retry');
}
throw err;
}
}
export async function getEvents(cartId: string): Promise<z.infer<typeof CartEventEnvelope>[]> {
const { rows } = await db.query(
`SELECT * FROM cart_events WHERE cart_id = $1 ORDER BY version ASC`,
[cartId]
);
return rows.map(r => ({
eventId: r.event_id,
cartId: r.cart_id,
userId: r.user_id,
timestamp: r.timestamp.toISOString(),
version: r.version,
event: r.event_data,
}));
}
// Rebuild cart state from events
export interface CartState {
cartId: string;
items: Map<string, {
productId: string;
productName: string;
quantity: number;
pricePerUnitCents: number;
addedAt: string;
originalPriceCents: number;
}>;
coupons: Array<{ code: string; discountType: string; discountValue: number }>;
status: 'active' | 'abandoned' | 'checked_out';
version: number;
}
export function projectCart(events: z.infer<typeof CartEventEnvelope>[]): CartState {
const state: CartState = {
cartId: events[0]?.cartId ?? '',
items: new Map(),
coupons: [],
status: 'active',
version: 0,
};
for (const envelope of events) {
const e = envelope.event;
state.version = envelope.version;
switch (e.type) {
case 'ItemAdded':
state.items.set(e.productId, {
productId: e.productId,
productName: e.productName,
quantity: e.quantity,
pricePerUnitCents: e.pricePerUnitCents,
addedAt: envelope.timestamp,
originalPriceCents: e.pricePerUnitCents,
});
break;
case 'ItemRemoved':
state.items.delete(e.productId);
break;
case 'QuantityChanged': {
const item = state.items.get(e.productId);
if (item) item.quantity = e.newQuantity;
break;
}
case 'PriceUpdated': {
const item = state.items.get(e.productId);
if (item) item.pricePerUnitCents = e.newPriceCents;
break;
}
case 'CouponApplied':
state.coupons.push({ code: e.couponCode, discountType: e.discountType, discountValue: e.discountValue });
break;
case 'CouponRemoved':
state.coupons = state.coupons.filter(c => c.code !== e.couponCode);
break;
case 'CartAbandoned':
state.status = 'abandoned';
break;
case 'CheckoutCompleted':
state.status = 'checked_out';
break;
}
}
return state;
}
import { z } from 'zod';
Step 3: Cart API with Undo
typescript
// src/api/cart.ts
import { Hono } from 'hono';
import { appendEvent, getEvents, projectCart } from '../cart/event-store';
const app = new Hono();
app.get('/v1/cart/:cartId', async (c) => {
const events = await getEvents(c.req.param('cartId'));
const state = projectCart(events);
const items = [...state.items.values()];
const subtotal = items.reduce((s, i) => s + i.pricePerUnitCents * i.quantity, 0);
return c.json({ items, coupons: state.coupons, subtotalCents: subtotal, version: state.version });
});
app.post('/v1/cart/:cartId/add', async (c) => {
const cartId = c.req.param('cartId');
const { productId, productName, quantity, priceCents } = await c.req.json();
const events = await getEvents(cartId);
const state = projectCart(events);
const newVersion = await appendEvent(cartId, c.get('userId'), {
type: 'ItemAdded',
productId, productName,
quantity: quantity ?? 1,
pricePerUnitCents: priceCents,
currency: 'USD',
}, state.version);
return c.json({ version: newVersion });
});
// Undo: replay events minus the last one
app.post('/v1/cart/:cartId/undo', async (c) => {
const cartId = c.req.param('cartId');
const events = await getEvents(cartId);
if (events.length === 0) return c.json({ error: 'Nothing to undo' }, 400);
const lastEvent = events[events.length - 1].event;
// Generate compensating event
let compensating: any;
switch (lastEvent.type) {
case 'ItemAdded':
compensating = { type: 'ItemRemoved', productId: lastEvent.productId };
break;
case 'ItemRemoved':
// Re-add from event history
const addEvent = [...events].reverse().find(e => e.event.type === 'ItemAdded' && (e.event as any).productId === (lastEvent as any).productId);
if (addEvent) compensating = addEvent.event;
break;
case 'QuantityChanged':
compensating = { type: 'QuantityChanged', productId: lastEvent.productId, oldQuantity: lastEvent.newQuantity, newQuantity: lastEvent.oldQuantity };
break;
default:
return c.json({ error: 'Cannot undo this action' }, 400);
}
if (compensating) {
const state = projectCart(events);
await appendEvent(cartId, c.get('userId'), compensating, state.version);
}
return c.json({ undone: lastEvent.type });
});
// Price proof for dispute resolution
app.get('/v1/cart/:cartId/price-history/:productId', async (c) => {
const events = await getEvents(c.req.param('cartId'));
const productId = c.req.param('productId');
const priceHistory = events
.filter(e => (e.event as any).productId === productId)
.map(e => ({
type: e.event.type,
timestamp: e.timestamp,
priceCents: (e.event as any).pricePerUnitCents ?? (e.event as any).newPriceCents,
}));
return c.json({ productId, priceHistory });
});
export default app;
Results
- $45K in disputes resolved: price history proves what customer paid at time of adding to cart
- Abandoned cart recovery: exact timeline of adds/removes → personalized recovery emails
- Undo/redo: customers can undo last action, reducing support tickets
- A/B testing: event stream analyzed to see which UI changes affect cart behavior
- Cart recovery after crash: events rebuild state, nothing lost
- Audit compliance: full trail of every cart interaction for financial audits
- Analytics: "items removed after coupon expired" → product team adjusted coupon strategy