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>
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
manufacturersto the TRUNCATE_TABLES list intests/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
seedManufacturerstosrc/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
globalItemsinsrc/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
upsertGlobalItemSchemainsrc/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.tsfor any hardcodedbrandreferences 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
createItemfunction
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:
- Add
manufacturersto the schema import - Add
.leftJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))after every.leftJoin(globalItems, ...) - 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} END→THEN ${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
brandwithmanufacturerSlugincatalogItemInputSchema
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_MANUFACTURERStosrc/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_ITEMSinsrc/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.tsto 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:
-
brandreferenced in a test helper → update tomanufacturerSlug -
Missing manufacturer in a test that inserts globalItems directly → add
insertManufacturercall 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"