# Phase 19: Reference Item Model & Tags Schema - Research

**Researched:** 2026-04-05
**Domain:** Drizzle ORM schema evolution, PostgreSQL migration, service-layer data merging
**Confidence:** HIGH

## Summary

Phase 19 transforms the item model from fully self-contained rows to a hybrid model where items can either be standalone (all data on the row) or reference items (pointing at a global catalog entry via `globalItemId` FK). The existing `itemGlobalLinks` junction table is replaced by a direct nullable FK on `items`. A tag system is added for global item discovery. Thread candidates gain the same `globalItemId` FK, and thread resolution creates reference items when the winning candidate has a catalog link.

The codebase is well-structured for this change. Services are pure functions taking a `db` instance, making the merge-on-read pattern straightforward. The main complexity is ensuring every query path that reads items (7+ locations across item, setup, totals, profile, CSV, and MCP services) correctly joins and merges global item data for reference items.

**Primary recommendation:** Use a SQL-level `COALESCE` merge pattern in Drizzle queries so that reference items transparently return global data with personal overrides, avoiding application-level merge logic scattered across services.

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions
- **D-01:** Add `globalItemId` (nullable FK to globalItems) directly to the `items` table. When set, the item is a "reference item" -- base data comes from the global item.
- **D-02:** Remove the `itemGlobalLinks` junction table entirely. It was a 1:1 relationship -- a direct FK on items is simpler. Migrate existing link data first.
- **D-03:** Personal fields on items remain: `categoryId`, `notes`, `imageFilename`, `imageSourceUrl`, `quantity`. Add `purchasePriceCents` (nullable integer) for what the user actually paid.
- **D-04:** For reference items, `name`, `weightGrams`, `priceCents` on the items row are kept NULL or empty. The service layer merges global data when returning items.
- **D-05:** Standalone items (no `globalItemId`) continue to work as before -- fully self-contained with all fields populated. This is the "manual entry" path.
- **D-06:** Service layer handles merging transparently. `getAllItems()` and `getItem()` join on globalItems when `globalItemId` is set, returning a unified shape.
- **D-07:** API response shape stays the same as current Item type. Global data fills in name, weight, price, etc. for reference items.
- **D-08:** `productUrl` on reference items: comes from the global item's future `productUrl` field if added, otherwise NULL.
- **D-09:** Add `globalItemId` (nullable FK to globalItems) to `threadCandidates` table.
- **D-10:** Candidates with `globalItemId` display global item data (brand + model as name, weight, price, image).
- **D-11:** Candidates without `globalItemId` work as before (fully manual data).
- **D-12:** When resolving a thread where the winning candidate has `globalItemId`, create a reference item: set `items.globalItemId` = candidate's `globalItemId`, copy only personal fields.
- **D-13:** When resolving a candidate WITHOUT `globalItemId`, behavior stays the same as today -- full data copy.
- **D-14:** New `tags` table: `id` (serial), `name` (text, unique, not null), `createdAt` (timestamp).
- **D-15:** New `globalItemTags` join table: `globalItemId` (FK), `tagId` (FK), composite primary key. Many-to-many.
- **D-16:** Tags are flat -- no type column for now.
- **D-17:** Seed initial tag set via script covering common bikepacking/outdoor gear categories.
- **D-18:** Global item search extends to support tag filtering: `GET /api/global-items?q=...&tags=handlebar-bag,waterproof` returns items matching ALL specified tags.
- **D-19:** Migration script: for each row in `itemGlobalLinks`, set `items.globalItemId = itemGlobalLinks.globalItemId`, then drop `itemGlobalLinks` table.
- **D-20:** Existing items that had links keep their current name/weight/price data populated (not nulled out) -- safe fallback during transition.

### Claude's Discretion
- Exact seed tag list content and count
- SQL migration ordering (add columns, migrate data, drop old table)
- Whether to update MCP tools in this phase or defer
- Test helper updates for new schema
- Whether global item search uses AND or OR for multiple tags (recommendation: AND -- intersection filtering)

### Deferred Ideas (OUT OF SCOPE)
- Catalog submission system -- manual items submitted for admin review
- Crowd-sourced purchase price intelligence
- Crowd-sourced weight intelligence
- Admin tag management UI
- Tag type categorization
- Personal weight override field on items
</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| CATFLOW-03 | User can add a catalog item to collection as a reference item with personal fields | Schema: `items.globalItemId` FK + `purchasePriceCents` column. Service: `createItem()` accepts `globalItemId`, stores minimal personal data. |
| CATFLOW-04 | Collection items referencing global items display merged data | Service: `getAllItems()` and `getItemById()` LEFT JOIN on `globalItems`, COALESCE fields. API shape unchanged. |
| CATFLOW-05 | Thread candidates can be added from catalog with global item link | Schema: `threadCandidates.globalItemId` FK. Service: `createCandidate()` accepts `globalItemId`, candidate queries merge global data. |
| CATFLOW-06 | Thread resolution with catalog-linked candidate creates reference item with auto-link | Service: `resolveThread()` branches on `candidate.globalItemId` -- sets FK instead of copying base data. |
| TAG-01 | Tags table seeded with curated tag set for outdoor/adventure gear | Schema: `tags` table. Seed script extends `seed-global-items.ts`. |
| TAG-02 | Global items have multiple tags, searchable and filterable via API | Schema: `globalItemTags` join table. Service: `searchGlobalItems()` accepts `tags` param, filters with subquery. Route: query param parsing. |
</phase_requirements>

## Standard Stack

### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| drizzle-orm | 0.45.2 | ORM for schema, queries, migrations | Already in use, pg-core dialect |
| drizzle-kit | (project dep) | Migration generation | `bun run db:generate` |
| @electric-sql/pglite | 0.4.3 | In-memory Postgres for tests | Already in test infrastructure |
| zod | (project dep) | Schema validation | Already used for all API schemas |

### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| @hono/zod-validator | (project dep) | Route-level validation | Tag query param validation |

No new dependencies are needed. All work uses existing libraries.

## Architecture Patterns

### Recommended Migration Order

The Drizzle migration must be a single SQL file with ordered statements:

```
1. ALTER TABLE items ADD COLUMN global_item_id (nullable FK)
2. ALTER TABLE items ADD COLUMN purchase_price_cents (nullable integer)
3. ALTER TABLE thread_candidates ADD COLUMN global_item_id (nullable FK)
4. CREATE TABLE tags
5. CREATE TABLE global_item_tags
6. UPDATE items SET global_item_id = (SELECT global_item_id FROM item_global_links WHERE item_global_links.item_id = items.id)
7. DROP TABLE item_global_links
```

Steps 1-5 are schema additions (safe). Step 6 migrates data. Step 7 removes the old table. Drizzle Kit generates steps 1-5 and 7 from schema diff; step 6 must be added manually to the generated migration SQL.

### Pattern 1: COALESCE Merge for Reference Items

**What:** Use SQL-level COALESCE to merge global item data into item queries, so the service returns a unified shape regardless of whether an item is standalone or reference.

**When to use:** Every query that returns items to clients.

**Example:**
```typescript
// item.service.ts - getAllItems with merge
import { globalItems } from "../../db/schema.ts";

export async function getAllItems(db: Db, userId: number) {
  return db
    .select({
      id: items.id,
      name: sql<string>`COALESCE(
        CASE WHEN ${items.globalItemId} IS NOT NULL
          THEN ${globalItems.brand} || ' ' || ${globalItems.model}
          ELSE ${items.name}
        END,
        ${items.name}
      )`.as("name"),
      weightGrams: sql<number | null>`COALESCE(
        ${globalItems.weightGrams},
        ${items.weightGrams}
      )`.as("weight_grams"),
      priceCents: sql<number | null>`COALESCE(
        ${globalItems.priceCents},
        ${items.priceCents}
      )`.as("price_cents"),
      purchasePriceCents: items.purchasePriceCents,
      quantity: items.quantity,
      categoryId: items.categoryId,
      notes: items.notes,
      productUrl: items.productUrl,
      imageFilename: sql<string | null>`COALESCE(
        ${items.imageFilename},
        ${globalItems.imageUrl}
      )`.as("image_filename"),
      imageSourceUrl: items.imageSourceUrl,
      globalItemId: items.globalItemId,
      createdAt: items.createdAt,
      updatedAt: items.updatedAt,
      categoryName: categories.name,
      categoryIcon: categories.icon,
    })
    .from(items)
    .innerJoin(categories, eq(items.categoryId, categories.id))
    .leftJoin(globalItems, eq(items.globalItemId, globalItems.id))
    .where(eq(items.userId, userId));
}
```

**Key points:**
- LEFT JOIN on globalItems (null when standalone item)
- COALESCE prefers global data for name/weight/price when globalItemId is set
- Name for reference items is `brand + ' ' + model` from globalItems
- Personal fields (categoryId, notes, quantity, purchasePriceCents) always come from items row
- `globalItemId` is returned in response so client knows it is a reference item

### Pattern 2: Tag Filtering with Subquery

**What:** Filter global items by tags using an intersection (AND) subquery pattern.

**When to use:** `searchGlobalItems()` when `tags` parameter is provided.

**Example:**
```typescript
export async function searchGlobalItems(
  db: Db,
  query?: string,
  tagNames?: string[],
) {
  let baseQuery = db.select().from(globalItems);

  // Text search filter
  if (query) {
    const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_");
    const pattern = `%${escaped}%`;
    baseQuery = baseQuery.where(
      or(like(globalItems.brand, pattern), like(globalItems.model, pattern)),
    );
  }

  // Tag intersection filter (AND logic)
  if (tagNames && tagNames.length > 0) {
    baseQuery = baseQuery.where(
      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}
      )`
    );
  }

  return baseQuery;
}
```

### Pattern 3: Branched Thread Resolution

**What:** `resolveThread()` creates a reference item or standalone item based on whether the candidate has `globalItemId`.

**Example:**
```typescript
// In resolveThread, step 4:
const insertValues = candidate.globalItemId
  ? {
      // Reference item - minimal data, global link
      name: "", // or candidate.name as fallback
      globalItemId: candidate.globalItemId,
      categoryId: safeCategoryId,
      userId,
      notes: candidate.notes,
      imageFilename: candidate.imageFilename,
      imageSourceUrl: candidate.imageSourceUrl,
      quantity: 1,
    }
  : {
      // Standalone item - full data copy (existing behavior)
      name: candidate.name,
      weightGrams: candidate.weightGrams,
      priceCents: candidate.priceCents,
      categoryId: safeCategoryId,
      userId,
      notes: candidate.notes,
      productUrl: candidate.productUrl,
      imageFilename: candidate.imageFilename,
      imageSourceUrl: candidate.imageSourceUrl,
      quantity: 1,
    };

const [newItem] = await tx.insert(items).values(insertValues).returning();
```

### All Query Locations Requiring Merge Updates

Every location that reads items and returns data to clients must be updated to join globalItems:

| File | Function | Current Pattern | Update Needed |
|------|----------|----------------|---------------|
| `item.service.ts` | `getAllItems()` | Direct select from items | LEFT JOIN + COALESCE merge |
| `item.service.ts` | `getItemById()` | Direct select from items | LEFT JOIN + COALESCE merge |
| `item.service.ts` | `duplicateItem()` | Copies all fields from source | If source has globalItemId, copy globalItemId instead of name/weight/price |
| `setup.service.ts` | `getSetupWithItems()` | Joins items via setupItems | LEFT JOIN globalItems + COALESCE |
| `setup.service.ts` | `getAllSetups()` | Subquery on items for totals | Subquery must COALESCE weight/price from globalItems |
| `profile.service.ts` | `getPublicSetupWithItems()` | Joins items via setupItems | LEFT JOIN globalItems + COALESCE |
| `totals.service.ts` | `getCategoryTotals()` | SUM on items.weightGrams/priceCents | Must COALESCE with globalItems values |
| `totals.service.ts` | `getGlobalTotals()` | SUM on items.weightGrams/priceCents | Must COALESCE with globalItems values |
| `csv.service.ts` | `exportItemsCsv()` | Reads items directly | Must merge global data for export |
| `global-item.service.ts` | `getGlobalItemWithOwnerCount()` | Counts via itemGlobalLinks | Count via `items.globalItemId` instead |
| `thread.service.ts` | `getThreadWithCandidates()` | Reads candidates directly | LEFT JOIN globalItems for candidates with globalItemId |

### Anti-Patterns to Avoid
- **Application-level merge:** Do NOT fetch items and global items separately then merge in TypeScript. Use SQL COALESCE in the query -- it is more efficient and prevents inconsistency.
- **Nullable name column:** The `items.name` column is currently `NOT NULL`. For reference items, store an empty string or the catalog name as a fallback, but do NOT change the column to nullable -- it would break standalone items and existing queries.
- **Breaking API shape:** Do NOT add a separate `globalItem` nested object to the API response. The merge must be transparent -- clients see the same shape as before, with `globalItemId` as the only new field.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Data migration | Manual SQL scripts run outside Drizzle | Drizzle migration file with custom SQL | Tracked, versioned, applied via `db:push` |
| Tag intersection query | Nested loops or multiple queries | Single SQL subquery with GROUP BY + HAVING | N+1 queries for tag matching would be very slow |
| Merge logic | TypeScript object spread in every service | SQL COALESCE in query select | Single source of truth, no missed merge points |

## Common Pitfalls

### Pitfall 1: Missed Merge Points
**What goes wrong:** Some query path returns raw item data without joining globalItems, so reference items show NULL name/weight/price.
**Why it happens:** 7+ services read items independently, easy to miss one.
**How to avoid:** The "All Query Locations" table above is the complete inventory. Update all of them. Write tests for each that create a reference item and verify merged output.
**Warning signs:** Items showing as unnamed or with zero weight in setups, totals, or CSV export.

### Pitfall 2: Migration Data Loss
**What goes wrong:** Dropping `itemGlobalLinks` before migrating data to `items.globalItemId`.
**Why it happens:** Drizzle Kit generates "drop table" and "add column" independently.
**How to avoid:** After `db:generate`, manually insert the data migration `UPDATE` statement into the generated SQL file BEFORE the `DROP TABLE` statement.
**Warning signs:** Items that were previously linked show no `globalItemId` after migration.

### Pitfall 3: NOT NULL Constraint on items.name
**What goes wrong:** Trying to insert a reference item with `name: null` fails because `items.name` is `NOT NULL`.
**Why it happens:** Reference items get their name from globalItems, so there is temptation to leave name null.
**How to avoid:** For reference items, store the brand+model as the `name` value (as a denormalized fallback). The merge query still prefers globalItems data, but the row is valid even without the join.
**Warning signs:** Insert failures on reference item creation.

### Pitfall 4: Totals Queries Missing Global Data
**What goes wrong:** Setup and global totals report 0 weight/cost for reference items.
**Why it happens:** Totals queries SUM `items.weightGrams` and `items.priceCents` directly without joining globalItems.
**How to avoid:** Update totals subqueries to LEFT JOIN globalItems and COALESCE weight/price values.
**Warning signs:** Setups with reference items showing lower totals than expected.

### Pitfall 5: Test Sync Calls vs Async
**What goes wrong:** Existing tests for `global-item.service.test.ts` and `global-items.test.ts` use synchronous `.get()`, `.all()`, `.run()` patterns from the old SQLite era.
**Why it happens:** These tests were written before the PostgreSQL migration but never fully updated.
**How to avoid:** When rewriting tests, ensure all database operations use `await` and the async PGlite pattern from `createTestDb()`. The test helper already returns async-compatible db.
**Warning signs:** Type errors about `.get()` not existing, or tests passing but data not actually persisted.

### Pitfall 6: LIKE Case Sensitivity on PostgreSQL
**What goes wrong:** Tag name matching or global item search becomes case-sensitive.
**Why it happens:** PostgreSQL `LIKE` is case-sensitive (unlike SQLite). The codebase comment says "LIKE is case-insensitive for ASCII" which was true for SQLite but is NOT true for PostgreSQL.
**How to avoid:** Use `ILIKE` (case-insensitive LIKE) for PostgreSQL text search. The current `like()` calls in `searchGlobalItems` should be changed to `ilike()` from `drizzle-orm`.
**Warning signs:** Searches for "revelate" not finding "Revelate Designs".

## Code Examples

### Schema Additions (schema.ts)

```typescript
// Add to items table
export const items = pgTable("items", {
  // ... existing fields ...
  globalItemId: integer("global_item_id").references(() => globalItems.id),
  purchasePriceCents: integer("purchase_price_cents"),
  // ... rest of fields ...
});

// Add to threadCandidates table
export const threadCandidates = pgTable("thread_candidates", {
  // ... existing fields ...
  globalItemId: integer("global_item_id").references(() => globalItems.id),
  // ... rest of fields ...
});

// New tables
export const tags = pgTable("tags", {
  id: serial("id").primaryKey(),
  name: text("name").notNull().unique(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
});

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: itemGlobalLinks table definition entirely
```

### Zod Schema Updates (schemas.ts)

```typescript
// Update createItemSchema
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(),       // NEW
  purchasePriceCents: z.number().int().nonnegative().optional(), // NEW
});

// Update createCandidateSchema
export const createCandidateSchema = z.object({
  // ... existing fields ...
  globalItemId: z.number().int().positive().optional(), // NEW
});

// Update searchGlobalItemsSchema
export const searchGlobalItemsSchema = z.object({
  q: z.string().optional(),
  tags: z.string().optional(), // comma-separated tag names
});

// REMOVE: linkItemSchema (no longer needed)
```

### Seed Tag Data Pattern

```typescript
// In seed-global-items.ts (or new seed-tags.ts)
const seedTags = [
  // Bag types
  "handlebar-bag", "framebag", "saddlebag", "top-tube-bag",
  "stem-bag", "fork-bag", "hip-pack", "backpack",
  // Shelter
  "tent", "bivy", "tarp", "hammock",
  // Sleep system
  "sleeping-bag", "sleeping-pad", "quilt", "pillow",
  // Cooking
  "stove", "cookware", "water-filter", "water-bottle",
  // Lighting
  "headlamp", "bike-light",
  // Properties
  "ultralight", "waterproof", "budget", "premium",
  // Activity
  "bikepacking", "hiking", "camping", "touring",
];
```

### Owner Count Migration (global-item.service.ts)

```typescript
// Updated to use items.globalItemId instead of itemGlobalLinks
export async function getGlobalItemWithOwnerCount(db: Db, id: number) {
  const [item] = await db
    .select()
    .from(globalItems)
    .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 };
}
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `itemGlobalLinks` junction table | Direct `items.globalItemId` FK | This phase | Simpler queries, fewer joins, cleaner model |
| Separate link/unlink endpoints | `globalItemId` set on item create/update | This phase | Fewer API calls, atomic operations |
| No tag system | `tags` + `globalItemTags` many-to-many | This phase | Enables catalog discovery filtering |
| Full data copy on resolution | Conditional reference vs copy | This phase | Reference items stay in sync with catalog |

## Open Questions

1. **MCP Tools Update**
   - What we know: MCP tools for items (create_item, update_item, list_items) will need to handle globalItemId. The merge happens at service level so list_items/get_item work automatically.
   - What's unclear: Whether create_item MCP tool should accept globalItemId in this phase.
   - Recommendation: Defer MCP tool updates. Service-level merge means read operations work automatically. Write operations (creating reference items via MCP) can be added when the catalog UI is ready in Phase 21.

2. **CSV Export/Import with Reference Items**
   - What we know: CSV export reads items and must show merged data. CSV import creates items.
   - What's unclear: Should CSV import support creating reference items (by globalItemId)?
   - Recommendation: Export shows merged data (transparent). Import creates standalone items only (existing behavior). Reference item creation via import is a future enhancement.

3. **items.name NOT NULL for Reference Items**
   - What we know: `items.name` is `NOT NULL`. Reference items get name from globalItems.
   - What's unclear: What to store in `items.name` for reference items.
   - Recommendation: Store `"${brand} ${model}"` as a denormalized fallback. This ensures the row is valid standalone and provides a searchable name even without the join. The merge query still prefers globalItems data.

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Bun test runner |
| Config file | bunfig.toml (if exists) / package.json scripts |
| Quick run command | `bun test tests/services/item.service.test.ts` |
| Full suite command | `bun test` |

### Phase Requirements to Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| CATFLOW-03 | Create reference item with globalItemId + personal fields | unit | `bun test tests/services/item.service.test.ts -t "reference item"` | Needs update |
| CATFLOW-04 | getAllItems/getItemById return merged data for reference items | unit | `bun test tests/services/item.service.test.ts -t "merged"` | Needs update |
| CATFLOW-05 | Create candidate with globalItemId, merged display | unit | `bun test tests/services/thread.service.test.ts -t "globalItemId"` | Needs update |
| CATFLOW-06 | resolveThread with catalog candidate creates reference item | unit | `bun test tests/services/thread.service.test.ts -t "resolve"` | Exists, needs update |
| TAG-01 | Tags table seeded with curated set | unit | `bun test tests/services/global-item.service.test.ts -t "seed"` | Needs new tests |
| TAG-02 | searchGlobalItems filters by tags (AND logic) | unit | `bun test tests/services/global-item.service.test.ts -t "tag"` | Needs new tests |

### Sampling Rate
- **Per task commit:** `bun test tests/services/item.service.test.ts tests/services/global-item.service.test.ts tests/services/thread.service.test.ts`
- **Per wave merge:** `bun test`
- **Phase gate:** Full suite green before verification

### Wave 0 Gaps
- [ ] `tests/services/global-item.service.test.ts` -- must be rewritten for async PGlite (currently uses sync SQLite patterns: `.get()`, `.all()`, `.run()`)
- [ ] `tests/routes/global-items.test.ts` -- must be rewritten for async PGlite (same sync pattern issue)
- [ ] Test helpers may need `insertGlobalItem()` and `insertTag()` async helpers

## Project Constraints (from CLAUDE.md)

- **Routing:** TanStack Router with file-based routes. Route tree auto-generated -- never edit manually.
- **Data fetching:** TanStack React Query via custom hooks. Mutations invalidate related query keys.
- **Styling:** Tailwind CSS v4.
- **Schemas:** Zod schemas in `src/shared/schemas.ts` are source of truth for types.
- **Types:** Inferred from Zod + Drizzle in `src/shared/types.ts`. No manual type duplication.
- **Services:** Pure business logic functions that take a db instance. No HTTP awareness.
- **Prices stored as cents** (integer) to avoid float rounding.
- **Timestamps:** stored as timestamps with `defaultNow()`.
- **Testing:** Bun test runner. `createTestDb()` with PGlite + Drizzle migrations.
- **Lint:** Biome (tabs, double quotes, organized imports).
- **Path alias:** `@/*` maps to `./src/*`.

## Sources

### Primary (HIGH confidence)
- `src/db/schema.ts` -- Current Drizzle schema, lines 1-220 (PostgreSQL pg-core dialect)
- `src/server/services/item.service.ts` -- Current item CRUD (153 lines)
- `src/server/services/global-item.service.ts` -- Current global item service with link/unlink (76 lines)
- `src/server/services/thread.service.ts` -- resolveThread at line 293 (full data copy pattern)
- `src/server/services/setup.service.ts` -- Setup queries that read items with weight/price
- `src/server/services/totals.service.ts` -- Aggregate weight/cost queries
- `src/server/services/profile.service.ts` -- Public setup item queries
- `tests/helpers/db.ts` -- PGlite test infrastructure
- `drizzle-pg/0001_tough_boomerang.sql` -- Latest migration (created globalItems + itemGlobalLinks)
- npm registry: drizzle-orm@0.45.2, @electric-sql/pglite@0.4.3

### Secondary (MEDIUM confidence)
- Drizzle ORM documentation for LEFT JOIN and COALESCE patterns with pg-core

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH - no new dependencies, all libraries already in use
- Architecture: HIGH - COALESCE merge pattern is standard SQL, schema changes are straightforward
- Pitfalls: HIGH - identified from direct code analysis of all query locations

**Research date:** 2026-04-05
**Valid until:** 2026-05-05 (stable schema, no external dependency changes expected)
