Files
GearBox/docs/superpowers/plans/2026-04-18-catalog-schema-migration.md
Jean-Luc Makiola bea386e7db
All checks were successful
CI / ci (push) Successful in 1m21s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 1m15s
style(i18n): fix lint — formatting and import ordering across 21 files
Biome auto-fix for formatting (line length, ternary wrapping) and
import organization in files touched by phase 34 i18n work.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 14:49:10 +02:00

1356 lines
42 KiB
Markdown

# 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"
```