From bc2a532238455ca47e8d41920bef9589fe93fe90 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 10 Apr 2026 10:38:26 +0200 Subject: [PATCH] docs(25): research catalog enrichment and agent tools phase --- .../25-RESEARCH.md | 587 ++++++++++++++++++ 1 file changed, 587 insertions(+) create mode 100644 .planning/phases/25-catalog-enrichment-agent-tools/25-RESEARCH.md diff --git a/.planning/phases/25-catalog-enrichment-agent-tools/25-RESEARCH.md b/.planning/phases/25-catalog-enrichment-agent-tools/25-RESEARCH.md new file mode 100644 index 0000000..7abfb5e --- /dev/null +++ b/.planning/phases/25-catalog-enrichment-agent-tools/25-RESEARCH.md @@ -0,0 +1,587 @@ +# Phase 25: Catalog Enrichment & Agent Tools - Research + +**Researched:** 2026-04-10 +**Domain:** Drizzle ORM upserts + Hono REST API + MCP tool registration + React detail page +**Confidence:** HIGH + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +- **D-01:** Add three new columns to `globalItems`: `sourceUrl` (text, nullable), `imageCredit` (text, nullable), `imageSourceUrl` (text, nullable). +- **D-02:** No separate `manufacturer` column — existing `brand` field serves this purpose. +- **D-03:** Attribution display on catalog detail page: image credit and source link shown inline below the image, not in a collapsible section. +- **D-04:** Add a unique constraint on `(brand, model)` to `globalItems`. +- **D-05:** Upsert semantics on conflict: `ON CONFLICT (brand, model) DO UPDATE` — update all non-key fields. +- **D-06:** `POST /api/global-items/bulk` — accepts array of items, upserts all in a single transaction. Requires API key auth. +- **D-07:** All-or-nothing transaction — if any item fails validation, reject the entire batch with detailed error response. +- **D-08:** Maximum 100 items per request. Return count of created vs updated items in the response. +- **D-09:** Request body shape: `{ items: [{ brand, model, category?, weightGrams?, priceCents?, imageUrl?, description?, sourceUrl?, imageCredit?, imageSourceUrl?, tags?: string[] }] }`. +- **D-10:** `POST /api/global-items` — upsert a single catalog item with the same conflict handling as bulk. Also requires auth. +- **D-11:** Add `upsert_catalog_item` MCP tool — writes directly to `globalItems` (not user-scoped). +- **D-12:** Add `bulk_upsert_catalog` MCP tool — accepts array of items, calls the bulk import service. +- **D-13:** MCP catalog tools require authenticated session (API key or OAuth bearer), same as all existing MCP tools. +- **D-14:** Register new MCP tools in `src/server/mcp/index.ts` following the existing pattern. + +### Claude's Discretion + +- Drizzle migration approach for new columns and unique constraint +- Exact Zod validation schemas for bulk import payload +- MCP tool descriptions and parameter documentation +- Tag handling in upsert (create-if-not-exists vs require existing tags) +- Response shape details for bulk import (created/updated counts, item IDs) + +### Deferred Ideas (OUT OF SCOPE) + +- SEED-04 (actual seeding run with 100+ items) +- Admin role for catalog management +- Catalog item edit UI + + + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| CATL-01 | Global items have attribution fields (sourceUrl, manufacturer, imageCredit, imageSourceUrl) | D-01: schema migration adds three columns; `brand` already serves manufacturer role (D-02) | +| CATL-02 | Global items have a unique constraint on (brand, model) preventing duplicates | D-04: Drizzle `unique()` constraint in schema; migration via `bun run db:generate && db:push` | +| CATL-03 | Catalog detail pages display image attribution with credit and source link | D-03: inline display below image in `$globalItemId.tsx`; `GlobalItem` interface needs new fields | +| CATL-04 | Bulk import API endpoint accepts multiple catalog items in one request | D-06: `POST /api/global-items/bulk`; zValidator + bulkUpsertGlobalItems service function | +| CATL-05 | Bulk import uses upsert semantics (ON CONFLICT update, not fail) | D-05: `onConflictDoUpdate({ target: [brand, model], set: {...} })` — already used elsewhere | +| SEED-01 | MCP server has `upsert_catalog_item` tool writing to globalItems (not user-scoped) | D-11/D-14: new `catalog.ts` tool file following `items.ts` pattern; registered in `mcp/index.ts` | +| SEED-02 | MCP server has `bulk_upsert_catalog` tool for batch catalog population | D-12: same tool file; calls bulkUpsertGlobalItems service | +| SEED-03 | Catalog MCP tools include attribution fields (sourceUrl, manufacturer, imageCredit) as parameters | D-11/D-12: inputSchema includes all attribution fields; same auth model as existing tools | + + + +## Summary + +Phase 25 adds write capability to the global items catalog: attribution metadata columns, a uniqueness constraint on `(brand, model)`, single and bulk upsert API endpoints, two new MCP tools, and attribution display on the catalog detail page. All patterns already exist in the codebase — this phase is entirely additive and follows established conventions. + +The DB layer uses Drizzle ORM 0.45.1 with PostgreSQL (via `pg` driver in production, `@electric-sql/pglite` in tests). Drizzle's `.onConflictDoUpdate()` is already used in `auth.service.ts` (single-column conflict) and `settings.ts` (multi-column conflict), so the upsert pattern for `(brand, model)` is proven. The migration workflow is `bun run db:generate` (drizzle-kit) then `bun run db:push`. + +MCP tools follow a three-part pattern: an exported `*ToolDefinitions` array, an exported `register*Tools(db, userId)` factory, and registration loops in `mcp/index.ts`. Catalog tools are unique in that they do NOT need `userId` (the catalog is shared, not user-scoped), but `userId` is still available for auth validation if needed. + +**Primary recommendation:** Create `src/server/mcp/tools/catalog.ts`, extend `global-item.service.ts` with `upsertGlobalItem` and `bulkUpsertGlobalItems`, add `POST` routes to `global-items.ts`, run a schema migration, update the client hook interface, and add attribution display to `$globalItemId.tsx`. + +## Standard Stack + +### Core + +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| drizzle-orm | 0.45.1 | ORM + upsert via `onConflictDoUpdate` | Project standard; upsert already in use | +| drizzle-kit | 0.31.9 | Schema migration generation | Project standard; `bun run db:generate` | +| hono | 4.12.8 | HTTP routing for new POST endpoints | Project standard | +| @hono/zod-validator | 0.7.6 | Request body validation middleware | Used on every POST/PUT route | +| zod | 4.3.6 | Schema definitions for bulk payload | Project standard for shared schemas | +| @modelcontextprotocol/sdk | 1.29.0 | MCP tool registration | Project standard; used in `mcp/index.ts` | + +### Supporting + +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| @electric-sql/pglite | 0.4.3 | In-memory Postgres for tests | Tests only — uses `createTestDb()` helper | +| @tanstack/react-query | 5.90.21 | Client-side data fetching | Update `useGlobalItem` interface after new fields land | + +**Installation:** No new dependencies needed. All required libraries are already installed. + +## Architecture Patterns + +### Recommended Project Structure + +The phase touches these existing files (no new directories needed): + +``` +src/ +├── db/ +│ └── schema.ts # Add 3 columns + unique constraint to globalItems +├── shared/ +│ └── schemas.ts # Add upsertGlobalItemSchema + bulkUpsertSchema +├── server/ +│ ├── services/ +│ │ └── global-item.service.ts # Add upsertGlobalItem + bulkUpsertGlobalItems +│ ├── routes/ +│ │ └── global-items.ts # Add POST / and POST /bulk handlers +│ └── mcp/ +│ ├── index.ts # Register catalog tool group +│ └── tools/ +│ └── catalog.ts # NEW: upsert_catalog_item + bulk_upsert_catalog +├── client/ +│ ├── hooks/ +│ │ └── useGlobalItems.ts # Add sourceUrl, imageCredit, imageSourceUrl to interface +│ └── routes/ +│ └── global-items/ +│ └── $globalItemId.tsx # Add attribution display below image +drizzle-pg/ +└── XXXX_catalog_enrichment.sql # Generated migration +``` + +### Pattern 1: Drizzle Upsert on Multi-Column Conflict + +The `onConflictDoUpdate` API with an array target is already proven in `settings.ts`: + +```typescript +// Source: src/server/routes/settings.ts (line 33) +await database + .insert(settings) + .values({ userId, key, value: body.value }) + .onConflictDoUpdate({ + target: [settings.userId, settings.key], + set: { value: body.value }, + }); +``` + +For `globalItems` with a unique constraint on `(brand, model)`, the pattern is: + +```typescript +// Adapts from settings.ts pattern — for global-item.service.ts +const [item] = await db + .insert(globalItems) + .values(data) + .onConflictDoUpdate({ + target: [globalItems.brand, globalItems.model], + set: { + category: data.category, + weightGrams: data.weightGrams, + priceCents: data.priceCents, + imageUrl: data.imageUrl, + description: data.description, + sourceUrl: data.sourceUrl, + imageCredit: data.imageCredit, + imageSourceUrl: data.imageSourceUrl, + }, + }) + .returning(); +return item; +``` + +**Key insight:** The unique constraint must exist on the table for `.onConflictDoUpdate({ target: [...] })` to reference it. The Drizzle migration (generated from the schema change) creates the constraint — `target` in `onConflictDoUpdate` references the schema columns, not raw strings. + +### Pattern 2: All-or-Nothing Transaction for Bulk Upsert + +Drizzle transactions are used in `setup.service.ts` and `thread.service.ts`. The bulk upsert wraps all inserts in a single transaction: + +```typescript +// Adapts from src/server/services/setup.service.ts (line 164) +export async function bulkUpsertGlobalItems( + db: Db, + items: UpsertGlobalItemInput[], +): Promise<{ created: number; updated: number; items: GlobalItem[] }> { + return await db.transaction(async (tx) => { + let created = 0; + let updated = 0; + const results: GlobalItem[] = []; + + for (const data of items) { + // Check if exists to determine created vs updated count + const [existing] = await tx + .select({ id: globalItems.id }) + .from(globalItems) + .where(and(eq(globalItems.brand, data.brand), eq(globalItems.model, data.model))); + + const [item] = await tx + .insert(globalItems) + .values(data) + .onConflictDoUpdate({ + target: [globalItems.brand, globalItems.model], + set: { /* all non-key fields */ }, + }) + .returning(); + + if (existing) updated++; + else created++; + results.push(item); + + // Handle tags if provided + if (data.tags && data.tags.length > 0) { + await syncGlobalItemTags(tx, item.id, data.tags); + } + } + + return { created, updated, items: results }; + }); +} +``` + +**Note:** If any `.insert()` throws (e.g., Zod fails at service level for a structural error), the transaction rolls back automatically. Zod validation happens at the route level BEFORE the transaction, so the transaction only sees pre-validated data. + +### Pattern 3: MCP Tool Registration (Catalog-Specific) + +The catalog tools differ from other tool groups: they do not scope to `userId`. The `createMcpServer` function signature in `mcp/index.ts` passes `userId` to every tool factory — catalog tools accept it but do not filter by it. + +```typescript +// Source: pattern from src/server/mcp/tools/images.ts +// images.ts already omits userId from its factory — catalog.ts follows the same approach + +export const catalogToolDefinitions = [ + { + name: "upsert_catalog_item", + description: "...", + inputSchema: { + brand: z.string().describe("Brand or manufacturer name"), + model: z.string().describe("Model name — combined with brand as unique identifier"), + // ... all fields + }, + }, + { + name: "bulk_upsert_catalog", + description: "...", + inputSchema: { + items: z.array(catalogItemSchema).max(100).describe("Array of catalog items to upsert"), + }, + }, +]; + +// Factory takes db only (no userId needed — catalog is global) +export function registerCatalogTools(db: Db) { + return { + upsert_catalog_item: async (args: UpsertArgs): Promise => { ... }, + bulk_upsert_catalog: async (args: { items: UpsertArgs[] }): Promise => { ... }, + }; +} +``` + +In `mcp/index.ts`, register like `imageHandlers` (no userId dependency): + +```typescript +// In createMcpServer(): +const catalogHandlers = registerCatalogTools(db); +for (const def of catalogToolDefinitions) { + const handler = catalogHandlers[def.name as keyof typeof catalogHandlers]; + server.tool(def.name, def.description, def.inputSchema, handler); +} +``` + +### Pattern 4: Schema Migration for Columns + Unique Constraint + +Adding columns to an existing table and a unique constraint in Drizzle: + +```typescript +// In src/db/schema.ts — globalItems table +export const globalItems = pgTable( + "global_items", + { + id: serial("id").primaryKey(), + brand: text("brand").notNull(), + model: text("model").notNull(), + category: text("category"), + weightGrams: doublePrecision("weight_grams"), + priceCents: integer("price_cents"), + imageUrl: text("image_url"), + description: text("description"), + // NEW attribution columns: + sourceUrl: text("source_url"), + imageCredit: text("image_credit"), + imageSourceUrl: text("image_source_url"), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [unique().on(table.brand, table.model)], // NEW unique constraint +); +``` + +Run `bun run db:generate` to generate the migration SQL, then `bun run db:push` to apply. The generated SQL will contain `ALTER TABLE "global_items" ADD COLUMN ...` statements and a `CREATE UNIQUE INDEX` or `CONSTRAINT` statement. + +**Important:** The existing `seedGlobalItems` function in `src/db/seed-global-items.ts` seeds with plain `db.insert()`. After the unique constraint lands, a second seed call would fail for duplicate `(brand, model)` pairs. The seed function is already idempotent by checking `existing.length > 0`, so no changes needed there. + +### Pattern 5: Attribution Display (Client) + +The `$globalItemId.tsx` component renders the image in a `div` below which we add attribution. The `useGlobalItem` hook interface needs updating to include new fields, then the component can conditionally render them: + +```tsx +{/* After the image div, before the header */} +{(item.imageCredit || item.imageSourceUrl) && ( +

+ {item.imageCredit && Photo: {item.imageCredit}} + {item.imageSourceUrl && ( + + Source + + )} +

+)} +``` + +### Pattern 6: Tag Handling in Upsert (Claude's Discretion) + +**Recommendation: create-if-not-exists.** The existing `tags` table has a unique constraint on `name`. Use `onConflictDoUpdate` (or `onConflictDoNothing`) on the tags table to get the tag ID, then upsert the `globalItemTags` junction. This lets agents pass tag names without pre-populating the tags table. + +```typescript +async function syncGlobalItemTags(tx: Tx, globalItemId: number, tagNames: string[]) { + // Delete existing tags for this item + await tx.delete(globalItemTags).where(eq(globalItemTags.globalItemId, globalItemId)); + + for (const name of tagNames) { + // Upsert tag (create if not exists) + const [tag] = await tx + .insert(tags) + .values({ name }) + .onConflictDoUpdate({ target: tags.name, set: { name } }) + .returning({ id: tags.id }); + + await tx.insert(globalItemTags).values({ globalItemId, tagId: tag.id }); + } +} +``` + +### Anti-Patterns to Avoid + +- **Do NOT modify `mcp/index.ts` `createMcpServer` signature** to remove `userId` just because catalog tools don't need it. Pass `db` only to `registerCatalogTools` — accept the `userId` parameter in `createMcpServer` and simply not forward it to catalog tools. +- **Do NOT validate tags at the Zod level as enum values.** Tags are open-ended strings; only length/format validation is appropriate. +- **Do NOT return partial success for bulk upsert.** D-07 mandates all-or-nothing. Zod schema validation at the route level catches structural errors before any DB work begins. +- **Do NOT add rate limiting to `POST /api/global-items` or `POST /api/global-items/bulk`.** These are authenticated-write endpoints — the auth middleware already gates them. The rate limiting added in Phase 24 only applies to public GET endpoints. + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Upsert on conflict | Manual SELECT then INSERT/UPDATE | `onConflictDoUpdate()` | Drizzle handles atomicity; already used in settings.ts and auth.service.ts | +| All-or-nothing bulk write | Try/catch with manual rollback | `db.transaction(async (tx) => { ... })` | Drizzle handles rollback on throw; used in setup.service.ts | +| Tag create-if-not-exists | Check exists, insert if not | `onConflictDoUpdate` on tags.name | Same conflict mechanism | +| Request body validation | Manual type checking in handler | `zValidator("json", schema)` from `@hono/zod-validator` | All POST/PUT routes use this; Hono returns 400 automatically | +| Auth on POST endpoints | Custom auth check in handler | Existing `requireAuth` middleware in `src/server/index.ts` | Auth middleware already gates all non-GET `/api/global-items*` after Phase 24 | + +**Key insight:** The auth middleware in `src/server/index.ts` (lines 150-170) already exempts only GET requests on `/api/global-items` from auth. POST requests on that path fall through to `requireAuth` automatically — no changes to `index.ts` needed. + +## Common Pitfalls + +### Pitfall 1: Unique Constraint Not Applied to Existing Data + +**What goes wrong:** If the database already has duplicate `(brand, model)` pairs (e.g., from early seeding), adding a unique constraint via migration will fail with a duplicate key error. + +**Why it happens:** The migration tries to add `UNIQUE(brand, model)` to a table that already has conflicting rows. + +**How to avoid:** Check for existing duplicates before generating the migration. In development with test data, truncate `global_items` or resolve duplicates first. The test database is reset between tests (`TRUNCATE ... RESTART IDENTITY CASCADE`) so tests are not affected. + +**Warning signs:** `drizzle-kit push` fails with "could not create unique index" or similar. + +### Pitfall 2: Client `GlobalItem` Interface Missing New Fields + +**What goes wrong:** `useGlobalItems.ts` defines a local `GlobalItem` interface. After the migration adds columns, the API returns new fields but the TypeScript interface doesn't include them, so the component can't render them. + +**Why it happens:** The interface at the top of `useGlobalItems.ts` (lines 3-14) is manually maintained — it's not generated from Drizzle schema types. + +**How to avoid:** Update the `GlobalItem` interface in `useGlobalItems.ts` to add `sourceUrl: string | null`, `imageCredit: string | null`, `imageSourceUrl: string | null`. The `GlobalItemWithOwnerCount` extends it and gets the fields for free. + +**Warning signs:** TypeScript error `Property 'imageCredit' does not exist on type 'GlobalItemWithOwnerCount'` in `$globalItemId.tsx`. + +### Pitfall 3: Bulk Endpoint Registered Before Auth Middleware Applies + +**What goes wrong:** `/api/global-items/bulk` is a POST endpoint. The auth middleware in `src/server/index.ts` exempts GET on `/api/global-items` (line 165-167). If the route registration order matters, the bulk POST could be accidentally exempt. + +**Why it happens:** The middleware skip check uses `c.req.path.startsWith("/api/global-items") && c.req.method === "GET"` — it's already method-gated, so POST requests are not exempt. No issue in practice. + +**How to avoid:** No special handling needed. The middleware skip condition already checks `c.req.method === "GET"`. Verify this by reading `src/server/index.ts` lines 165-167 before implementing. + +**Warning signs:** POST to `/api/global-items/bulk` returns 200 without `X-API-Key` header. + +### Pitfall 4: MCP Tool for Bulk Returns Success on Partial Failure + +**What goes wrong:** If the service function for bulk upsert silently skips failed items instead of throwing, the MCP tool returns `{ created: X, updated: Y }` even though some items were not persisted. + +**Why it happens:** Swallowing errors inside a loop without re-throwing. + +**How to avoid:** Zod validation happens at the route level (for HTTP) and at the MCP tool handler level (parse inputSchema). The service function should assume pre-validated data and let Drizzle throw naturally inside the transaction. The transaction auto-rolls-back on any thrown error. + +**Warning signs:** Bulk upsert returns counts that don't add up to the input array length, with no error. + +### Pitfall 5: `onConflictDoUpdate` Target Requires Constraint, Not Arbitrary Columns + +**What goes wrong:** Calling `.onConflictDoUpdate({ target: [globalItems.brand, globalItems.model], ... })` without a unique constraint on those columns causes a Postgres error at runtime. + +**Why it happens:** PostgreSQL's `ON CONFLICT (col1, col2) DO UPDATE` requires an existing unique index or constraint. Drizzle passes through to Postgres directly. + +**How to avoid:** The unique constraint must be added to the schema and migration applied BEFORE any upsert code runs. Always run `bun run db:push` before testing the new service functions. + +**Warning signs:** `ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification`. + +## Code Examples + +Verified patterns from the codebase: + +### Drizzle Upsert (multi-column target) + +```typescript +// Source: src/server/routes/settings.ts lines 33-37 +await database + .insert(settings) + .values({ userId, key, value: body.value }) + .onConflictDoUpdate({ + target: [settings.userId, settings.key], + set: { value: body.value }, + }); +``` + +### Drizzle Transaction + +```typescript +// Source: src/server/services/setup.service.ts line 164 +return await db.transaction(async (tx) => { + // ... multiple tx.insert / tx.select calls + // Any thrown error rolls back automatically +}); +``` + +### Hono Route with zValidator + +```typescript +// Source: src/server/routes/setups.ts line 36 +app.post("/", zValidator("json", createSetupSchema), async (c) => { + const db = c.get("db"); + const userId = c.get("userId")!; + const data = c.req.valid("json"); + // ... +}); +``` + +### MCP Tool Definition + Handler (no-userId pattern) + +```typescript +// Source: src/server/mcp/tools/images.ts — full file +export const imageToolDefinitions = [ + { + name: "upload_image_from_url", + description: "...", + inputSchema: { url: z.string().describe("...") }, + }, +]; + +export function registerImageTools() { // no db, no userId + return { + upload_image_from_url: async (args: { url: string }): Promise => { + try { + const result = await fetchImageFromUrl(args.url); + return textResult(result); + } catch (err) { + return errorResult((err as Error).message); + } + }, + }; +} +``` + +### Schema with Unique Constraint (table-level) + +```typescript +// Source: src/db/schema.ts line 26-38 (categories table — same pattern) +export const categories = pgTable( + "categories", + { + id: serial("id").primaryKey(), + // ... + }, + (table) => [unique().on(table.userId, table.name)], +); +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Separate `item_global_links` junction table | `globalItemId` FK directly on `items` | Phase migration 0002 | Simpler joins; one less table | +| No unique constraint on globalItems | `unique().on(brand, model)` | This phase | Prevents duplicate catalog entries | +| Read-only global item API | Read + write (upsert) API | This phase | Enables agent-powered seeding | + +## Open Questions + +1. **Tag replacement vs merge on upsert** + - What we know: D-09 includes `tags?: string[]` as optional field + - What's unclear: When tags are omitted on an upsert of an existing item, should existing tags be left untouched, or cleared? + - Recommendation: Leave existing tags untouched when `tags` field is absent in the payload. Only sync (replace) tags when `tags` is explicitly provided (even if empty array = clear all tags). This is the least-surprising behavior for agents that send partial updates. + +2. **`imageUrl` field on globalItems vs `imageFilename`** + - What we know: `globalItems.imageUrl` stores an absolute URL (unlike `items.imageFilename` which stores a filename for S3). The bulk upsert input accepts `imageUrl`. + - What's unclear: Should agents use `upload_image_from_url` first, then pass the returned filename as `imageUrl`? Or pass the original URL directly? + - Recommendation: Accept both — agents can pass any URL to `imageUrl`. When the agent wants to mirror the image to S3, they call `upload_image_from_url` first and use the result. The `imageSourceUrl` attribution field is separate and intended to record the original source regardless of where the image is now stored. + +## Environment Availability + +Step 2.6: SKIPPED (no external dependencies identified — this phase is purely code and schema changes within the existing stack; PostgreSQL/PGlite is already operational) + +## Validation Architecture + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework | Bun test runner (built-in) | +| Config file | none — `bun test` discovers `tests/**/*.test.ts` | +| Quick run command | `bun test tests/services/global-item.service.test.ts tests/routes/global-items.test.ts tests/mcp/tools.test.ts` | +| Full suite command | `bun test` | + +### Phase Requirements → Test Map + +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| CATL-01 | Attribution columns present and returnable | integration | `bun test tests/services/global-item.service.test.ts` | ✅ (extend) | +| CATL-02 | Duplicate (brand, model) upserts rather than errors | integration | `bun test tests/services/global-item.service.test.ts` | ✅ (extend) | +| CATL-03 | Attribution rendered in detail page | manual | Visual check in browser | — | +| CATL-04 | `POST /api/global-items/bulk` accepts array | integration | `bun test tests/routes/global-items.test.ts` | ✅ (extend) | +| CATL-05 | Bulk upsert updates on conflict, returns created/updated counts | integration | `bun test tests/routes/global-items.test.ts` | ✅ (extend) | +| SEED-01 | `upsert_catalog_item` MCP tool writes to globalItems | integration | `bun test tests/mcp/tools.test.ts` | ✅ (extend) | +| SEED-02 | `bulk_upsert_catalog` MCP tool persists all items | integration | `bun test tests/mcp/tools.test.ts` | ✅ (extend) | +| SEED-03 | Attribution fields available as MCP tool parameters | unit | `bun test tests/mcp/tools.test.ts` | ✅ (extend) | + +### Sampling Rate + +- **Per task commit:** `bun test tests/services/global-item.service.test.ts tests/routes/global-items.test.ts tests/mcp/tools.test.ts` +- **Per wave merge:** `bun test` +- **Phase gate:** Full suite green before `/gsd:verify-work` + +### Wave 0 Gaps + +None — existing test infrastructure covers all phase requirements. The three test files already exist and test the relevant modules; new test cases are added to existing `describe` blocks. + +## Project Constraints (from CLAUDE.md) + +- Use `bun run db:generate` + `bun run db:push` for all schema changes (not raw SQL) +- Prices stored as cents (`priceCents: integer`), weights as grams (`doublePrecision`) +- Services take `(db, ...)` as first argument — no HTTP awareness +- Hono routes delegate to services; use `@hono/zod-validator` for all request validation +- Shared Zod schemas live in `src/shared/schemas.ts` +- MCP tools: definitions array + register function pattern per domain +- Route tree is auto-generated — never edit `routeTree.gen.ts` +- Always reuse existing components; check `src/client/components/` before creating new UI elements +- `@/*` maps to `./src/*` +- Tabs, double quotes, organized imports (Biome lint) + +## Sources + +### Primary (HIGH confidence) + +- Direct codebase inspection: `src/db/schema.ts` — current globalItems table definition +- Direct codebase inspection: `src/server/services/global-item.service.ts` — existing read patterns +- Direct codebase inspection: `src/server/routes/global-items.ts` — existing route handlers +- Direct codebase inspection: `src/server/mcp/index.ts` — tool registration loop pattern +- Direct codebase inspection: `src/server/mcp/tools/items.ts` — tool definition + handler pattern +- Direct codebase inspection: `src/server/mcp/tools/images.ts` — no-userId factory pattern +- Direct codebase inspection: `src/server/routes/settings.ts` — multi-column `onConflictDoUpdate` pattern +- Direct codebase inspection: `src/server/services/auth.service.ts` — single-column `onConflictDoUpdate` pattern +- Direct codebase inspection: `src/server/services/setup.service.ts` — `db.transaction()` pattern +- Direct codebase inspection: `src/server/index.ts` — auth middleware skip logic for global-items GET +- Direct codebase inspection: `tests/helpers/db.ts` — PGlite test setup with migrations +- Direct codebase inspection: `tests/services/global-item.service.test.ts` — existing test structure +- Direct codebase inspection: `tests/routes/global-items.test.ts` — existing route test structure +- Direct codebase inspection: `tests/mcp/tools.test.ts` — MCP tool test structure + +### Secondary (MEDIUM confidence) + +- drizzle-orm 0.45.1 installed version confirmed via `bun pm ls` — upsert API stable since 0.28 + +## Metadata + +**Confidence breakdown:** + +- Standard stack: HIGH — all libraries confirmed installed; APIs confirmed in use +- Architecture: HIGH — all patterns directly observed in codebase, not assumed +- Pitfalls: HIGH — derived from reading the actual middleware skip logic and schema constraints + +**Research date:** 2026-04-10 +**Valid until:** 2026-05-10 (stable stack, no fast-moving dependencies)