---
phase: 19-reference-item-model-tags-schema
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/db/schema.ts
  - src/shared/schemas.ts
  - src/shared/types.ts
  - tests/helpers/db.ts
  - src/db/seed-global-items.ts
autonomous: true
requirements:
  - CATFLOW-03
  - TAG-01
  - TAG-02

must_haves:
  truths:
    - "items table has globalItemId nullable FK column and purchasePriceCents nullable integer column"
    - "threadCandidates table has globalItemId nullable FK column"
    - "tags table exists with id, name (unique), createdAt"
    - "globalItemTags join table exists with composite PK on (globalItemId, tagId)"
    - "itemGlobalLinks table no longer exists in schema"
    - "Existing itemGlobalLinks data is migrated to items.globalItemId before table drop"
    - "Zod schemas accept globalItemId and purchasePriceCents on items and candidates"
    - "Seed script creates curated tag set for outdoor/adventure gear"
  artifacts:
    - path: "src/db/schema.ts"
      provides: "Updated schema with items.globalItemId, items.purchasePriceCents, threadCandidates.globalItemId, tags, globalItemTags tables; no itemGlobalLinks"
      contains: "globalItemId"
    - path: "src/shared/schemas.ts"
      provides: "Updated Zod schemas with globalItemId and purchasePriceCents fields, tags query param, removed linkItemSchema"
      contains: "purchasePriceCents"
    - path: "src/shared/types.ts"
      provides: "Updated types removing ItemGlobalLink, adding Tag and GlobalItemTag"
      contains: "Tag"
    - path: "tests/helpers/db.ts"
      provides: "Test helper compatible with new schema"
    - path: "src/db/seed-global-items.ts"
      provides: "Tag seeding alongside global items"
      contains: "tags"
  key_links:
    - from: "src/db/schema.ts"
      to: "drizzle-pg migration SQL"
      via: "bun run db:generate"
      pattern: "global_item_id"
    - from: "src/shared/schemas.ts"
      to: "src/shared/types.ts"
      via: "Zod inference"
      pattern: "globalItemId"
---

<objective>
Update the database schema, Zod validation schemas, TypeScript types, test helpers, and seed script to support the reference item model and tag system.

Purpose: Establish the data foundation that all subsequent service and route changes depend on. This is the schema layer -- no business logic changes.
Output: Updated schema.ts, schemas.ts, types.ts, test helpers, seed script, and a Drizzle migration file.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/19-reference-item-model-tags-schema/19-CONTEXT.md
@.planning/phases/19-reference-item-model-tags-schema/19-RESEARCH.md

<interfaces>
<!-- Current schema exports used by all services -->
From src/db/schema.ts:
```typescript
export const items = pgTable("items", { ... }); // Add globalItemId, purchasePriceCents
export const threadCandidates = pgTable("thread_candidates", { ... }); // Add globalItemId
export const globalItems = pgTable("global_items", { ... }); // Unchanged
export const itemGlobalLinks = pgTable("item_global_links", { ... }); // REMOVE entirely
```

From src/shared/schemas.ts:
```typescript
export const createItemSchema = z.object({ ... }); // Add globalItemId, purchasePriceCents
export const createCandidateSchema = z.object({ ... }); // Add globalItemId
export const searchGlobalItemsSchema = z.object({ q: z.string().optional() }); // Add tags
export const linkItemSchema = z.object({ ... }); // REMOVE
```

From src/shared/types.ts:
```typescript
export type ItemGlobalLink = typeof itemGlobalLinks.$inferSelect; // REMOVE
export type LinkItem = z.infer<typeof linkItemSchema>; // REMOVE
// ADD: Tag, GlobalItemTag types from new schema tables
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Update schema.ts, generate migration with data migration step</name>
  <files>src/db/schema.ts</files>
  <read_first>
    - src/db/schema.ts
    - .planning/phases/19-reference-item-model-tags-schema/19-RESEARCH.md (migration order section)
  </read_first>
  <action>
    Modify `src/db/schema.ts` with the following changes:

    **Add to `items` table definition (after the `quantity` field):**
    ```typescript
    globalItemId: integer("global_item_id").references(() => globalItems.id),
    purchasePriceCents: integer("purchase_price_cents"),
    ```

    **Add to `threadCandidates` table definition (after the `sortOrder` field):**
    ```typescript
    globalItemId: integer("global_item_id").references(() => globalItems.id),
    ```

    **Add new `tags` table after the `globalItems` table:**
    ```typescript
    export const tags = pgTable("tags", {
      id: serial("id").primaryKey(),
      name: text("name").notNull().unique(),
      createdAt: timestamp("created_at").defaultNow().notNull(),
    });
    ```

    **Add new `globalItemTags` join table after `tags`:**
    ```typescript
    export const globalItemTags = pgTable(
      "global_item_tags",
      {
        globalItemId: integer("global_item_id")
          .notNull()
          .references(() => globalItems.id, { onDelete: "cascade" }),
        tagId: integer("tag_id")
          .notNull()
          .references(() => tags.id, { onDelete: "cascade" }),
      },
      (table) => [primaryKey({ columns: [table.globalItemId, table.tagId] })],
    );
    ```

    **Remove the entire `itemGlobalLinks` table definition** (lines 146-155 including the comment above it).

    After editing schema.ts, run `bun run db:generate` to produce a migration SQL file.

    Then manually edit the generated migration SQL file in `drizzle-pg/` to insert a data migration step. After the `ALTER TABLE "items" ADD COLUMN "global_item_id"` line and before the `DROP TABLE "item_global_links"` line, add:
    ```sql
    UPDATE "items" SET "global_item_id" = (
      SELECT "global_item_id" FROM "item_global_links"
      WHERE "item_global_links"."item_id" = "items"."id"
    );
    ```

    This ensures existing link data is preserved before the old table is dropped (per D-19, D-20).
  </action>
  <verify>
    <automated>grep -c "globalItemId" src/db/schema.ts | grep -q "^[3-9]" && grep -c "tags" src/db/schema.ts | grep -q "^[2-9]" && ! grep -q "itemGlobalLinks" src/db/schema.ts && echo "PASS" || echo "FAIL"</automated>
  </verify>
  <acceptance_criteria>
    - src/db/schema.ts contains `globalItemId: integer("global_item_id").references(() => globalItems.id)` in items table
    - src/db/schema.ts contains `purchasePriceCents: integer("purchase_price_cents")` in items table
    - src/db/schema.ts contains `globalItemId: integer("global_item_id").references(() => globalItems.id)` in threadCandidates table
    - src/db/schema.ts contains `export const tags = pgTable("tags"`
    - src/db/schema.ts contains `export const globalItemTags = pgTable("global_item_tags"`
    - src/db/schema.ts does NOT contain `itemGlobalLinks`
    - A new migration SQL file exists in drizzle-pg/ with `ALTER TABLE "items" ADD COLUMN "global_item_id"`
    - Migration SQL file contains `UPDATE "items" SET "global_item_id"` BEFORE `DROP TABLE "item_global_links"`
  </acceptance_criteria>
  <done>Schema has globalItemId on items and threadCandidates, purchasePriceCents on items, tags + globalItemTags tables, no itemGlobalLinks. Migration includes data migration step.</done>
</task>

<task type="auto">
  <name>Task 2: Update Zod schemas, types, test helpers, and seed script</name>
  <files>src/shared/schemas.ts, src/shared/types.ts, tests/helpers/db.ts, src/db/seed-global-items.ts</files>
  <read_first>
    - src/shared/schemas.ts
    - src/shared/types.ts
    - tests/helpers/db.ts
    - src/db/seed-global-items.ts
    - src/db/schema.ts (after Task 1 changes)
  </read_first>
  <action>
    **Update `src/shared/schemas.ts`:**

    1. Add `globalItemId` and `purchasePriceCents` to `createItemSchema`:
    ```typescript
    export const createItemSchema = z.object({
      name: z.string().min(1, "Name is required"),
      weightGrams: z.number().nonnegative().optional(),
      priceCents: z.number().int().nonnegative().optional(),
      categoryId: z.number().int().positive(),
      notes: z.string().optional(),
      productUrl: z.string().url().optional().or(z.literal("")),
      imageFilename: z.string().optional(),
      imageSourceUrl: z.string().url().optional().or(z.literal("")),
      quantity: z.number().int().positive().optional(),
      globalItemId: z.number().int().positive().optional(),
      purchasePriceCents: z.number().int().nonnegative().optional(),
    });
    ```

    2. Add `globalItemId` to `createCandidateSchema`:
    ```typescript
    export const createCandidateSchema = z.object({
      name: z.string().min(1, "Name is required"),
      weightGrams: z.number().nonnegative().optional(),
      priceCents: z.number().int().nonnegative().optional(),
      categoryId: z.number().int().positive(),
      notes: z.string().optional(),
      productUrl: z.string().url().optional().or(z.literal("")),
      imageFilename: z.string().optional(),
      imageSourceUrl: z.string().url().optional().or(z.literal("")),
      status: candidateStatusSchema.optional(),
      pros: z.string().optional(),
      cons: z.string().optional(),
      globalItemId: z.number().int().positive().optional(),
    });
    ```

    3. Update `searchGlobalItemsSchema` to accept tags:
    ```typescript
    export const searchGlobalItemsSchema = z.object({
      q: z.string().optional(),
      tags: z.string().optional(),
    });
    ```

    4. Remove `linkItemSchema` entirely (the `z.object({ globalItemId: ... })` definition).

    **Update `src/shared/types.ts`:**

    1. Remove `itemGlobalLinks` from the import of `../db/schema.ts`.
    2. Remove `linkItemSchema` from the import of `./schemas.ts`.
    3. Remove `export type ItemGlobalLink = typeof itemGlobalLinks.$inferSelect;`.
    4. Remove `export type LinkItem = z.infer<typeof linkItemSchema>;`.
    5. Add imports for `tags` and `globalItemTags` from schema.
    6. Add:
    ```typescript
    export type Tag = typeof tags.$inferSelect;
    export type GlobalItemTag = typeof globalItemTags.$inferSelect;
    ```

    **Update `tests/helpers/db.ts`:**

    No structural changes needed -- test helper uses Drizzle migrations which will automatically apply the new schema. Verify it still works by confirming `createTestDb()` applies migrations cleanly.

    **Update `src/db/seed-global-items.ts`:**

    1. Convert from sync `.all()` / `.run()` patterns to async `await` pattern.
    2. Import `tags` from schema.
    3. Add a `seedTags` function that inserts curated tags (idempotent -- skip if any exist):
    ```typescript
    const SEED_TAGS = [
      "handlebar-bag", "framebag", "saddlebag", "top-tube-bag",
      "stem-bag", "fork-bag", "hip-pack", "backpack",
      "tent", "bivy", "tarp", "hammock",
      "sleeping-bag", "sleeping-pad", "quilt", "pillow",
      "stove", "cookware", "water-filter", "water-bottle",
      "headlamp", "bike-light",
      "ultralight", "waterproof", "budget", "premium",
      "bikepacking", "hiking", "camping", "touring",
    ];
    ```
    4. Make `seedGlobalItems` async and call `seedTags` at the end.
    5. The `seedTags` function:
    ```typescript
    export async function seedTags(db: Db = prodDb) {
      const existing = await db.select().from(tags).limit(1);
      if (existing.length > 0) return;

      for (const name of SEED_TAGS) {
        await db.insert(tags).values({ name });
      }
    }
    ```
    6. Update `seedGlobalItems` to be async, replace `.all()` with `await`, replace `.run()` with `await`, and call `await seedTags(db)` at the end.
  </action>
  <verify>
    <automated>grep -q "globalItemId" src/shared/schemas.ts && grep -q "purchasePriceCents" src/shared/schemas.ts && ! grep -q "linkItemSchema" src/shared/schemas.ts && grep -q "Tag" src/shared/types.ts && ! grep -q "ItemGlobalLink" src/shared/types.ts && grep -q "SEED_TAGS" src/db/seed-global-items.ts && echo "PASS" || echo "FAIL"</automated>
  </verify>
  <acceptance_criteria>
    - src/shared/schemas.ts `createItemSchema` contains `globalItemId: z.number().int().positive().optional()`
    - src/shared/schemas.ts `createItemSchema` contains `purchasePriceCents: z.number().int().nonnegative().optional()`
    - src/shared/schemas.ts `createCandidateSchema` contains `globalItemId: z.number().int().positive().optional()`
    - src/shared/schemas.ts `searchGlobalItemsSchema` contains `tags: z.string().optional()`
    - src/shared/schemas.ts does NOT contain `linkItemSchema`
    - src/shared/types.ts contains `export type Tag = typeof tags.$inferSelect`
    - src/shared/types.ts contains `export type GlobalItemTag = typeof globalItemTags.$inferSelect`
    - src/shared/types.ts does NOT contain `ItemGlobalLink`
    - src/shared/types.ts does NOT contain `linkItemSchema`
    - src/db/seed-global-items.ts contains `SEED_TAGS` array with at least 25 tag names
    - src/db/seed-global-items.ts contains `async function seedTags`
    - src/db/seed-global-items.ts contains `async function seedGlobalItems` (converted from sync)
  </acceptance_criteria>
  <done>Zod schemas accept new fields, old link schema removed, types updated, seed script creates tags, test helper works with new schema.</done>
</task>

</tasks>

<verification>
```bash
# Schema has correct exports
grep -c "globalItemId" src/db/schema.ts  # Should be >= 3 (items, threadCandidates, globalItemTags)
grep "itemGlobalLinks" src/db/schema.ts  # Should return nothing

# Zod schemas correct
grep "globalItemId" src/shared/schemas.ts  # Should appear in createItemSchema and createCandidateSchema
grep "linkItemSchema" src/shared/schemas.ts  # Should return nothing

# Types correct
grep "Tag" src/shared/types.ts  # Should show Tag and GlobalItemTag
grep "ItemGlobalLink" src/shared/types.ts  # Should return nothing

# Migration exists
ls drizzle-pg/*.sql | tail -1  # Should show new migration file
grep "global_item_id" drizzle-pg/*.sql  # Should find ADD COLUMN and UPDATE statements

# Seed script
grep "SEED_TAGS" src/db/seed-global-items.ts  # Should exist
grep "async" src/db/seed-global-items.ts  # Should show async functions
```
</verification>

<success_criteria>
- Schema defines items.globalItemId, items.purchasePriceCents, threadCandidates.globalItemId, tags table, globalItemTags table
- itemGlobalLinks table completely removed from schema
- Drizzle migration generated with data migration step
- Zod schemas updated with new fields, old linkItemSchema removed
- Types updated with Tag and GlobalItemTag, old ItemGlobalLink removed
- Seed script creates 28+ curated tags for outdoor/adventure gear
- Test helper works with new schema (migrations apply cleanly)
</success_criteria>

<output>
After completion, create `.planning/phases/19-reference-item-model-tags-schema/19-01-SUMMARY.md`
</output>
