Skip to content

Latest commit

 

History

History
447 lines (339 loc) · 11 KB

File metadata and controls

447 lines (339 loc) · 11 KB

Services Module

What This Module Teaches

This module demonstrates business logic, database operations, and transaction management for a reservation system. It implements atomic operations to prevent race conditions.

Key Concepts

1. Race Condition Problem

Without atomic operations:

Time  | Request A              | Request B
------+-----------------------+-----------------------
t1    | Read: qty = 3         |
t2    |                       | Read: qty = 3
t3    | Reserve 2             |
t4    | Write: qty = 1        |
t5    |                       | Reserve 2
t6    |                       | Write: qty = 1
t7    | Success!              | Success!

Result: Both reserved, but we oversold by 1!

2. Solution: Conditional Updates

Use atomic database operations:

UPDATE items
SET availableQty = availableQty - ?
WHERE id = ? AND availableQty >= ?

This UPDATE only succeeds if there's enough stock. If not, changes === 0.

3. Transactions

Use ACID transactions for multi-step operations:

export function transaction<T>(fn: () => T): T {
  const tx = db.transaction(fn);
  return tx();
}

All operations succeed or all rollback together.

Files in This Directory

Reservation business logic:

  • Reserve - Create reservation with atomic stock decrement
  • Confirm - Confirm reservation (status transition)
  • Cancel - Cancel reservation and restore stock
  • Expire - Expire old reservations
  • Query - List/get items and reservations

Operations

Reserve Operation

Creates a reservation and atomically decrements stock:

export function reserveItem(request: ReserveRequest): ReserveResult {
  return transaction(() => {
    // 1. Check if item exists
    const item = db.prepare('SELECT * FROM items WHERE id = ?').get(itemId);

    if (!item) {
      return { kind: 'NOT_FOUND' };
    }

    // 2. Atomically decrement stock (only if enough available)
    const updateResult = db.prepare(
      'UPDATE items SET availableQty = availableQty - ? WHERE id = ? AND availableQty >= ?'
    ).run(qty, itemId, qty);

    if (updateResult.changes === 0) {
      // Conditional update failed = not enough stock
      return { kind: 'OUT_OF_STOCK', available: item.availableQty };
    }

    // 3. Create reservation
    const reservation = {
      id: `res_${crypto.randomUUID()}`,
      userId,
      itemId,
      qty,
      status: 'reserved',
      expiresAt: Date.now() + timeout,
      createdAt: Date.now()
    };

    db.prepare('INSERT INTO reservations ...').run(reservation);

    return { kind: 'OK', reservation };
  });
}

Result types:

  • OK - Reservation created
  • NOT_FOUND - Item doesn't exist
  • OUT_OF_STOCK - Not enough stock available
  • INVALID_QUANTITY - Invalid quantity (too small/large)

Confirm Operation

Confirms a reservation (status transition: reservedconfirmed):

export function confirmReservation(request: ConfirmRequest): ConfirmResult {
  return transaction(() => {
    // 1. Find reservation
    const reservation = db.prepare(
      'SELECT * FROM reservations WHERE id = ? AND userId = ?'
    ).get(reservationId, userId);

    if (!reservation) {
      return { kind: 'NOT_FOUND' };
    }

    // 2. Check status transitions
    if (reservation.status === 'confirmed') {
      return { kind: 'ALREADY_CONFIRMED' };
    }

    if (reservation.status === 'cancelled') {
      return { kind: 'CANCELLED' };
    }

    // 3. Check if expired
    if (now > reservation.expiresAt) {
      // Restore stock and mark as expired
      db.prepare('UPDATE items SET availableQty = availableQty + ? WHERE id = ?').run(qty, itemId);
      db.prepare('UPDATE reservations SET status = ? WHERE id = ?').run('expired', reservationId);
      return { kind: 'EXPIRED' };
    }

    // 4. Confirm reservation
    db.prepare('UPDATE reservations SET status = ? WHERE id = ?').run('confirmed', reservationId);

    return { kind: 'OK' };
  });
}

Result types:

  • OK - Confirmed
  • NOT_FOUND - Reservation doesn't exist
  • ALREADY_CONFIRMED - Already confirmed (idempotent)
  • CANCELLED - Reservation was cancelled
  • EXPIRED - Reservation has expired

Cancel Operation

Cancels a reservation and restores stock:

export function cancelReservation(request: CancelRequest): CancelResult {
  return transaction(() => {
    // 1. Find reservation
    const reservation = db.prepare(
      'SELECT * FROM reservations WHERE id = ? AND userId = ?'
    ).get(reservationId, userId);

    if (!reservation) {
      return { kind: 'NOT_FOUND' };
    }

    // 2. Check status
    if (reservation.status === 'cancelled') {
      return { kind: 'ALREADY_CANCELLED' };
    }

    if (reservation.status === 'confirmed') {
      return { kind: 'ALREADY_CONFIRMED' };
    }

    // 3. Restore stock (if not already expired)
    if (reservation.status !== 'expired') {
      db.prepare('UPDATE items SET availableQty = availableQty + ? WHERE id = ?').run(qty, itemId);
    }

    // 4. Update status
    db.prepare('UPDATE reservations SET status = ? WHERE id = ?').run('cancelled', reservationId);

    return { kind: 'OK' };
  });
}

Result types:

  • OK - Cancelled
  • NOT_FOUND - Reservation doesn't exist
  • ALREADY_CANCELLED - Already cancelled (idempotent)
  • ALREADY_CONFIRMED - Can't cancel confirmed reservations

Expire Operation

Expires old reservations and restores stock:

export function expireReservations(): ExpireResult {
  return transaction(() => {
    // 1. Find all expired reservations
    const expiredReservations = db.prepare(`
      SELECT id, itemId, qty
      FROM reservations
      WHERE status = 'reserved' AND expiresAt < ?
    `).all(now);

    if (expiredReservations.length === 0) {
      return { kind: 'OK', expired: 0 };
    }

    // 2. Process each expired reservation
    for (const resv of expiredReservations) {
      // Restore stock
      db.prepare('UPDATE items SET availableQty = availableQty + ? WHERE id = ?').run(
        resv.qty,
        resv.itemId
      );

      // Mark as expired
      db.prepare('UPDATE reservations SET status = ? WHERE id = ?').run('expired', resv.id);
    }

    return { kind: 'OK', expired: expiredReservations.length };
  });
}

Query Operations

List Items

export function listItems(options: {
  sortBy?: 'name' | 'availableQty';
  sortOrder?: 'asc' | 'desc';
} = {}): Item[] {
  const { sortBy = 'name', sortOrder = 'asc' } = options;

  const items = db.prepare(`
    SELECT id, name, availableQty FROM items
    ORDER BY ${sortBy} ${sortOrder.toUpperCase()}
  `).all();

  return items;
}

Get Item

export function getItem(itemId: string): Item | null {
  const item = db.prepare(
    'SELECT id, name, availableQty FROM items WHERE id = ?'
  ).get(itemId);

  return item || null;
}

List User's Reservations

export function listReservationsForUser(
  userId: string,
  options: { status?: string } = {}
): Reservation[] {
  const { status } = options;

  let query = 'SELECT * FROM reservations WHERE userId = ?';
  const params = [userId];

  if (status) {
    query += ' AND status = ?';
    params.push(status);
  }

  query += ' ORDER BY createdAt DESC';

  return db.prepare(query).all(...params);
}

Discriminated Unions

All operations return discriminated unions for type safety:

type ReserveResult =
  | { kind: 'OK'; reservation: Reservation }
  | { kind: 'NOT_FOUND' }
  | { kind: 'OUT_OF_STOCK'; available: number }
  | { kind: 'INVALID_QUANTITY'; min: number; max: number };

// Usage with type narrowing
const result = reserveItem(request);

if (result.kind === 'OK') {
  console.log(result.reservation.id);
} else if (result.kind === 'OUT_OF_STOCK') {
  console.log(`Only ${result.available} available`);
}

Status Transitions

Valid status transitions:

reserved ────────→ confirmed
   │                    │
   ↓                    │
expired               (can't go back)
   │
   ↓
cancelled

Rules:

  • reservedconfirmed: OK
  • reservedcancelled: OK
  • reservedexpired: OK (automatic)
  • cancelledreserved: Not allowed
  • confirmedcancelled: Not allowed
  • expiredconfirmed: Not allowed

Batch Operations

Reserve Multiple Items

All succeed or all fail together:

export function reserveMultipleItems(
  userId: string,
  items: Array<{ itemId: string; qty: number }>
): { kind: 'OK'; reservations: Reservation[] } | { kind: 'ERROR'; message: string } {
  return transaction(() => {
    const reservations: Reservation[] = [];

    for (const item of items) {
      const result = reserveItem({ userId, itemId: item.itemId, qty: item.qty });

      if (result.kind !== 'OK') {
        return {
          kind: 'ERROR',
          message: `Failed to reserve item ${item.itemId}`
        };
      }

      if ('reservation' in result) {
        reservations.push(result.reservation);
      }
    }

    return { kind: 'OK', reservations };
  });
}

Cache Invalidation

After modifying data, invalidate cache:

// After reserving
invalidateItemCaches(itemId);

// After confirming
invalidateReservationCaches(reservationId, userId);

Statistics

Get Reservation Statistics

export function getReservationStats(): {
  total: number;
  byStatus: Record<string, number>;
  expiredCount: number;
  expiringSoon: number;
} {
  const total = db.prepare('SELECT COUNT(*) as count FROM reservations').get().count;

  const byStatus = {};
  const statusRows = db.prepare(
    'SELECT status, COUNT(*) as count FROM reservations GROUP BY status'
  ).all();

  for (const row of statusRows) {
    byStatus[row.status] = row.count;
  }

  const now = Date.now();
  const expiredCount = db.prepare(
    'SELECT COUNT(*) as count FROM reservations WHERE status = ? AND expiresAt < ?'
  ).get('reserved', now).count;

  const expiringSoon = db.prepare(
    'SELECT COUNT(*) as count FROM reservations WHERE status = ? AND expiresAt >= ? AND expiresAt < ?'
  ).get('reserved', now, now + 5 * 60 * 1000).count;

  return { total, byStatus, expiredCount, expiringSoon };
}

Related Files

Best Practices

✅ DO

  • Use transactions for multi-step operations
  • Use conditional updates to prevent race conditions
  • Return discriminated unions for type safety
  • Restore stock on expiry/cancellation
  • Validate status transitions
  • Log all state changes

❌ DON'T

  • Read-then-write (race condition)
  • Forget transaction rollback
  • Allow invalid status transitions
  • Forget to restore stock
  • Ignore edge cases

💡 Tip: The WHERE availableQty >= ? condition is what prevents overselling - it's all about atomicity!