Files
GearBox/.planning/research/ARCHITECTURE.md

21 KiB

Architecture Research

Domain: Single-user gear management and purchase planning web app Researched: 2026-03-14 Confidence: HIGH

Standard Architecture

System Overview

┌─────────────────────────────────────────────────────────────────┐
│                        Client (Browser)                         │
│  ┌───────────┐  ┌───────────┐  ┌───────────┐  ┌───────────┐   │
│  │ Dashboard  │  │Collection │  │  Threads  │  │  Setups   │   │
│  │   Page     │  │   Page    │  │   Page    │  │   Page    │   │
│  └─────┬─────┘  └─────┬─────┘  └─────┬─────┘  └─────┬─────┘   │
│        │              │              │              │           │
│  ┌─────┴──────────────┴──────────────┴──────────────┴─────┐    │
│  │              Shared UI Components                       │    │
│  │  (ItemCard, ComparisonTable, WeightBadge, CostBadge)    │    │
│  └────────────────────────┬───────────────────────────────┘    │
│                           │ fetch()                            │
├───────────────────────────┼────────────────────────────────────┤
│                    Bun.serve()                                  │
│  ┌────────────────────────┴───────────────────────────────┐    │
│  │                   API Routes Layer                      │    │
│  │   /api/items    /api/threads    /api/setups             │    │
│  │   /api/stats    /api/candidates /api/images             │    │
│  └────────────────────────┬───────────────────────────────┘    │
│                           │                                    │
│  ┌────────────────────────┴───────────────────────────────┐    │
│  │                  Service Layer                          │    │
│  │   ItemService  ThreadService  SetupService  StatsService│    │
│  └────────────────────────┬───────────────────────────────┘    │
│                           │                                    │
│  ┌────────────────────────┴───────────────────────────────┐    │
│  │               Data Access (Drizzle ORM)                 │    │
│  │                    Schema + Queries                     │    │
│  └────────────────────────┬───────────────────────────────┘    │
│                           │                                    │
│  ┌────────────────────────┴───────────────────────────────┐    │
│  │                  SQLite (bun:sqlite)                    │    │
│  │                   gearbox.db file                       │    │
│  └────────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────────┘

This is a monolithic full-stack app running on a single Bun process. No microservices, no separate API server, no Docker. Bun's built-in fullstack dev server handles both static asset bundling and API routes from a single Bun.serve() call. SQLite is the database -- embedded, zero-config, accessed through Bun's native bun:sqlite module (3-6x faster than better-sqlite3).

Component Responsibilities

Component Responsibility Typical Implementation
Dashboard Page Entry point, summary cards, navigation React page showing item count, active threads, setup stats
Collection Page CRUD for gear items, filtering, sorting React page with list/grid views, item detail modal
Threads Page Purchase research threads with candidates React page with thread list, candidate comparison view
Setups Page Compose named setups from collection items React page with drag/drop or select-to-add from collection
API Routes HTTP endpoints for all data operations Bun.serve() route handlers, REST-style
Service Layer Business logic, calculations (weight/cost totals) TypeScript modules with domain logic
Data Access Schema definition, queries, migrations Drizzle ORM with SQLite dialect
SQLite DB Persistent storage Single file, bun:sqlite native module
Image Storage Photo uploads for gear items Local filesystem (./uploads/) served as static files
src/
├── index.tsx              # Bun.serve() entry point, route registration
├── pages/                 # HTML entrypoints for each page
│   ├── index.html         # Dashboard
│   ├── collection.html    # Collection page
│   ├── threads.html       # Planning threads page
│   └── setups.html        # Setups page
├── client/                # React frontend code
│   ├── components/        # Shared UI components
│   │   ├── ItemCard.tsx
│   │   ├── WeightBadge.tsx
│   │   ├── CostBadge.tsx
│   │   ├── ComparisonTable.tsx
│   │   ├── StatusBadge.tsx
│   │   └── Layout.tsx
│   ├── pages/             # Page-level React components
│   │   ├── Dashboard.tsx
│   │   ├── Collection.tsx
│   │   ├── ThreadList.tsx
│   │   ├── ThreadDetail.tsx
│   │   ├── SetupList.tsx
│   │   └── SetupDetail.tsx
│   ├── hooks/             # Custom React hooks
│   │   ├── useItems.ts
│   │   ├── useThreads.ts
│   │   └── useSetups.ts
│   └── lib/               # Client utilities
│       ├── api.ts         # Fetch wrapper for API calls
│       └── formatters.ts  # Weight/cost formatting helpers
├── server/                # Backend code
│   ├── routes/            # API route handlers
│   │   ├── items.ts
│   │   ├── threads.ts
│   │   ├── candidates.ts
│   │   ├── setups.ts
│   │   ├── images.ts
│   │   └── stats.ts
│   └── services/          # Business logic
│       ├── item.service.ts
│       ├── thread.service.ts
│       ├── setup.service.ts
│       └── stats.service.ts
├── db/                    # Database layer
│   ├── schema.ts          # Drizzle table definitions
│   ├── index.ts           # Database connection singleton
│   ├── seed.ts            # Optional dev seed data
│   └── migrations/        # Drizzle Kit generated migrations
├── shared/                # Types shared between client and server
│   └── types.ts           # Item, Thread, Candidate, Setup types
uploads/                   # Gear photos (gitignored, outside src/)
drizzle.config.ts          # Drizzle Kit config

Structure Rationale

  • client/ and server/ separation: Clear boundary between browser code and server code. Both import from shared/ and db/ (server only) but never from each other.
  • pages/ HTML entrypoints: Bun's fullstack server uses HTML files as route entrypoints. Each HTML file imports its corresponding React component tree.
  • server/routes/ + server/services/: Routes handle HTTP concerns (parsing params, status codes). Services handle business logic (calculating totals, validating state transitions). This prevents bloated route handlers.
  • db/schema.ts as single source of truth: All table definitions in one file. Drizzle infers TypeScript types from the schema, so types flow from DB to API to client.
  • shared/types.ts: API response types and domain enums shared between client and server. Avoids type drift.
  • uploads/ outside src/: User-uploaded images are not source code. Served as static files by Bun.

Architectural Patterns

Pattern 1: Bun Fullstack Monolith

What: Single Bun.serve() process serves HTML pages, bundled React assets, and API routes. No separate frontend dev server, no proxy config, no CORS. When to use: Single-user apps, prototypes, small team projects where deployment simplicity matters. Trade-offs: Extremely simple to deploy (one process, one command), but no horizontal scaling. For GearBox this is ideal -- single user, no scaling needed.

Example:

// src/index.tsx
import homepage from "./pages/index.html";
import collectionPage from "./pages/collection.html";
import { itemRoutes } from "./server/routes/items";
import { threadRoutes } from "./server/routes/threads";

Bun.serve({
  routes: {
    "/": homepage,
    "/collection": collectionPage,
    ...itemRoutes,
    ...threadRoutes,
  },
  development: true,
});

Pattern 2: Service Layer for Business Logic

What: Route handlers delegate to service modules that contain domain logic. Services are pure functions or classes that take data in and return results, with no HTTP awareness. When to use: When routes would otherwise contain calculation logic (weight totals, cost impact analysis, status transitions). Trade-offs: Slightly more files, but logic is testable without HTTP mocking and reusable across routes.

Example:

// server/services/setup.service.ts
export function calculateSetupTotals(items: Item[]): SetupTotals {
  return {
    totalWeight: items.reduce((sum, i) => sum + (i.weightGrams ?? 0), 0),
    totalCost: items.reduce((sum, i) => sum + (i.priceCents ?? 0), 0),
    itemCount: items.length,
  };
}

export function computeCandidateImpact(
  setup: Setup,
  candidate: Candidate
): Impact {
  const currentTotals = calculateSetupTotals(setup.items);
  return {
    weightDelta: (candidate.weightGrams ?? 0) - (setup.replacingItem?.weightGrams ?? 0),
    costDelta: (candidate.priceCents ?? 0) - (setup.replacingItem?.priceCents ?? 0),
    newTotalWeight: currentTotals.totalWeight + this.weightDelta,
    newTotalCost: currentTotals.totalCost + this.costDelta,
  };
}

Pattern 3: Drizzle ORM with bun:sqlite

What: Drizzle provides type-safe SQL query building and schema-as-code migrations on top of Bun's native SQLite. Schema definitions double as TypeScript type sources. When to use: Any Bun + SQLite project that wants type safety without the overhead of a full ORM like Prisma. Trade-offs: Lightweight (no query engine, no runtime overhead). SQL-first philosophy means you write SQL-like code, not abstract methods. Migration tooling via Drizzle Kit is solid but simpler than Prisma Migrate.

Example:

// db/schema.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";

export const items = sqliteTable("items", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  name: text("name").notNull(),
  category: text("category"),
  weightGrams: integer("weight_grams"),
  priceCents: integer("price_cents"),
  purchaseSource: text("purchase_source"),
  productUrl: text("product_url"),
  notes: text("notes"),
  imageFilename: text("image_filename"),
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
});

Data Flow

Request Flow

[User clicks "Add Item"]
    |
[React component] --> fetch("/api/items", { method: "POST", body })
    |
[Bun.serve route handler] --> validates input, calls service
    |
[ItemService.create()] --> business logic, defaults
    |
[Drizzle ORM] --> db.insert(items).values(...)
    |
[bun:sqlite] --> writes to gearbox.db
    |
[Response] <-- { id, name, ... } JSON <-- 201 Created
    |
[React state update] --> re-renders item list

Key Data Flows

  1. Collection CRUD: Straightforward REST. Client sends item data, server validates, persists, returns updated item. Client hooks (useItems) manage local state.

  2. Thread lifecycle: Create thread -> Add candidates -> Compare -> Resolve (pick winner). Resolution triggers: candidate becomes a collection item, thread status changes to "resolved", other candidates marked as rejected. This is the most stateful flow.

  3. Setup composition: User selects items from collection to add to a named setup. Server calculates aggregate weight/cost. When viewing a thread candidate, "impact on setup" is computed by comparing candidate against current setup totals (or against a specific item being replaced).

  4. Dashboard aggregation: Dashboard fetches summary stats via /api/stats -- total items, total collection value, active threads count, setup count. This is a read-only aggregation endpoint, not a separate data store.

  5. Image upload: Multipart form upload to /api/images, saved to ./uploads/ with a UUID filename. The filename is stored on the item record. Images served as static files.

Data Model Relationships

items (gear collection)
  |
  |-- 1:N --> setup_items (junction) <-- N:1 -- setups
  |
  |-- 1:N --> thread_candidates (when resolved, candidate -> item)

threads (planning threads)
  |
  |-- 1:N --> candidates (potential purchases)
                |-- status: researching | ordered | arrived
                |-- resolved_as: winner | rejected | null

setups
  |
  |-- N:M --> items (via setup_items junction table)

State Management

No global state library needed. React hooks + fetch are sufficient for a single-user app with this complexity level.

[React Hook per domain]     [API call]        [Server]         [SQLite]
useItems() state  -------->  GET /api/items --> route handler --> SELECT
  |                                                               |
  |<-- setItems(data) <--- JSON response <--- query result <------+

Each page manages its own state via custom hooks (useItems, useThreads, useSetups). No Redux, no Zustand. If a mutation on one page affects another (e.g., resolving a thread adds an item to collection), the target page simply refetches on mount.

Scaling Considerations

Scale Architecture Adjustments
Single user (GearBox) SQLite + single Bun process. Zero infrastructure. This is the target.
1-10 users Still fine with SQLite in WAL mode. Add basic auth if needed.
100+ users Switch to PostgreSQL, add connection pooling, consider separate API server. Not relevant for this project.

Scaling Priorities

  1. First bottleneck: Image storage. If users upload many high-res photos, disk fills up. Mitigation: resize on upload (sharp library), limit file sizes.
  2. Second bottleneck: SQLite write contention under concurrent access. Not a concern for single-user. If it ever matters, switch to WAL mode or PostgreSQL.

Anti-Patterns

Anti-Pattern 1: Storing Money as Floats

What people do: Use float or JavaScript number for prices (e.g., 19.99). Why it's wrong: Floating point arithmetic causes rounding errors. 0.1 + 0.2 !== 0.3. Price calculations silently drift. Do this instead: Store prices as integers in cents (1999 for $19.99). Format for display only in the UI layer. The schema uses priceCents: integer.

Anti-Pattern 2: Overengineering State Management

What people do: Install Redux/Zustand/Jotai for a single-user CRUD app, create elaborate store slices, actions, reducers. Why it's wrong: Adds complexity with zero benefit when there is one user and no shared state across tabs or real-time updates. Do this instead: Use React hooks with fetch. useState + useEffect + a thin API wrapper. Refetch on mount. Keep it boring.

Anti-Pattern 3: SPA with Client-Side Routing for Everything

What people do: Build a full SPA with React Router, lazy loading, code splitting for 4-5 pages. Why it's wrong: Bun's fullstack server already handles page routing via HTML entrypoints. Adding client-side routing means duplicating routing logic, losing Bun's built-in asset optimization per page, and adding bundle complexity. Do this instead: Use Bun's HTML-based routing. Each page is a separate HTML entrypoint with its own React tree. Navigation between pages is standard <a href> links. Keep client-side routing for in-page state (e.g., tabs within thread detail) only.

Anti-Pattern 4: Storing Computed Aggregates in the Database

What people do: Store totalWeight and totalCost on the setup record, then try to keep them in sync when items change. Why it's wrong: Stale data, sync bugs, update anomalies. Items get edited but setup totals do not get recalculated. Do this instead: Compute totals on read. SQLite is fast enough for SUM() across a handful of items. Calculate in the service layer or as a SQL aggregate. For a single-user app with small datasets, this is effectively instant.

Integration Points

External Services

Service Integration Pattern Notes
None for v1 N/A Single-user local app, no external APIs needed
Product URLs Outbound links only Store URLs to retailer pages, no API scraping

Internal Boundaries

Boundary Communication Notes
Client <-> Server REST API (JSON over fetch) No WebSockets needed, no real-time requirements
Routes <-> Services Direct function calls Same process, no serialization overhead
Services <-> Database Drizzle ORM queries Type-safe, no raw SQL strings
Server <-> Filesystem Image read/write ./uploads/ directory for gear photos

Build Order (Dependency Chain)

The architecture implies this build sequence:

  1. Database schema + Drizzle setup -- Everything depends on the data model. Define tables for items, threads, candidates, setups, setup_items first.
  2. API routes for items (CRUD) -- The core entity. Threads and setups reference items.
  3. Collection UI -- First visible feature. Validates the data model and API work end-to-end.
  4. Thread + candidate API and UI -- Depends on items existing to resolve candidates into the collection.
  5. Setup composition API and UI -- Depends on items existing to compose into setups.
  6. Dashboard -- Aggregates stats from all other entities. Build last since it reads from everything.
  7. Polish: image upload, impact calculations, status tracking -- Enhancement layer on top of working CRUD.

This ordering means each phase produces a usable increment: after phase 3 you have a working gear catalog, after phase 4 you can plan purchases, after phase 5 you can compose setups.

Sources


Architecture research for: GearBox gear management app Researched: 2026-03-14