# Catalog Schema Migration Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace `globalItems.brand` text field with a normalized `manufacturers` table, wiring all services, routes, MCP tools, and seed data to use the new FK. **Architecture:** Add `manufacturers` table → migrate `globalItems` to drop `brand` and add `manufacturerId` FK → update every service, route, MCP tool, and seed that references `globalItems.brand`. API responses keep returning a `brand` string (populated via join) so client components need no changes. API inputs replace `brand: string` with `manufacturerSlug: string` for ergonomic upserts. **Tech Stack:** Drizzle ORM + PostgreSQL (PGlite in tests), Hono, Zod, Bun test runner. --- ## File Map | Action | File | |--------|------| | Modify | `src/db/schema.ts` — add manufacturers table, update globalItems | | Create | `src/server/services/manufacturer.service.ts` | | Create | `src/server/routes/manufacturers.ts` | | Modify | `src/server/index.ts` — register manufacturers route | | Modify | `src/server/services/global-item.service.ts` — upsert + search use manufacturerId | | Modify | `src/shared/schemas.ts` — replace brand with manufacturerSlug in upsert schemas | | Modify | `src/shared/types.ts` — re-derive UpsertGlobalItemInput | | Modify | `src/server/services/item.service.ts` — join manufacturers, replace brand ref | | Modify | `src/server/services/setup.service.ts` — join manufacturers, replace brand ref | | Modify | `src/server/services/discovery.service.ts` — join manufacturers, replace brand ref | | Modify | `src/server/services/profile.service.ts` — join manufacturers, replace brand ref | | Modify | `src/server/services/csv.service.ts` — join manufacturers, replace brand ref | | Modify | `src/server/services/thread.service.ts` — join manufacturers, replace brand ref | | Modify | `src/server/mcp/tools/catalog.ts` — replace brand with manufacturerSlug | | Modify | `src/db/seed-global-items.ts` — add seedManufacturers, update seedGlobalItems | | Modify | `src/db/global-items-seed.json` — replace brand with manufacturerSlug | | Modify | `src/db/dev-seed-data.ts` — add DEV_MANUFACTURERS, update DEV_GLOBAL_ITEMS | | Modify | `src/db/dev-seed.ts` — insert manufacturers before globalItems | | Modify | `tests/helpers/db.ts` — add manufacturers to TRUNCATE_TABLES | | Create | `tests/services/manufacturer.service.test.ts` | | Modify | `tests/services/global-item.service.test.ts` — update insertGlobalItem helper + tests | --- ## Task 1: Add `manufacturers` table to schema **Files:** - Modify: `src/db/schema.ts` - [ ] **Step 1: Add manufacturers table to schema.ts** Open `src/db/schema.ts`. After the `users` table and before `categories`, insert: ```typescript // ── Manufacturers ──────────────────────────────────────────────────── export const manufacturers = pgTable("manufacturers", { id: serial("id").primaryKey(), name: text("name").notNull().unique(), slug: text("slug").notNull().unique(), website: text("website").notNull(), tier: integer("tier").notNull().default(1), active: boolean("active").notNull().default(true), country: text("country"), createdAt: timestamp("created_at").defaultNow().notNull(), }); ``` Add `boolean` to the import at the top: ```typescript import { boolean, doublePrecision, integer, pgTable, primaryKey, serial, text, timestamp, unique, } from "drizzle-orm/pg-core"; ``` - [ ] **Step 2: Generate and push the migration** ```bash bun run db:generate bun run db:push ``` Expected: new `manufacturers` table created with no errors. - [ ] **Step 3: Commit** ```bash git add src/db/schema.ts drizzle-pg/ git commit -m "feat: add manufacturers table to schema" ``` --- ## Task 2: Manufacturer service **Files:** - Create: `src/server/services/manufacturer.service.ts` - Create: `tests/services/manufacturer.service.test.ts` - [ ] **Step 1: Write the failing tests** Create `tests/services/manufacturer.service.test.ts`: ```typescript import { beforeEach, describe, expect, it } from "bun:test"; import { manufacturers } from "../../src/db/schema.ts"; import { createManufacturer, getManufacturerBySlug, listManufacturers, } from "../../src/server/services/manufacturer.service.ts"; import { createTestDb } from "../helpers/db.ts"; let db: Awaited>["db"]; beforeEach(async () => { ({ db } = await createTestDb()); }); describe("createManufacturer", () => { it("inserts a manufacturer and returns it", async () => { const result = await createManufacturer(db, { name: "Apidura", slug: "apidura", website: "https://apidura.com", tier: 1, country: "GB", }); expect(result.id).toBeGreaterThan(0); expect(result.name).toBe("Apidura"); expect(result.slug).toBe("apidura"); expect(result.active).toBe(true); }); it("throws on duplicate slug", async () => { await createManufacturer(db, { name: "Apidura", slug: "apidura", website: "https://apidura.com", }); await expect( createManufacturer(db, { name: "Apidura Copy", slug: "apidura", website: "https://other.com", }), ).rejects.toThrow(); }); }); describe("getManufacturerBySlug", () => { it("returns manufacturer when found", async () => { await createManufacturer(db, { name: "Revelate Designs", slug: "revelate-designs", website: "https://revelatedesigns.com", }); const result = await getManufacturerBySlug(db, "revelate-designs"); expect(result?.name).toBe("Revelate Designs"); }); it("returns null when not found", async () => { const result = await getManufacturerBySlug(db, "nope"); expect(result).toBeNull(); }); }); describe("listManufacturers", () => { it("returns all manufacturers ordered by name", async () => { await createManufacturer(db, { name: "Ortlieb", slug: "ortlieb", website: "https://ortlieb.com" }); await createManufacturer(db, { name: "Apidura", slug: "apidura", website: "https://apidura.com" }); const result = await listManufacturers(db); expect(result[0]?.name).toBe("Apidura"); expect(result[1]?.name).toBe("Ortlieb"); }); }); ``` - [ ] **Step 2: Run tests to confirm they fail** ```bash bun test tests/services/manufacturer.service.test.ts ``` Expected: FAIL — module not found. - [ ] **Step 3: Also add `manufacturers` to the TRUNCATE_TABLES list in `tests/helpers/db.ts`** In `tests/helpers/db.ts`, add `"manufacturers"` before `"users"`: ```typescript const TRUNCATE_TABLES = [ "shares", "setup_items", "setups", "thread_candidates", "threads", "community_prices", "market_prices", "items", "global_item_tags", "global_items", "tags", "oauth_tokens", "oauth_codes", "oauth_clients", "api_keys", "settings", "categories", "manufacturers", "users", ]; ``` - [ ] **Step 4: Create `src/server/services/manufacturer.service.ts`** ```typescript import { asc, eq } from "drizzle-orm"; import { db as prodDb } from "../../db/index.ts"; import { manufacturers } from "../../db/schema.ts"; type Db = typeof prodDb; export type CreateManufacturerInput = { name: string; slug: string; website: string; tier?: number; country?: string; }; export async function listManufacturers(db: Db = prodDb) { return db.select().from(manufacturers).orderBy(asc(manufacturers.name)); } export async function getManufacturerBySlug(db: Db = prodDb, slug: string) { const [row] = await db .select() .from(manufacturers) .where(eq(manufacturers.slug, slug)); return row ?? null; } export async function createManufacturer( db: Db = prodDb, data: CreateManufacturerInput, ) { const [row] = await db .insert(manufacturers) .values({ name: data.name, slug: data.slug, website: data.website, tier: data.tier ?? 1, country: data.country ?? null, }) .returning(); return row!; } ``` - [ ] **Step 5: Run tests to confirm they pass** ```bash bun test tests/services/manufacturer.service.test.ts ``` Expected: PASS (3 test suites, all green). - [ ] **Step 6: Commit** ```bash git add src/server/services/manufacturer.service.ts tests/services/manufacturer.service.test.ts tests/helpers/db.ts git commit -m "feat: manufacturer service with list, get, create" ``` --- ## Task 3: Manufacturers API route **Files:** - Create: `src/server/routes/manufacturers.ts` - Modify: `src/server/index.ts` - Modify: `src/shared/schemas.ts` - [ ] **Step 1: Add Zod schema for manufacturer creation to `src/shared/schemas.ts`** Append after the existing global item schemas (around line 147): ```typescript export const createManufacturerSchema = z.object({ name: z.string().min(1).max(200), slug: z .string() .min(1) .max(100) .regex(/^[a-z0-9-]+$/, "Slug must be lowercase alphanumeric with hyphens"), website: z.string().url(), tier: z.number().int().min(1).max(3).optional(), country: z.string().length(2).optional(), }); ``` - [ ] **Step 2: Create `src/server/routes/manufacturers.ts`** ```typescript import { zValidator } from "@hono/zod-validator"; import { Hono } from "hono"; import { createManufacturerSchema } from "../../shared/schemas.ts"; import { createManufacturer, getManufacturerBySlug, listManufacturers, } from "../services/manufacturer.service.ts"; type Env = { Variables: { db?: any } }; const app = new Hono(); app.get("/", async (c) => { const db = c.get("db"); return c.json(await listManufacturers(db)); }); app.get("/:slug", async (c) => { const db = c.get("db"); const slug = c.req.param("slug"); const manufacturer = await getManufacturerBySlug(db, slug); if (!manufacturer) return c.json({ error: "Manufacturer not found" }, 404); return c.json(manufacturer); }); app.post("/", zValidator("json", createManufacturerSchema), async (c) => { const db = c.get("db"); const data = c.req.valid("json"); try { const manufacturer = await createManufacturer(db, data); return c.json(manufacturer, 201); } catch { return c.json({ error: "Manufacturer with this name or slug already exists" }, 409); } }); export { app as manufacturerRoutes }; ``` - [ ] **Step 3: Register the route in `src/server/index.ts`** Find the existing import of `globalItemRoutes` and add alongside it: ```typescript import { manufacturerRoutes } from "./routes/manufacturers.ts"; ``` Find where `globalItemRoutes` is registered (around line 292) and add below it: ```typescript app.route("/api/manufacturers", manufacturerRoutes); ``` Note: GET routes are public (no auth middleware needed — manufacturers are read-only public data). POST is protected by the existing auth middleware that covers all POST/PUT/DELETE on `/api/*`. - [ ] **Step 4: Commit** ```bash git add src/server/routes/manufacturers.ts src/server/index.ts src/shared/schemas.ts git commit -m "feat: manufacturers route — list, get, create" ``` --- ## Task 4: Seed manufacturers **Files:** - Modify: `src/db/seed-global-items.ts` - [ ] **Step 1: Add `seedManufacturers` to `src/db/seed-global-items.ts`** Replace the full contents of `src/db/seed-global-items.ts` with: ```typescript import seedData from "./global-items-seed.json"; import { db as prodDb } from "./index.ts"; import { globalItems, manufacturers, tags } from "./schema.ts"; type Db = typeof prodDb; export const SEED_MANUFACTURERS = [ { name: "Revelate Designs", slug: "revelate-designs", website: "https://revelatedesigns.com", country: "US", tier: 1 }, { name: "Apidura", slug: "apidura", website: "https://apidura.com", country: "GB", tier: 1 }, { name: "Ortlieb", slug: "ortlieb", website: "https://ortlieb.com", country: "DE", tier: 1 }, { name: "Big Agnes", slug: "big-agnes", website: "https://bigagnes.com", country: "US", tier: 1 }, { name: "Tarptent", slug: "tarptent", website: "https://tarptent.com", country: "US", tier: 1 }, { name: "Zpacks", slug: "zpacks", website: "https://zpacks.com", country: "US", tier: 1 }, { name: "Sea to Summit", slug: "sea-to-summit", website: "https://seatosummit.com", country: "AU", tier: 1 }, { name: "Western Mountaineering", slug: "western-mountaineering", website: "https://westernmountaineering.com", country: "US", tier: 1 }, { name: "MSR", slug: "msr", website: "https://msrgear.com", country: "US", tier: 1 }, { name: "BioLite", slug: "biolite", website: "https://bioliteenergy.com", country: "US", tier: 1 }, { name: "Petzl", slug: "petzl", website: "https://petzl.com", country: "FR", tier: 1 }, { name: "Black Diamond", slug: "black-diamond", website: "https://blackdiamondequipment.com", country: "US", tier: 1 }, { name: "Garmin", slug: "garmin", website: "https://garmin.com", country: "US", tier: 1 }, { name: "Wahoo", slug: "wahoo", website: "https://wahoofitness.com", country: "US", tier: 1 }, { name: "Sawyer", slug: "sawyer", website: "https://sawyerproducts.com", country: "US", tier: 1 }, { name: "Canyon", slug: "canyon", website: "https://canyon.com", country: "DE", tier: 1 }, { name: "Specialized", slug: "specialized", website: "https://specialized.com", country: "US", tier: 1 }, { name: "Trek", slug: "trek", website: "https://trekbikes.com", country: "US", tier: 1 }, { name: "Salsa Cycles", slug: "salsa-cycles", website: "https://salsacycles.com", country: "US", tier: 1 }, { name: "Surly", slug: "surly", website: "https://surlybikes.com", country: "US", tier: 1 }, ]; const SEED_TAGS = [ "bikepacking", "cycling", "hiking", "backpacking", "camping", "climbing", "mountaineering", "road-cycling", "gravel", "running", "trail-running", "handlebar-bag", "framebag", "saddlebag", "top-tube-bag", "stem-bag", "fork-bag", "feed-bag", "dry-bag", "stuff-sack", "bike-bag", "tent", "bivy", "tarp", "hammock", "sleeping-bag", "sleeping-pad", "quilt", "pillow", "stove", "cookware", "mug", "utensils", "water-filter", "water-bottle", "headlamp", "bike-light", "lantern", "gps", "bike-computer", "power-bank", "solar-panel", "multi-tool", "pump", "repair-kit", "lock", "rain-jacket", "base-layer", "gloves", "shoe", ]; export async function seedManufacturers(db: Db = prodDb) { for (const m of SEED_MANUFACTURERS) { await db .insert(manufacturers) .values(m) .onConflictDoNothing(); } } export async function seedTags(db: Db = prodDb) { const existing = await db.select().from(tags); const existingNames = new Set(existing.map((t) => t.name)); for (const name of SEED_TAGS) { if (!existingNames.has(name)) { await db.insert(tags).values({ name }); } } } export async function seedGlobalItems(db: Db = prodDb) { await seedManufacturers(db); const existing = await db.select().from(globalItems).limit(1); if (existing.length > 0) return; const allManufacturers = await db.select().from(manufacturers); const mfByName = new Map(allManufacturers.map((m) => [m.name, m.id])); for (const item of seedData) { const manufacturerId = mfByName.get(item.brand); if (!manufacturerId) continue; // skip items with no matching manufacturer await db.insert(globalItems).values({ manufacturerId, model: item.model, category: item.category ?? null, weightGrams: item.weightGrams ?? null, priceCents: item.priceCents ?? null, description: item.description ?? null, }); } await seedTags(db); } ``` - [ ] **Step 2: Commit** ```bash git add src/db/seed-global-items.ts git commit -m "feat: seed manufacturers list, update seedGlobalItems to resolve by name" ``` --- ## Task 5: Migrate `globalItems` — drop brand, add manufacturerId **Files:** - Modify: `src/db/schema.ts` - [ ] **Step 1: Update `globalItems` in `src/db/schema.ts`** Find the `globalItems` table. Remove the `brand` column and add `manufacturerId`. Change the unique constraint. The updated table definition: ```typescript export const globalItems = pgTable( "global_items", { id: serial("id").primaryKey(), manufacturerId: integer("manufacturer_id") .notNull() .references(() => manufacturers.id), model: text("model").notNull(), category: text("category"), weightGrams: doublePrecision("weight_grams"), priceCents: integer("price_cents"), imageUrl: text("image_url"), description: text("description"), sourceUrl: text("source_url"), imageCredit: text("image_credit"), imageSourceUrl: text("image_source_url"), dominantColor: text("dominant_color"), cropZoom: doublePrecision("crop_zoom"), cropX: doublePrecision("crop_x"), cropY: doublePrecision("crop_y"), createdAt: timestamp("created_at").defaultNow().notNull(), }, (table) => [unique().on(table.manufacturerId, table.model)], ); ``` - [ ] **Step 2: Generate and push migration** ```bash bun run db:generate bun run db:push ``` If the push fails due to existing data violating `NOT NULL`, that's expected in dev — clear the table first: ```bash bun run db:push --force-reset # or connect to the DB and: TRUNCATE global_items CASCADE; ``` Then re-push. - [ ] **Step 3: Commit** ```bash git add src/db/schema.ts drizzle-pg/ git commit -m "feat: migrate globalItems — drop brand text, add manufacturerId FK" ``` --- ## Task 6: Update `global-item.service.ts` **Files:** - Modify: `src/server/services/global-item.service.ts` - Modify: `tests/services/global-item.service.test.ts` - [ ] **Step 1: Update the test helper and tests in `tests/services/global-item.service.test.ts`** Replace the `insertGlobalItem` helper at the top of the file: ```typescript async function insertManufacturer(db: TestDb["db"], name = "Apidura", slug = "apidura") { const [row] = await db .insert(schema.manufacturers) .values({ name, slug, website: `https://${slug}.com` }) .returning(); return row!; } async function insertGlobalItem( db: TestDb["db"], data: { manufacturerId: number; model: string; category?: string; weightGrams?: number; priceCents?: number; }, ) { const [row] = await db .insert(globalItems) .values({ manufacturerId: data.manufacturerId, model: data.model, category: data.category ?? null, weightGrams: data.weightGrams ?? null, priceCents: data.priceCents ?? null, }) .returning(); return row!; } ``` Also update all test cases that pass `brand:` to pass `manufacturerSlug:` and set up a manufacturer before inserting global items. - [ ] **Step 2: Run tests to confirm they fail** ```bash bun test tests/services/global-item.service.test.ts ``` Expected: FAIL — type errors or runtime errors on `brand` field. - [ ] **Step 3: Rewrite `src/server/services/global-item.service.ts`** ```typescript import type { SQL } from "drizzle-orm"; import { and, count, eq, ilike, or, sql } from "drizzle-orm"; import { db as prodDb } from "../../db/index.ts"; import { globalItems, globalItemTags, items, manufacturers, tags } from "../../db/schema.ts"; type Db = typeof prodDb; type TxDb = Parameters[0]>[0]; async function resolveManufacturerId(db: Db | TxDb, slug: string): Promise { const [m] = await (db as Db) .select({ id: manufacturers.id }) .from(manufacturers) .where(eq(manufacturers.slug, slug)); if (!m) throw new Error(`Manufacturer not found: ${slug}`); return m.id; } export async function searchGlobalItems( db: Db = prodDb, query?: string, tagNames?: string[], ) { const conditions: SQL[] = []; if (query) { const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_"); const pattern = `%${escaped}%`; conditions.push( or(ilike(manufacturers.name, pattern), ilike(globalItems.model, pattern))!, ); } if (tagNames && tagNames.length > 0) { conditions.push( sql`${globalItems.id} IN ( SELECT ${globalItemTags.globalItemId} FROM ${globalItemTags} JOIN ${tags} ON ${tags.id} = ${globalItemTags.tagId} WHERE ${tags.name} IN (${sql.join( tagNames.map((t) => sql`${t}`), sql`, `, )}) GROUP BY ${globalItemTags.globalItemId} HAVING COUNT(DISTINCT ${tags.name}) = ${tagNames.length} )`, ); } const baseQuery = db .select({ id: globalItems.id, manufacturerId: globalItems.manufacturerId, brand: manufacturers.name, model: globalItems.model, category: globalItems.category, weightGrams: globalItems.weightGrams, priceCents: globalItems.priceCents, imageUrl: globalItems.imageUrl, description: globalItems.description, sourceUrl: globalItems.sourceUrl, imageCredit: globalItems.imageCredit, imageSourceUrl: globalItems.imageSourceUrl, dominantColor: globalItems.dominantColor, cropZoom: globalItems.cropZoom, cropX: globalItems.cropX, cropY: globalItems.cropY, createdAt: globalItems.createdAt, }) .from(globalItems) .innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id)); if (conditions.length === 0) { return baseQuery; } return baseQuery.where(and(...conditions)); } export async function getGlobalItemWithOwnerCount(db: Db = prodDb, id: number) { const [item] = await db .select({ id: globalItems.id, manufacturerId: globalItems.manufacturerId, brand: manufacturers.name, model: globalItems.model, category: globalItems.category, weightGrams: globalItems.weightGrams, priceCents: globalItems.priceCents, imageUrl: globalItems.imageUrl, description: globalItems.description, sourceUrl: globalItems.sourceUrl, imageCredit: globalItems.imageCredit, imageSourceUrl: globalItems.imageSourceUrl, dominantColor: globalItems.dominantColor, cropZoom: globalItems.cropZoom, cropX: globalItems.cropX, cropY: globalItems.cropY, createdAt: globalItems.createdAt, }) .from(globalItems) .innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id)) .where(eq(globalItems.id, id)); if (!item) return null; const [result] = await db .select({ ownerCount: count() }) .from(items) .where(eq(items.globalItemId, id)); return { ...item, ownerCount: result?.ownerCount ?? 0 }; } async function syncGlobalItemTags( tx: TxDb, globalItemId: number, tagNames: string[], ) { await tx .delete(globalItemTags) .where(eq(globalItemTags.globalItemId, globalItemId)); for (const name of tagNames) { 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 }); } } export async function upsertGlobalItem( db: Db, data: { manufacturerSlug: string; model: string; category?: string; weightGrams?: number; priceCents?: number; imageUrl?: string; description?: string; sourceUrl?: string; imageCredit?: string; imageSourceUrl?: string; tags?: string[]; }, ) { const manufacturerId = await resolveManufacturerId(db, data.manufacturerSlug); return await db.transaction(async (tx) => { const [existing] = await tx .select({ id: globalItems.id }) .from(globalItems) .where( and( eq(globalItems.manufacturerId, manufacturerId), eq(globalItems.model, data.model), ), ); const { tags: tagNames, manufacturerSlug: _slug, ...itemData } = data; const [item] = await tx .insert(globalItems) .values({ manufacturerId, model: itemData.model, category: itemData.category ?? null, weightGrams: itemData.weightGrams ?? null, priceCents: itemData.priceCents ?? null, imageUrl: itemData.imageUrl ?? null, description: itemData.description ?? null, sourceUrl: itemData.sourceUrl ?? null, imageCredit: itemData.imageCredit ?? null, imageSourceUrl: itemData.imageSourceUrl ?? null, }) .onConflictDoUpdate({ target: [globalItems.manufacturerId, globalItems.model], set: { category: itemData.category ?? null, weightGrams: itemData.weightGrams ?? null, priceCents: itemData.priceCents ?? null, imageUrl: itemData.imageUrl ?? null, description: itemData.description ?? null, sourceUrl: itemData.sourceUrl ?? null, imageCredit: itemData.imageCredit ?? null, imageSourceUrl: itemData.imageSourceUrl ?? null, }, }) .returning(); if (tagNames !== undefined) { await syncGlobalItemTags(tx, item!.id, tagNames); } return { item: item!, created: !existing }; }); } export async function bulkUpsertGlobalItems( db: Db, itemsData: Array<{ manufacturerSlug: string; model: string; category?: string; weightGrams?: number; priceCents?: number; imageUrl?: string; description?: string; sourceUrl?: string; imageCredit?: string; imageSourceUrl?: string; tags?: string[]; }>, ) { return await db.transaction(async (tx) => { let created = 0; let updated = 0; const resultItems = []; for (const data of itemsData) { const manufacturerId = await resolveManufacturerId(db, data.manufacturerSlug); const [existing] = await tx .select({ id: globalItems.id }) .from(globalItems) .where( and( eq(globalItems.manufacturerId, manufacturerId), eq(globalItems.model, data.model), ), ); const { tags: tagNames, manufacturerSlug: _slug, ...itemData } = data; const [item] = await tx .insert(globalItems) .values({ manufacturerId, model: itemData.model, category: itemData.category ?? null, weightGrams: itemData.weightGrams ?? null, priceCents: itemData.priceCents ?? null, imageUrl: itemData.imageUrl ?? null, description: itemData.description ?? null, sourceUrl: itemData.sourceUrl ?? null, imageCredit: itemData.imageCredit ?? null, imageSourceUrl: itemData.imageSourceUrl ?? null, }) .onConflictDoUpdate({ target: [globalItems.manufacturerId, globalItems.model], set: { category: itemData.category ?? null, weightGrams: itemData.weightGrams ?? null, priceCents: itemData.priceCents ?? null, imageUrl: itemData.imageUrl ?? null, description: itemData.description ?? null, sourceUrl: itemData.sourceUrl ?? null, imageCredit: itemData.imageCredit ?? null, imageSourceUrl: itemData.imageSourceUrl ?? null, }, }) .returning(); if (tagNames !== undefined) { await syncGlobalItemTags(tx, item!.id, tagNames); } if (existing) { updated++; } else { created++; } resultItems.push(item!); } return { created, updated, items: resultItems }; }); } ``` - [ ] **Step 4: Run tests** ```bash bun test tests/services/global-item.service.test.ts ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/server/services/global-item.service.ts tests/services/global-item.service.test.ts git commit -m "feat: global-item service uses manufacturerSlug, joins manufacturers for brand" ``` --- ## Task 7: Update Zod schemas and types **Files:** - Modify: `src/shared/schemas.ts` - Modify: `src/shared/types.ts` - [ ] **Step 1: Update `upsertGlobalItemSchema` in `src/shared/schemas.ts`** Replace the `brand` field with `manufacturerSlug`: ```typescript export const upsertGlobalItemSchema = z.object({ manufacturerSlug: z.string().min(1, "Manufacturer slug is required"), model: z.string().min(1, "Model is required"), category: z.string().optional(), weightGrams: z.number().nonnegative().optional(), priceCents: z.number().int().nonnegative().optional(), imageUrl: z.string().url().optional().or(z.literal("")), description: z.string().optional(), sourceUrl: z.string().url().optional().or(z.literal("")), imageCredit: z.string().optional(), imageSourceUrl: z.string().url().optional().or(z.literal("")), tags: z.array(z.string().min(1).max(100)).max(20).optional(), dominantColor: z.string().nullable().optional(), cropZoom: z.number().nullable().optional(), cropX: z.number().nullable().optional(), cropY: z.number().nullable().optional(), }); ``` `bulkUpsertGlobalItemsSchema` references `upsertGlobalItemSchema` and needs no direct change. - [ ] **Step 2: Check `src/shared/types.ts` for any hardcoded `brand` references in global item types** Open `src/shared/types.ts`. If `UpsertGlobalItemInput` or `GlobalItem` is manually typed (not inferred), update to use `manufacturerSlug` / `manufacturerId`. If these types are inferred via `z.infer<>` from the Zod schemas and Drizzle, they will update automatically. - [ ] **Step 3: Commit** ```bash git add src/shared/schemas.ts src/shared/types.ts git commit -m "feat: upsertGlobalItemSchema — brand → manufacturerSlug" ``` --- ## Task 8: Update `item.service.ts` **Files:** - Modify: `src/server/services/item.service.ts` - [ ] **Step 1: Add manufacturers to imports and joins** In `src/server/services/item.service.ts`, update the import: ```typescript import { categories, globalItems, items, manufacturers } from "../../db/schema.ts"; ``` In `getAllItems`, add a left join to manufacturers after the globalItems join: ```typescript .leftJoin(globalItems, eq(items.globalItemId, globalItems.id)) .leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id)) ``` Replace the two `globalItems.brand` references: ```typescript // name computation: was globalItems.brand || ' ' || globalItems.model name: sql`COALESCE( CASE WHEN ${items.globalItemId} IS NOT NULL THEN ${manufacturers.name} || ' ' || ${globalItems.model} ELSE ${items.name} END, ${items.name} )`.as("name"), // brand field: was COALESCE(globalItems.brand, items.brand) brand: sql`COALESCE(${manufacturers.name}, ${items.brand})`.as("brand"), ``` Apply the same two replacements in `getItemById` (same patterns, same file). - [ ] **Step 2: Update `createItem` function** The `createItem` function fetches brand+model from globalItems to build the item name. Update the select: ```typescript const [gi] = await db .select({ name: manufacturers.name, model: globalItems.model }) .from(globalItems) .innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id)) .where(eq(globalItems.id, data.globalItemId)); if (gi) { name = `${gi.name} ${gi.model}`; } ``` - [ ] **Step 3: Commit** ```bash git add src/server/services/item.service.ts git commit -m "feat: item service joins manufacturers for brand display" ``` --- ## Task 9: Update remaining services **Files:** - Modify: `src/server/services/setup.service.ts` - Modify: `src/server/services/discovery.service.ts` - Modify: `src/server/services/profile.service.ts` - Modify: `src/server/services/csv.service.ts` - Modify: `src/server/services/thread.service.ts` The pattern is the same in all five files. For each: 1. Add `manufacturers` to the schema import 2. Add `.leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))` after every `.leftJoin(globalItems, ...)` 3. Replace every `${globalItems.brand}` with `${manufacturers.name}` - [ ] **Step 1: `src/server/services/setup.service.ts`** Add to import: ```typescript import { ..., manufacturers } from "../../db/schema.ts"; ``` The file has three query functions (around lines 82, 134, 188). In each, add manufacturers join after the globalItems join: ```typescript .leftJoin(globalItems, eq(items.globalItemId, globalItems.id)) .leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id)) ``` Replace all four `${globalItems.brand}` occurrences: - Lines ~82, 134, 188: `THEN ${globalItems.brand} || ' ' || ${globalItems.model}` → `THEN ${manufacturers.name} || ' ' || ${globalItems.model}` - Line ~195: `THEN ${globalItems.brand} ELSE ${items.brand} END` → `THEN ${manufacturers.name} ELSE ${items.brand} END` - [ ] **Step 2: `src/server/services/discovery.service.ts`** Add to import: ```typescript import { ..., manufacturers } from "../../db/schema.ts"; ``` In `getPopularItemsByTags` (around line 160), add manufacturers join. This query starts FROM globalItems, so use innerJoin (globalItems always has a manufacturer): ```typescript .from(globalItems) .innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id)) .innerJoin(globalItemTags, ...) ``` Replace `brand: globalItems.brand` in the select: ```typescript brand: manufacturers.name, ``` - [ ] **Step 3: `src/server/services/profile.service.ts`** Add to import: ```typescript import { ..., manufacturers } from "../../db/schema.ts"; ``` Add manufacturers join after the globalItems join and replace `${globalItems.brand}` → `${manufacturers.name}` (same pattern as setup.service.ts). - [ ] **Step 4: `src/server/services/csv.service.ts`** Add to import and add manufacturers join after globalItems join. Replace `${globalItems.brand}` → `${manufacturers.name}`. - [ ] **Step 5: `src/server/services/thread.service.ts`** This service joins from `threadCandidates` and uses `leftJoin(globalItems, eq(threadCandidates.globalItemId, globalItems.id))`. Add: ```typescript .leftJoin(globalItems, eq(threadCandidates.globalItemId, globalItems.id)) .leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id)) ``` Replace `${globalItems.brand}` → `${manufacturers.name}`. - [ ] **Step 6: Commit** ```bash git add src/server/services/setup.service.ts src/server/services/discovery.service.ts src/server/services/profile.service.ts src/server/services/csv.service.ts src/server/services/thread.service.ts git commit -m "feat: all services join manufacturers for global item brand display" ``` --- ## Task 10: Update MCP catalog tools **Files:** - Modify: `src/server/mcp/tools/catalog.ts` - [ ] **Step 1: Replace `brand` with `manufacturerSlug` in `catalogItemInputSchema`** In `src/server/mcp/tools/catalog.ts`, update `catalogItemInputSchema`: ```typescript const catalogItemInputSchema = { manufacturerSlug: z .string() .describe("Manufacturer slug (e.g. 'apidura', 'revelate-designs') — must exist in the manufacturers table"), model: z .string() .describe("Model name — combined with manufacturerSlug forms the unique identifier"), category: z .string() .optional() .describe("Category name (e.g., 'bags', 'shelters', 'sleep')"), weightGrams: z.number().optional().describe("Weight in grams"), priceCents: z .number() .optional() .describe("MSRP price in cents (e.g., 9999 = €99.99)"), imageUrl: z.string().optional().describe("URL to the product image"), description: z.string().optional().describe("Product description"), sourceUrl: z .string() .optional() .describe("URL to the product page on manufacturer/retailer site"), imageCredit: z .string() .optional() .describe("Image credit — photographer or source name"), imageSourceUrl: z .string() .optional() .describe("Original URL where the image was sourced from"), tags: z .array(z.string()) .optional() .describe("Tags for categorization (created automatically if new)"), }; ``` Update the handler type annotations: replace `brand: string` with `manufacturerSlug: string` in both `upsert_catalog_item` and `bulk_upsert_catalog` handler args types. Update the tool descriptions to mention slugs: - `upsert_catalog_item`: "...identified by (manufacturerSlug, model)..." - `bulk_upsert_catalog`: "...upserted on (manufacturerSlug, model) uniqueness..." - [ ] **Step 2: Commit** ```bash git add src/server/mcp/tools/catalog.ts git commit -m "feat: MCP catalog tools use manufacturerSlug instead of brand" ``` --- ## Task 11: Update dev seed data **Files:** - Modify: `src/db/dev-seed-data.ts` - Modify: `src/db/dev-seed.ts` - Modify: `src/db/global-items-seed.json` - [ ] **Step 1: Add `DEV_MANUFACTURERS` to `src/db/dev-seed-data.ts`** At the top of the file, add: ```typescript export const DEV_MANUFACTURERS = [ { name: "Revelate Designs", slug: "revelate-designs", website: "https://revelatedesigns.com", country: "US", tier: 1 as const }, { name: "Apidura", slug: "apidura", website: "https://apidura.com", country: "GB", tier: 1 as const }, { name: "Ortlieb", slug: "ortlieb", website: "https://ortlieb.com", country: "DE", tier: 1 as const }, { name: "Big Agnes", slug: "big-agnes", website: "https://bigagnes.com", country: "US", tier: 1 as const }, { name: "Tarptent", slug: "tarptent", website: "https://tarptent.com", country: "US", tier: 1 as const }, { name: "Zpacks", slug: "zpacks", website: "https://zpacks.com", country: "US", tier: 1 as const }, { name: "Sea to Summit", slug: "sea-to-summit", website: "https://seatosummit.com", country: "AU", tier: 1 as const }, { name: "Western Mountaineering", slug: "western-mountaineering", website: "https://westernmountaineering.com", country: "US", tier: 1 as const }, { name: "MSR", slug: "msr", website: "https://msrgear.com", country: "US", tier: 1 as const }, { name: "Petzl", slug: "petzl", website: "https://petzl.com", country: "FR", tier: 1 as const }, { name: "Black Diamond", slug: "black-diamond", website: "https://blackdiamondequipment.com", country: "US", tier: 1 as const }, { name: "Garmin", slug: "garmin", website: "https://garmin.com", country: "US", tier: 1 as const }, { name: "Wahoo", slug: "wahoo", website: "https://wahoofitness.com", country: "US", tier: 1 as const }, { name: "Sawyer", slug: "sawyer", website: "https://sawyerproducts.com", country: "US", tier: 1 as const }, { name: "BioLite", slug: "biolite", website: "https://bioliteenergy.com", country: "US", tier: 1 as const }, ] as const; ``` - [ ] **Step 2: Update `DEV_GLOBAL_ITEMS` in `src/db/dev-seed-data.ts`** Replace the `brand` field with `manufacturerSlug` on every entry. Example for the first few entries: ```typescript export const DEV_GLOBAL_ITEMS = [ // Bags (indices 0-5) { manufacturerSlug: "revelate-designs", model: "Terrapin System", category: "bags", weightGrams: 529, priceCents: 18500, description: "Waterproof saddle bag with 14L capacity, roll-top closure, and integrated seat bag mount.", }, { manufacturerSlug: "apidura", model: "Expedition Handlebar Pack", category: "bags", weightGrams: 300, priceCents: 16000, description: "14L waterproof handlebar roll bag with internal dry bag and accessory pocket.", }, { manufacturerSlug: "ortlieb", model: "Frame-Pack RC", category: "bags", weightGrams: 250, priceCents: 12000, description: "6L waterproof roll-closure frame bag with TIZIP zipper for full-frame bikes.", }, // ... continue for all entries, replacing brand with manufacturerSlug ]; ``` Apply `brand → manufacturerSlug` for every entry in the array. - [ ] **Step 3: Update `src/db/dev-seed.ts`** to insert manufacturers first In `seedDevData`, before step 1 (seed global items), add: ```typescript // ── 0. Insert dev manufacturers ──────────────────────────────── for (const m of DEV_MANUFACTURERS) { await database .insert(schema.manufacturers) .values(m) .onConflictDoNothing(); } console.log(` ${DEV_MANUFACTURERS.length} manufacturers seeded.`); ``` Also add `DEV_MANUFACTURERS` to the import from `./dev-seed-data.ts`. In step 5 (insert global items), update the insertion block to use `manufacturerSlug`: ```typescript for (const item of DEV_GLOBAL_ITEMS) { const key = `${item.manufacturerSlug}::${item.model}`; const existingId = existingGlobalItemMap.get(key); // ... resolve manufacturerId from slug before inserting const [mfRow] = await database .select({ id: schema.manufacturers.id }) .from(schema.manufacturers) .where(eq(schema.manufacturers.slug, item.manufacturerSlug)); if (!mfRow) continue; if (existingId) { globalItemIds.push(existingId); } else { const [inserted] = await database .insert(schema.globalItems) .values({ manufacturerId: mfRow.id, model: item.model, category: item.category, weightGrams: item.weightGrams, priceCents: item.priceCents, description: item.description, }) .returning(); if (!inserted) throw new Error(`Failed to insert: ${item.manufacturerSlug} ${item.model}`); globalItemIds.push(inserted.id); newGlobalCount++; } } ``` Also update the `existingGlobalItemMap` to key by `manufacturerId::model`. After loading `existingGlobalItems`, build the manufacturer slug → id map first, then key the map: ```typescript // Build manufacturer slug → id map const allMfrs = await database.select().from(schema.manufacturers); const mfrSlugToId = new Map(allMfrs.map((m) => [m.slug, m.id])); // Build existing global item map keyed by manufacturerId::model const existingGlobalItems = await database.select().from(schema.globalItems); const existingGlobalItemMap = new Map(); for (const gi of existingGlobalItems) { existingGlobalItemMap.set(`${gi.manufacturerId}::${gi.model}`, gi.id); } // When checking/inserting each item: const mfrId = mfrSlugToId.get(item.manufacturerSlug); if (!mfrId) continue; const key = `${mfrId}::${item.model}`; const existingId = existingGlobalItemMap.get(key); ``` - [ ] **Step 4: Update `src/db/global-items-seed.json`** Replace `"brand"` with `"manufacturerSlug"` using the manufacturer slugs. Example: ```json [ { "manufacturerSlug": "revelate-designs", "model": "Terrapin System", "category": "bags", "weightGrams": 529, "priceCents": 18500, "description": "Waterproof saddle bag with 14L capacity..." }, ... ] ``` Apply `brand → manufacturerSlug` for all ~20 entries, converting brand names to slugs (lowercase, hyphens). - [ ] **Step 5: Commit** ```bash git add src/db/dev-seed-data.ts src/db/dev-seed.ts src/db/global-items-seed.json git commit -m "feat: dev seed and json seed use manufacturerSlug" ``` --- ## Task 12: Run full test suite - [ ] **Step 1: Run all tests** ```bash bun test ``` Expected: all tests pass. Common failure patterns: - `brand` referenced in a test helper → update to `manufacturerSlug` - Missing manufacturer in a test that inserts globalItems directly → add `insertManufacturer` call first - TypeScript type errors on service function signatures → check all call sites - [ ] **Step 2: Fix any failures, commit** ```bash git add -p git commit -m "fix: update remaining test references after brand → manufacturerSlug migration" ``` - [ ] **Step 3: Verify dev seed runs cleanly** ```bash bun run db:seed:dev ``` Expected output includes "X manufacturers seeded" and all subsequent counts without errors. - [ ] **Step 4: Commit if any fixes were needed** ```bash git add . git commit -m "fix: dev seed after manufacturers migration" ```