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 |
Recommended Project Structure
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/andserver/separation: Clear boundary between browser code and server code. Both import fromshared/anddb/(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.tsas 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/outsidesrc/: 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
-
Collection CRUD: Straightforward REST. Client sends item data, server validates, persists, returns updated item. Client hooks (useItems) manage local state.
-
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.
-
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).
-
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. -
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
- First bottleneck: Image storage. If users upload many high-res photos, disk fills up. Mitigation: resize on upload (sharp library), limit file sizes.
- 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:
- Database schema + Drizzle setup -- Everything depends on the data model. Define tables for items, threads, candidates, setups, setup_items first.
- API routes for items (CRUD) -- The core entity. Threads and setups reference items.
- Collection UI -- First visible feature. Validates the data model and API work end-to-end.
- Thread + candidate API and UI -- Depends on items existing to resolve candidates into the collection.
- Setup composition API and UI -- Depends on items existing to compose into setups.
- Dashboard -- Aggregates stats from all other entities. Build last since it reads from everything.
- 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
- Bun Fullstack Dev Server docs -- Official documentation on Bun's HTML-based routing and asset bundling
- bun:sqlite API Reference -- Native SQLite module documentation
- Building Full-Stack App with Bun.js, React and Drizzle ORM -- Project structure reference
- Bun v3.1 Release (InfoQ) -- Zero-config frontend, built-in DB clients
- Bun + React + Hono pattern -- Alternative fullstack patterns
- Inventory Management DB Design (Medium) -- Schema design patterns for inventory systems
Architecture research for: GearBox gear management app Researched: 2026-03-14