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

42 KiB

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:

// ── 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:

import {
	boolean,
	doublePrecision,
	integer,
	pgTable,
	primaryKey,
	serial,
	text,
	timestamp,
	unique,
} from "drizzle-orm/pg-core";
  • Step 2: Generate and push the migration
bun run db:generate
bun run db:push

Expected: new manufacturers table created with no errors.

  • Step 3: Commit
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:

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
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":

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
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
bun test tests/services/manufacturer.service.test.ts

Expected: PASS (3 test suites, all green).

  • Step 6: Commit
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):

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
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:

import { manufacturerRoutes } from "./routes/manufacturers.ts";

Find where globalItemRoutes is registered (around line 292) and add below it:

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
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:

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
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:

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
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:

bun run db:push --force-reset
# or connect to the DB and: TRUNCATE global_items CASCADE;

Then re-push.

  • Step 3: Commit
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:

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
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
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
bun test tests/services/global-item.service.test.ts

Expected: PASS.

  • Step 5: Commit
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:

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
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:

import { categories, globalItems, items, manufacturers } from "../../db/schema.ts";

In getAllItems, add a left join to manufacturers after the globalItems join:

.leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
.leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))

Replace the two globalItems.brand references:

// 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:

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
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:

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:

.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} ENDTHEN ${manufacturers.name} ELSE ${items.brand} END

  • Step 2: src/server/services/discovery.service.ts

Add to import:

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):

.from(globalItems)
.innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
.innerJoin(globalItemTags, ...)

Replace brand: globalItems.brand in the select:

brand: manufacturers.name,
  • Step 3: src/server/services/profile.service.ts

Add to import:

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:

.leftJoin(globalItems, eq(threadCandidates.globalItemId, globalItems.id))
.leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))

Replace ${globalItems.brand}${manufacturers.name}.

  • Step 6: Commit
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:

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

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:

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:

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:

// ── 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:

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:

// 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:

[
  {
    "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
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
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

git add -p
git commit -m "fix: update remaining test references after brand → manufacturerSlug migration"
  • Step 3: Verify dev seed runs cleanly
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
git add .
git commit -m "fix: dev seed after manufacturers migration"