docs(19-01): complete reference item model and tags schema plan
- Add 19-01-SUMMARY.md with execution results - Update STATE.md with phase 19 position and decisions - Update ROADMAP.md with plan progress
This commit is contained in:
@@ -3,7 +3,7 @@ gsd_state_version: 1.0
|
||||
milestone: v1.3
|
||||
milestone_name: Research & Decision Tools
|
||||
status: planning
|
||||
stopped_at: Completed 18-05-PLAN.md
|
||||
stopped_at: Completed 19-01-PLAN.md
|
||||
last_updated: "2026-04-05T11:22:25.312Z"
|
||||
last_activity: 2026-04-05
|
||||
progress:
|
||||
@@ -25,12 +25,12 @@ See: .planning/PROJECT.md (updated 2026-04-03)
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 18 of 18 (PostgreSQL Migration)
|
||||
Plan: Not started
|
||||
Status: Ready to plan
|
||||
Phase: 19 of 19 (Reference Item Model & Tags Schema)
|
||||
Plan: 1 of 3
|
||||
Status: Executing
|
||||
Last activity: 2026-04-05
|
||||
|
||||
Progress: [----------] 0% (v2.0 milestone)
|
||||
Progress: [#---------] 3% (v2.0 milestone)
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
@@ -55,6 +55,9 @@ Key decisions made during v2.0 planning:
|
||||
- Separate globalItems table — not a flag on user items table
|
||||
- Single-user SQLite mode diverges at v2.0 boundary
|
||||
- [Phase 18]: Profile data loaded via usePublicProfile(userId) not /auth/me extension
|
||||
- [Phase 19]: Direct globalItemId FK on items replaces itemGlobalLinks junction table
|
||||
- [Phase 19]: Data migration SQL: UPDATE items before DROP TABLE item_global_links
|
||||
- [Phase 19]: Flat tags system without type categorization per D-16
|
||||
|
||||
### Pending Todos
|
||||
|
||||
@@ -67,6 +70,6 @@ None active.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-04-05T11:20:56.920Z
|
||||
Stopped at: Completed 18-05-PLAN.md
|
||||
Last session: 2026-04-05T18:28:00Z
|
||||
Stopped at: Completed 19-01-PLAN.md
|
||||
Resume file: None
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
---
|
||||
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>
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
phase: 19-reference-item-model-tags-schema
|
||||
plan: 01
|
||||
subsystem: database
|
||||
tags: [drizzle, postgres, schema, migration, tags, reference-items]
|
||||
|
||||
requires:
|
||||
- phase: 18-global-items-public-profiles
|
||||
provides: globalItems table and itemGlobalLinks junction table
|
||||
provides:
|
||||
- items.globalItemId direct FK replacing itemGlobalLinks junction table
|
||||
- items.purchasePriceCents for user-specific purchase price tracking
|
||||
- threadCandidates.globalItemId for catalog-linked candidates
|
||||
- tags and globalItemTags tables for tag-based discovery
|
||||
- Zod schemas with globalItemId and purchasePriceCents fields
|
||||
- Tag and GlobalItemTag TypeScript types
|
||||
- 30 curated seed tags for outdoor/adventure gear
|
||||
affects: [19-02, 19-03, global-item-service, item-service, thread-service]
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Reference item model: nullable globalItemId FK on items replaces junction table"
|
||||
- "Tag system: flat tags table with many-to-many via globalItemTags"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- drizzle-pg/0002_wakeful_vermin.sql
|
||||
modified:
|
||||
- src/db/schema.ts
|
||||
- src/shared/schemas.ts
|
||||
- src/shared/types.ts
|
||||
- src/db/seed-global-items.ts
|
||||
|
||||
key-decisions:
|
||||
- "Data migration in SQL: UPDATE items SET global_item_id before DROP TABLE item_global_links"
|
||||
- "Seed tags as flat list without type categorization per D-16"
|
||||
|
||||
patterns-established:
|
||||
- "Reference items: globalItemId nullable FK on items table, when set base data comes from global item"
|
||||
- "Tag seeding: idempotent async seedTags function alongside seedGlobalItems"
|
||||
|
||||
requirements-completed: [CATFLOW-03, TAG-01, TAG-02]
|
||||
|
||||
duration: 4min
|
||||
completed: 2026-04-05
|
||||
---
|
||||
|
||||
# Phase 19 Plan 01: Reference Item Model & Tags Schema Summary
|
||||
|
||||
**Database schema updated with direct globalItemId FK on items/candidates, tags system tables, and data migration from itemGlobalLinks**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 4 min
|
||||
- **Started:** 2026-04-05T18:23:49Z
|
||||
- **Completed:** 2026-04-05T18:28:00Z
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 5
|
||||
|
||||
## Accomplishments
|
||||
- Added globalItemId and purchasePriceCents columns to items table, globalItemId to threadCandidates
|
||||
- Created tags and globalItemTags tables for tag-based global item discovery
|
||||
- Removed itemGlobalLinks junction table with safe data migration in SQL
|
||||
- Updated Zod schemas with new fields, removed linkItemSchema
|
||||
- Converted seed script to async with 30 curated outdoor/adventure tags
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Update schema.ts, generate migration with data migration step** - `5df513c` (feat)
|
||||
2. **Task 2: Update Zod schemas, types, test helpers, and seed script** - `e9baa8d` (feat)
|
||||
|
||||
## Files Created/Modified
|
||||
- `src/db/schema.ts` - Added globalItemId/purchasePriceCents to items, globalItemId to threadCandidates, tags + globalItemTags tables, removed itemGlobalLinks
|
||||
- `src/shared/schemas.ts` - Added globalItemId/purchasePriceCents to createItemSchema, globalItemId to createCandidateSchema, tags to searchGlobalItemsSchema, removed linkItemSchema
|
||||
- `src/shared/types.ts` - Added Tag and GlobalItemTag types, removed ItemGlobalLink and LinkItem
|
||||
- `src/db/seed-global-items.ts` - Converted to async, added seedTags with 30 curated tags
|
||||
- `drizzle-pg/0002_wakeful_vermin.sql` - Migration with ADD COLUMN, data migration UPDATE, DROP TABLE
|
||||
|
||||
## Decisions Made
|
||||
- Reordered generated migration SQL to ensure data migration (UPDATE items SET global_item_id) runs before DROP TABLE item_global_links
|
||||
- Kept seed tags as flat list per D-16 (no type categorization)
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] drizzle-kit generate interactive prompt**
|
||||
- **Found during:** Task 1 (migration generation)
|
||||
- **Issue:** drizzle-kit detected table rename ambiguity between itemGlobalLinks and globalItemTags, prompting interactively
|
||||
- **Fix:** Used Bun.spawn with piped stdin to programmatically select "create table" option
|
||||
- **Files modified:** None (tooling workaround)
|
||||
- **Verification:** Migration file generated correctly
|
||||
- **Committed in:** 5df513c (Task 1 commit)
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 auto-fixed (1 blocking)
|
||||
**Impact on plan:** Tooling workaround only, no code impact.
|
||||
|
||||
## Issues Encountered
|
||||
None beyond the drizzle-kit interactive prompt handled above.
|
||||
|
||||
## User Setup Required
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
- Schema foundation ready for service layer updates (plan 19-02)
|
||||
- Services referencing itemGlobalLinks, linkItemToGlobal, unlinkItemFromGlobal need updating
|
||||
- Test files referencing removed schema entities need updating
|
||||
- Client code referencing LinkToGlobalItem component needs updating
|
||||
|
||||
---
|
||||
*Phase: 19-reference-item-model-tags-schema*
|
||||
*Completed: 2026-04-05*
|
||||
@@ -0,0 +1,137 @@
|
||||
# Phase 19: Reference Item Model & Tags Schema - Context
|
||||
|
||||
**Gathered:** 2026-04-05
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Transform collection items from full data copies to reference pointers at global catalog entries. A reference item stores `globalItemId` + personal fields (categoryId, notes, purchasePriceCents, imageFilename, quantity); base data (brand, model, weight, MSRP, image, description) comes from the linked global item. Add a tag system so global items can be tagged for discovery and filtering. Update thread candidates to support `globalItemId`. Update thread resolution to create reference items with auto-link.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Reference Item Model
|
||||
- **D-01:** Add `globalItemId` (nullable FK → 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.
|
||||
|
||||
### Merged Data Strategy
|
||||
- **D-06:** Service layer handles merging transparently. `getAllItems()` and `getItem()` join on globalItems when `globalItemId` is set, returning a unified shape. Clients see one type — no need to distinguish reference vs standalone.
|
||||
- **D-07:** API response shape stays the same as current Item type. Global data fills in name, weight, price, etc. for reference items. Personal overrides (if any exist) take precedence — but initially only the fields listed in D-03 are personal.
|
||||
- **D-08:** `productUrl` on reference items: comes from the global item's future `productUrl` field if added, otherwise NULL. For now, reference items may not have a product URL unless the global item has one. (Not critical — users can add to notes.)
|
||||
|
||||
### Thread Candidates
|
||||
- **D-09:** Add `globalItemId` (nullable FK → globalItems) to `threadCandidates` table. Candidates added from catalog have this set.
|
||||
- **D-10:** Candidates with `globalItemId` display global item data (brand + model as name, weight, price, image). Personal candidate fields (notes, pros, cons, status) remain on the candidate row.
|
||||
- **D-11:** Candidates without `globalItemId` work as before (fully manual data).
|
||||
|
||||
### Thread Resolution
|
||||
- **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 (categoryId, notes, imageFilename). Don't copy name/weight/price — those come from global item.
|
||||
- **D-13:** When resolving a candidate WITHOUT `globalItemId`, behavior stays the same as today — full data copy.
|
||||
|
||||
### Tag System
|
||||
- **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 (gear-type, activity, property, etc.) for now. Keep it simple. Type categorization can be added later.
|
||||
- **D-17:** Seed initial tag set via script covering common bikepacking/outdoor gear categories: handlebar-bag, framebag, saddlebag, tent, bivy, tarp, sleeping-bag, sleeping-pad, stove, cookware, headlamp, waterproof, ultralight, budget, premium, etc.
|
||||
- **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.
|
||||
|
||||
### Migration
|
||||
- **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) — the service layer prefers global data for reference items, but having the old data as fallback is safe 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)
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Design Spec
|
||||
- `docs/superpowers/specs/2026-04-05-catalog-driven-gear-flow-design.md` — Full catalog-driven gear flow vision. Phase 19 implements the data model foundation described here.
|
||||
|
||||
### Schema
|
||||
- `src/db/schema.ts` — Current schema. Add globalItemId to items and threadCandidates, add tags + globalItemTags tables, remove itemGlobalLinks.
|
||||
|
||||
### Services (must be updated for merged data)
|
||||
- `src/server/services/item.service.ts` — Item CRUD — must join globalItems for reference items
|
||||
- `src/server/services/global-item.service.ts` — Global item search — add tag filtering, remove linkItemToGlobal/unlinkItemFromGlobal (replaced by direct FK)
|
||||
- `src/server/services/thread.service.ts` — resolveThread() at line 293 — must create reference items when candidate has globalItemId
|
||||
|
||||
### Routes
|
||||
- `src/server/routes/items.ts` — Item routes — link/unlink endpoints removed (replaced by globalItemId on item)
|
||||
- `src/server/routes/global-items.ts` — Add tag filtering to search
|
||||
|
||||
### Client (linking UI removed)
|
||||
- `src/client/components/LinkToGlobalItem.tsx` — Remove or repurpose (direct linking replaced by add-from-catalog flow in Phase 21)
|
||||
|
||||
### Tests
|
||||
- `tests/services/global-item.service.test.ts` — Update for removed link/unlink, add tag tests
|
||||
- `tests/services/thread.service.test.ts` — Update resolve tests for reference item creation
|
||||
- `tests/helpers/db.ts` — Update for new schema (tags, globalItemTags, items.globalItemId)
|
||||
|
||||
### Requirements
|
||||
- `.planning/REQUIREMENTS.md` — CATFLOW-03, CATFLOW-04, CATFLOW-05, CATFLOW-06, TAG-01, TAG-02
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `globalItems` table already exists with brand, model, category, weight, price, image, description
|
||||
- `global-item.service.ts` has searchGlobalItems() with ILIKE — extend with tag filtering
|
||||
- `thread.service.ts` resolveThread() — modify step 4 (item creation) for reference items
|
||||
- `seed-global-items.ts` — extend seed script to also seed tags and assign them
|
||||
|
||||
### Established Patterns
|
||||
- Service DI pattern: `(db: Db, userId: number, ...)` — consistent across all services
|
||||
- Drizzle ORM with pg-core — use same patterns for new tables
|
||||
- Zod schemas in `src/shared/schemas.ts` — update for new fields
|
||||
- Types inferred from Zod + Drizzle in `src/shared/types.ts`
|
||||
|
||||
### Integration Points
|
||||
- `src/db/schema.ts` — Add tables, add columns, remove itemGlobalLinks
|
||||
- `src/server/index.ts` — No new route groups needed (existing global-items routes extended)
|
||||
- `tests/helpers/db.ts` — Must include new tables in test migrations
|
||||
- `src/db/seed-global-items.ts` — Extend to seed tags
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- The reference model is the foundation for future crowd-sourced data: purchase prices (what users actually paid), real-world weights (vs manufacturer claims), and geographic price intelligence. The `purchasePriceCents` field is the first step.
|
||||
- Catalog submission system (manual item → submit → admin review → convert to reference) is deferred but the data model should make this possible later.
|
||||
- Weight override on personal items is deferred — not critical for now.
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- Catalog submission system — manual items submitted for admin review, approved items convert to references (saved in memory)
|
||||
- Crowd-sourced purchase price intelligence — aggregate what users paid, show market prices vs MSRP (saved in memory)
|
||||
- Crowd-sourced weight intelligence — user-submitted actual weights vs manufacturer claims (saved in memory)
|
||||
- Admin tag management UI — manage tags via settings, not just seed script
|
||||
- Tag type categorization — gear-type, activity, property, mounting as tag types
|
||||
- Personal weight override field on items
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 19-reference-item-model-tags-schema*
|
||||
*Context gathered: 2026-04-05*
|
||||
@@ -0,0 +1,526 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user