# 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<ReturnType<typeof createTestDb>>["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<Env>();

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<Parameters<Db["transaction"]>[0]>[0];

async function resolveManufacturerId(db: Db | TxDb, slug: string): Promise<number> {
	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<string>`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<string | null>`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<string, number>();
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"
```
