# Stack Research

**Domain:** Public-first gear discovery platform — catalog enrichment, discovery feed, agent-powered seeding (v2.1)
**Researched:** 2026-04-09
**Confidence:** HIGH (existing stack verified against package.json; additions verified against npm/official docs)

---

## Context: What Already Exists (Do Not Re-Research)

The following are validated and in production at v2.0. This file covers ADDITIONS AND CHANGES only.

| Layer | Current |
|-------|---------|
| Runtime | Bun |
| Frontend | React 19, TanStack Router/Query v5, Tailwind CSS v4, Zustand, Zod 4.x, framer-motion, Recharts, Lucide React |
| Backend | Hono 4.12.x, Drizzle ORM 0.45.x, PostgreSQL (postgres.js 3.4.x driver) |
| Auth | @hono/oidc-auth 1.8.x (Logto), API key auth, MCP OAuth 2.1 |
| Storage | @aws-sdk/client-s3 3.x (MinIO) |
| MCP | @modelcontextprotocol/sdk 1.29.x (19 tools) |
| Rate limiting | Custom in-process Map (auth endpoints only, 5 req/15 min per IP) |

---

## New Capability Areas

### 1. Public Access Auth Model

**What's needed:** The `requireAuth` middleware in `src/server/middleware/auth.ts` already handles three auth paths (API key, OAuth Bearer, OIDC session). The skip-list pattern in `src/server/index.ts` already exempts public GETs on `/api/global-items`, `/api/tags`, `/api/users/:id/profile`, and `/api/setups/:id/public`.

**This milestone extends the skip-list** to cover new discovery endpoints (`/api/discovery/*`). Additionally, a new `tryAuth` middleware variant is needed for endpoints that work for both anonymous and authenticated users — it resolves `userId` if credentials are present but does NOT 401 on absence. This enables auth-aware responses (e.g., annotating feed items with "in your collection" for logged-in users).

**No new dependency.** Pure middleware logic — add `tryAuth` to `auth.ts`, update skip-list in `index.ts`.

---

### 2. Discovery Feed (Popular Setups, Trending Items)

The feed requires: ranked/scored queries, cursor-based pagination, and cheap repeated reads by anonymous users.

#### Trending Score

Use a hot-score computed in PostgreSQL SQL — no external search engine or materialized view needed at this scale.

```sql
-- Hacker News-style decay: engagement / time^gravity
SELECT id, brand, model,
  (owner_count::float / power((extract(epoch from now()) - extract(epoch from created_at)) / 3600.0 + 2, 1.8)) AS hot_score
FROM global_items
ORDER BY hot_score DESC
LIMIT 20;
```

This requires `ownerCount` as a real column (not a JOIN-time COUNT) on `globalItems`. The column already logically exists via join — promote it to a denormalized integer that the collection add/remove service path updates. No trigger needed; update it in the same database transaction as the collection operation.

**No new dependency.** Schema migration + service-layer update.

#### Cursor-Based Pagination

Drizzle ORM 0.45.x has documented cursor pagination support (two-column keyset). Use `(hotScore DESC, id DESC)` for the trending feed and `(createdAt DESC, id DESC)` for "recently added." Encode cursor as base64 JSON — opaque to the client.

The Hono + Drizzle cursor pattern is documented and actively used in the ecosystem. No pagination library needed.

**No new dependency.** Drizzle already supports this natively.

#### Full-Text Catalog Search

`globalItems` needs fast free-text search across `brand + model + description`. Use PostgreSQL native `tsvector` with a GIN index.

Drizzle 0.45.x does not generate `GENERATED ALWAYS AS ... STORED` syntax for tsvector columns in drizzle-kit. Add the `searchVector` column and GIN index via a raw SQL migration file (create via `drizzle-kit generate` then manually add the ALTER TABLE and CREATE INDEX statements to the generated file).

For the Hono route, use Drizzle's `sql` template tag with `to_tsquery`:

```typescript
.where(sql`search_vector @@ plainto_tsquery('english', ${q})`)
.orderBy(sql`ts_rank(search_vector, plainto_tsquery('english', ${q})) DESC`)
```

**No new dependency.** Schema migration + raw SQL in service layer.

#### Feed Client (TanStack Query + IntersectionObserver)

`useInfiniteQuery` from `@tanstack/react-query` (already at 5.90.x) handles cursor pagination natively via `getNextPageParam`. The scroll trigger uses the browser-native IntersectionObserver API — implement a `useIntersectionObserver(ref, callback)` hook (~12 lines) rather than adding a scroll library. This matches the existing GearBox pattern of minimal third-party UI dependencies.

**No new dependency.**

---

### 3. Catalog Enrichment Infrastructure

#### Schema Additions to `globalItems`

New fields for attribution, source tracking, and feed ranking:

| Field | Type | Purpose |
|-------|------|---------|
| `sourceUrl` | `text` | Canonical product page (retailer or manufacturer) |
| `sourceAttribution` | `text` | Human-readable credit ("via REI", "via manufacturer") |
| `imageAttributionUrl` | `text` | URL where product image was originally sourced |
| `imageAttributionText` | `text` | License or credit line for the image |
| `submittedByUserId` | `integer FK → users` | Who submitted this catalog entry (null = seeded by admin/agent) |
| `verifiedAt` | `timestamp` | When an admin approved the entry (null = unverified) |
| `ownerCount` | `integer NOT NULL DEFAULT 0` | Denormalized count of collection items referencing this |
| `productUrl` | `text` | Retailer/manufacturer product link (duplicates item-level, but catalog-owned) |

These are Drizzle schema additions. **No new dependency.**

#### Zod Schemas for Enriched Catalog

Add `CreateCatalogItemSchema` in `src/shared/schemas.ts` with attribution fields. Zod 4.3.x handles this natively. The schema feeds the new `POST /api/global-items` route (currently only GET is public — writes will require auth but open to non-admins for catalog submissions).

---

### 4. Agent-Powered Catalog Seeding via MCP

The existing MCP server (`@modelcontextprotocol/sdk` 1.29.x, 19 tools) already provides the infrastructure. The agent workflow:

1. Claude agent receives a category or brand as a prompt
2. Uses a new `create_catalog_item` MCP tool — purpose-built for `globalItems` insertion with full attribution fields
3. Server validates via Zod, inserts into `globalItems`, updates `ownerCount` denormalization
4. Agent uses the existing `upload_image_from_url` tool to fetch and store product images

The new tool registers identically to existing tools in `src/server/mcp/index.ts`. Batch seeding sessions: the agent runs N `create_catalog_item` calls in sequence within one MCP session — no parallel execution framework needed at catalog bootstrap scale.

For standalone seed scripts (`bun run src/db/dev-seed.ts` extensions), use the Drizzle db instance directly. No external seeding framework.

**No new dependency.**

---

### 5. HTTP Caching for Public Endpoints

Public GET endpoints (discovery feed, catalog detail pages) will be hit by anonymous users repeatedly. Add HTTP-level cache hints to reduce DB round-trips.

- **Catalog item detail pages** (`GET /api/global-items/:id`): Use Hono's built-in `etag()` middleware. Content-addressed — returns 304 Not Modified when item hasn't changed.
- **Discovery feed endpoints** (`GET /api/discovery/*`): Set `Cache-Control: public, max-age=60, stale-while-revalidate=300` manually in route handlers. Feed data tolerates 60s staleness.

**Do NOT use Hono's `cache()` middleware** — it is platform-specific to Cloudflare Workers and Deno, and silently does nothing on Bun. This is a documented limitation. Known issue #4401 in the Hono repo also shows the `etag()` middleware can generate inconsistent ETags when combining with other middleware — test in integration tests before shipping.

**No new dependency.** `etag` is built into Hono 4.12.x.

---

### 6. Rate Limiting for Public Traffic

The existing `rateLimit.ts` in-process Map handles auth endpoints correctly (5 req/15 min per IP). It is inappropriate for public discovery traffic because:

- 5 req/15 min is far too strict for anonymous browsing
- In-process state resets on server restart (tolerable for auth, wrong for general rate limiting)
- No way to differentiate authenticated vs anonymous callers in the current implementation

**Recommendation:** Keep the existing `rateLimit.ts` for auth endpoints only. Add `hono-rate-limiter` for discovery/catalog public endpoints with a permissive anonymous limit (e.g., 100 req/min per IP) and no limit for authenticated callers.

```typescript
import { rateLimiter } from "hono-rate-limiter";

const discoveryLimiter = rateLimiter({
  windowMs: 60 * 1000,  // 1 minute
  limit: 100,
  keyGenerator: (c) => c.req.header("x-forwarded-for")?.split(",")[0] ?? "unknown",
});

app.use("/api/discovery/*", discoveryLimiter);
```

The in-process storage adapter (default in `hono-rate-limiter`) is sufficient for single-instance deployment. If the app scales horizontally, swap to `@hono-rate-limiter/redis` — but that is a future decision, not a v2.1 concern.

**New dependency:**

| Library | Version | Purpose |
|---------|---------|---------|
| `hono-rate-limiter` | `^0.5.3` | Per-route rate limiting with configurable windows for public endpoints |

```bash
bun add hono-rate-limiter
```

---

## Full Stack Additions Summary

### New Dependencies (v2.1 only)

| Library | Version | Purpose | Why |
|---------|---------|---------|-----|
| `hono-rate-limiter` | `^0.5.3` | Configurable rate limits for public discovery routes | Existing in-process limiter is auth-only with a 5-req cap; public browse traffic needs separate, permissive limits |

### No New Dependencies Needed For

| Capability | Existing Stack Component Used |
|------------|------------------------------|
| Public auth model (`tryAuth` variant) | Hono middleware — no library |
| Discovery feed cursor pagination | Drizzle 0.45.x cursor pagination docs |
| Full-text catalog search (tsvector GIN) | PostgreSQL native + Drizzle `sql` template |
| Trending score computation | PostgreSQL SQL expression — no extension |
| Infinite scroll client | TanStack Query `useInfiniteQuery` + native IntersectionObserver |
| Catalog attribution fields | Drizzle schema migration |
| Agent catalog seeding | Existing MCP SDK + new `create_catalog_item` tool |
| HTTP cache headers | Hono built-in `etag()` + manual `Cache-Control` |
| Feed ranking denormalization | Service-layer transaction update (no trigger, no cron) |

---

## Schema Changes Required (Not Library Changes)

These are Drizzle schema additions generating migrations:

### `globalItems` additions

```typescript
// In src/db/schema.ts — globalItems table additions
sourceUrl: text("source_url"),
sourceAttribution: text("source_attribution"),
imageAttributionUrl: text("image_attribution_url"),
imageAttributionText: text("image_attribution_text"),
submittedByUserId: integer("submitted_by_user_id").references(() => users.id),
verifiedAt: timestamp("verified_at"),
ownerCount: integer("owner_count").notNull().default(0),
productUrl: text("product_url"),
```

### Raw SQL migration additions (cannot be expressed in Drizzle schema)

```sql
-- Add after Drizzle-generated migration runs:

-- Generated tsvector column for full-text search
ALTER TABLE global_items
  ADD COLUMN search_vector tsvector
  GENERATED ALWAYS AS (
    to_tsvector('english',
      coalesce(brand, '') || ' ' ||
      coalesce(model, '') || ' ' ||
      coalesce(description, '')
    )
  ) STORED;

CREATE INDEX global_items_search_vector_idx ON global_items USING GIN(search_vector);

-- Partial index for public setup discovery feed
CREATE INDEX setups_public_updated_idx ON setups (updated_at DESC) WHERE is_public = true;

-- Trending feed index
CREATE INDEX global_items_owner_count_id_idx ON global_items (owner_count DESC, id DESC);
```

> **Note:** Drizzle Kit does not generate `GENERATED ALWAYS AS ... STORED` for tsvector. Add these as a separate raw SQL file appended to the Drizzle migration or as a separate `customMigration` file in the migrations folder. Run via `bun run db:push` after the Drizzle migration applies.

### `setups` additions

```typescript
// In src/db/schema.ts — setups table additions
viewCount: integer("view_count").notNull().default(0),
```

---

## Alternatives Considered

| Recommended | Alternative | Why Not |
|-------------|-------------|---------|
| PostgreSQL tsvector + GIN | Meilisearch / Typesense | Separate search service adds infra ops complexity; tsvector covers structured gear catalog search at GearBox scale without additional containers |
| PostgreSQL tsvector + GIN | pg_textsearch (BM25 extension) | Requires installing a PostgreSQL extension in production; BM25 ranking is unnecessary for a catalog of branded products where exact brand/model matches dominate |
| Denormalized `ownerCount` column | COUNT JOIN per feed request | Feed queries fire on every anonymous page load; a JOIN COUNT becomes a bottleneck before any other part of the stack does |
| Native IntersectionObserver hook | react-infinite-scroll-component | Zero-dependency — 12-line hook replaces an 8KB library; consistent with GearBox's minimal-external-dependency UI philosophy |
| Manual `Cache-Control` headers | Hono `cache()` middleware | Hono `cache()` is Cloudflare Workers/Deno only — silently does nothing on Bun |
| `hono-rate-limiter` in-process | Redis-backed rate limiter | Single-instance deployment — Redis adds an infra dependency not justified at current scale |
| Extend existing MCP toolset | Separate seeding CLI script | MCP agents already have auth and structured tool calling; a dedicated `create_catalog_item` tool is cleaner than a one-off script that bypasses the service layer |
| Service-layer `ownerCount` update | PostgreSQL trigger | Triggers are invisible to the TypeScript codebase, harder to test, and prone to silent failures in complex transactions |

---

## What NOT to Add

| Avoid | Why | Use Instead |
|-------|-----|-------------|
| Elasticsearch / OpenSearch | Separate cluster, ops overhead, overkill for a structured product catalog | PostgreSQL tsvector with GIN index |
| pg_textsearch / VectorChord-BM25 | PostgreSQL extension install required in prod; BM25 precision unnecessary for brand+model search | PostgreSQL native `ts_rank` |
| Hono `cache()` middleware | Platform-specific to Cloudflare/Deno; does nothing on Bun | Manual `Cache-Control` headers in route handlers |
| react-virtual / windowing | Feed is paginated, not a virtual list; items per page (~20) never hit DOM performance limits | Standard DOM list with cursor pagination |
| Prisma | Already using Drizzle ORM; two ORMs in one codebase is a maintenance trap | drizzle-orm (existing) |
| Materialized views for feed caching | drizzle-kit does not fully support materialized view migrations; manual REFRESH logic is brittle | Denormalized score columns + partial indexes |
| Separate seeding framework (Faker, etc.) | Catalog data is real product data, not fake; agent seeding produces real structured records | MCP `create_catalog_item` tool |

---

## Version Compatibility

| Package | Current Version | v2.1 Notes |
|---------|----------------|------------|
| `hono` | 4.12.x (4.12.12 latest) | `etag()` built-in available; `cache()` is NOT compatible with Bun — do not use |
| `drizzle-orm` | 0.45.x (0.45.2 latest stable) | Cursor pagination confirmed; generated tsvector column requires raw SQL migration appended to drizzle-kit output |
| `@tanstack/react-query` | 5.90.x | `useInfiniteQuery` with `getNextPageParam` fully supports cursor pattern natively |
| `hono-rate-limiter` | 0.5.3 (latest, published ~16 days ago) | In-process storage adapter works on Bun; actively maintained |
| `@modelcontextprotocol/sdk` | 1.29.x | Existing MCP tooling is sufficient for adding `create_catalog_item` |
| `zod` | 4.3.x | New catalog attribution schemas are straightforward additions to existing `schemas.ts` |
| `@hono/zod-validator` | 0.7.x | Already used for all routes; covers new discovery/catalog endpoints |

---

## Installation

```bash
# Only one new package for v2.1
bun add hono-rate-limiter
```

Everything else is schema migrations, new service/route/middleware code, and one new MCP tool — all on the existing stack.

---

## Sources

- [Drizzle ORM cursor-based pagination](https://orm.drizzle.team/docs/guides/cursor-based-pagination) — two-column keyset pattern, v0.45.x confirmed (HIGH)
- [Drizzle ORM PostgreSQL full-text search](https://orm.drizzle.team/docs/guides/postgresql-full-text-search) — tsvector approach confirmed (HIGH)
- [Drizzle ORM full-text search with generated columns](https://orm.drizzle.team/docs/guides/full-text-search-with-generated-columns) — generated column pattern for tsvector (HIGH)
- [Hono ETag middleware](https://hono.dev/docs/middleware/builtin/etag) — built-in, no install required (HIGH)
- [Hono Cache middleware](https://hono.dev/docs/middleware/builtin/cache) — explicitly listed as Cloudflare/Deno only, not Bun (HIGH)
- [Hono ETag issue #4401](https://github.com/honojs/hono/issues/4401) — known inconsistency bug in etag middleware (MEDIUM)
- [hono-rate-limiter GitHub](https://github.com/rhinobase/hono-rate-limiter) — v0.5.3, active, Bun compatible (HIGH)
- [hono-rate-limiter npm](https://www.npmjs.com/package/hono-rate-limiter) — version 0.5.3 confirmed (HIGH)
- [TanStack Query infinite queries](https://tanstack.com/query/latest/docs/framework/react/guides/infinite-queries) — `useInfiniteQuery` cursor pattern (HIGH)
- [Drizzle ORM materialized views issue #2653](https://github.com/drizzle-team/drizzle-orm/issues/2653) — confirmed drizzle-kit does not fully support materialized view migrations (MEDIUM)
- [Hono middleware docs](https://hono.dev/docs/guides/middleware) — selective auth middleware pattern (HIGH)
- GearBox `package.json` — all existing dependency versions verified directly (HIGH)
- GearBox `src/server/index.ts` — existing skip-list pattern verified directly (HIGH)
- GearBox `src/server/middleware/auth.ts` — existing three-way auth verified directly (HIGH)
- GearBox `src/db/schema.ts` — existing `globalItems` table columns verified directly (HIGH)

---

*Stack research for: GearBox v2.1 Public Discovery milestone*
*Researched: 2026-04-09*
