From 5df513c138368e1cba1254aa6f4a3a9cac0e8da2 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 20:25:59 +0200 Subject: [PATCH 1/3] feat(19-01): update schema with reference item model and tags tables - Add globalItemId and purchasePriceCents columns to items table - Add globalItemId column to threadCandidates table - Add tags and globalItemTags tables for tag system - Remove itemGlobalLinks table (replaced by direct FK) - Generate migration with data migration step before table drop --- drizzle-pg/0002_wakeful_vermin.sql | 27 + drizzle-pg/meta/0002_snapshot.json | 1171 ++++++++++++++++++++++++++++ drizzle-pg/meta/_journal.json | 7 + src/db/schema.ts | 31 +- 4 files changed, 1227 insertions(+), 9 deletions(-) create mode 100644 drizzle-pg/0002_wakeful_vermin.sql create mode 100644 drizzle-pg/meta/0002_snapshot.json diff --git a/drizzle-pg/0002_wakeful_vermin.sql b/drizzle-pg/0002_wakeful_vermin.sql new file mode 100644 index 0000000..c55099d --- /dev/null +++ b/drizzle-pg/0002_wakeful_vermin.sql @@ -0,0 +1,27 @@ +CREATE TABLE "tags" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "tags_name_unique" UNIQUE("name") +); +--> statement-breakpoint +CREATE TABLE "global_item_tags" ( + "global_item_id" integer NOT NULL, + "tag_id" integer NOT NULL, + CONSTRAINT "global_item_tags_global_item_id_tag_id_pk" PRIMARY KEY("global_item_id","tag_id") +); +--> statement-breakpoint +ALTER TABLE "items" ADD COLUMN "global_item_id" integer;--> statement-breakpoint +ALTER TABLE "items" ADD COLUMN "purchase_price_cents" integer;--> statement-breakpoint +ALTER TABLE "thread_candidates" ADD COLUMN "global_item_id" integer;--> statement-breakpoint +UPDATE "items" SET "global_item_id" = ( + SELECT "global_item_id" FROM "item_global_links" + WHERE "item_global_links"."item_id" = "items"."id" +); +--> statement-breakpoint +ALTER TABLE "item_global_links" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint +DROP TABLE "item_global_links" CASCADE;--> statement-breakpoint +ALTER TABLE "global_item_tags" ADD CONSTRAINT "global_item_tags_global_item_id_global_items_id_fk" FOREIGN KEY ("global_item_id") REFERENCES "public"."global_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "global_item_tags" ADD CONSTRAINT "global_item_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "items" ADD CONSTRAINT "items_global_item_id_global_items_id_fk" FOREIGN KEY ("global_item_id") REFERENCES "public"."global_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "thread_candidates" ADD CONSTRAINT "thread_candidates_global_item_id_global_items_id_fk" FOREIGN KEY ("global_item_id") REFERENCES "public"."global_items"("id") ON DELETE no action ON UPDATE no action; diff --git a/drizzle-pg/meta/0002_snapshot.json b/drizzle-pg/meta/0002_snapshot.json new file mode 100644 index 0000000..6aaaaa4 --- /dev/null +++ b/drizzle-pg/meta/0002_snapshot.json @@ -0,0 +1,1171 @@ +{ + "id": "1c8fbda2-e486-4f57-a6d5-1a2e7042e413", + "prevId": "8fb47390-ff75-41f7-aa35-fad97b1a097e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'package'" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "categories_user_id_users_id_fk": { + "name": "categories_user_id_users_id_fk", + "tableFrom": "categories", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_user_id_name_unique": { + "name": "categories_user_id_name_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.global_item_tags": { + "name": "global_item_tags", + "schema": "", + "columns": { + "global_item_id": { + "name": "global_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "global_item_tags_global_item_id_global_items_id_fk": { + "name": "global_item_tags_global_item_id_global_items_id_fk", + "tableFrom": "global_item_tags", + "tableTo": "global_items", + "columnsFrom": [ + "global_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "global_item_tags_tag_id_tags_id_fk": { + "name": "global_item_tags_tag_id_tags_id_fk", + "tableFrom": "global_item_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "global_item_tags_global_item_id_tag_id_pk": { + "name": "global_item_tags_global_item_id_tag_id_pk", + "columns": [ + "global_item_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.global_items": { + "name": "global_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weight_grams": { + "name": "weight_grams", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.items": { + "name": "items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight_grams": { + "name": "weight_grams", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_url": { + "name": "product_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_filename": { + "name": "image_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_source_url": { + "name": "image_source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "global_item_id": { + "name": "global_item_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "purchase_price_cents": { + "name": "purchase_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "items_category_id_categories_id_fk": { + "name": "items_category_id_categories_id_fk", + "tableFrom": "items", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "items_user_id_users_id_fk": { + "name": "items_user_id_users_id_fk", + "tableFrom": "items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "items_global_item_id_global_items_id_fk": { + "name": "items_global_item_id_global_items_id_fk", + "tableFrom": "items", + "tableTo": "global_items", + "columnsFrom": [ + "global_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_clients": { + "name": "oauth_clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_clients_client_id_unique": { + "name": "oauth_clients_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_codes": { + "name": "oauth_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'S256'" + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_codes_code_unique": { + "name": "oauth_codes_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_tokens": { + "name": "oauth_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "access_token_hash": { + "name": "access_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_hash": { + "name": "refresh_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "refresh_expires_at": { + "name": "refresh_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_tokens_user_id_users_id_fk": { + "name": "oauth_tokens_user_id_users_id_fk", + "tableFrom": "oauth_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_tokens_access_token_hash_unique": { + "name": "oauth_tokens_access_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "access_token_hash" + ] + }, + "oauth_tokens_refresh_token_hash_unique": { + "name": "oauth_tokens_refresh_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_users_id_fk": { + "name": "settings_user_id_users_id_fk", + "tableFrom": "settings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "settings_user_id_key_pk": { + "name": "settings_user_id_key_pk", + "columns": [ + "user_id", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.setup_items": { + "name": "setup_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "setup_id": { + "name": "setup_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "classification": { + "name": "classification", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'base'" + } + }, + "indexes": {}, + "foreignKeys": { + "setup_items_setup_id_setups_id_fk": { + "name": "setup_items_setup_id_setups_id_fk", + "tableFrom": "setup_items", + "tableTo": "setups", + "columnsFrom": [ + "setup_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "setup_items_item_id_items_id_fk": { + "name": "setup_items_item_id_items_id_fk", + "tableFrom": "setup_items", + "tableTo": "items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.setups": { + "name": "setups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "setups_user_id_users_id_fk": { + "name": "setups_user_id_users_id_fk", + "tableFrom": "setups", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_name_unique": { + "name": "tags_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.thread_candidates": { + "name": "thread_candidates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight_grams": { + "name": "weight_grams", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_url": { + "name": "product_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_filename": { + "name": "image_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_source_url": { + "name": "image_source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'researching'" + }, + "pros": { + "name": "pros", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cons": { + "name": "cons", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "global_item_id": { + "name": "global_item_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "thread_candidates_thread_id_threads_id_fk": { + "name": "thread_candidates_thread_id_threads_id_fk", + "tableFrom": "thread_candidates", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "thread_candidates_category_id_categories_id_fk": { + "name": "thread_candidates_category_id_categories_id_fk", + "tableFrom": "thread_candidates", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "thread_candidates_global_item_id_global_items_id_fk": { + "name": "thread_candidates_global_item_id_global_items_id_fk", + "tableFrom": "thread_candidates", + "tableTo": "global_items", + "columnsFrom": [ + "global_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.threads": { + "name": "threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "resolved_candidate_id": { + "name": "resolved_candidate_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "threads_category_id_categories_id_fk": { + "name": "threads_category_id_categories_id_fk", + "tableFrom": "threads", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "threads_user_id_users_id_fk": { + "name": "threads_user_id_users_id_fk", + "tableFrom": "threads", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "logto_sub": { + "name": "logto_sub", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_logto_sub_unique": { + "name": "users_logto_sub_unique", + "nullsNotDistinct": false, + "columns": [ + "logto_sub" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle-pg/meta/_journal.json b/drizzle-pg/meta/_journal.json index ba29933..746a6fb 100644 --- a/drizzle-pg/meta/_journal.json +++ b/drizzle-pg/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1775386658636, "tag": "0001_tough_boomerang", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1775413526643, + "tag": "0002_wakeful_vermin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index b11d6bc..cf06baf 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -55,6 +55,8 @@ export const items = pgTable("items", { imageFilename: text("image_filename"), imageSourceUrl: text("image_source_url"), quantity: integer("quantity").notNull().default(1), + globalItemId: integer("global_item_id").references(() => globalItems.id), + purchasePriceCents: integer("purchase_price_cents"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); @@ -97,6 +99,7 @@ export const threadCandidates = pgTable("thread_candidates", { pros: text("pros"), cons: text("cons"), sortOrder: doublePrecision("sort_order").notNull().default(0), + globalItemId: integer("global_item_id").references(() => globalItems.id), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); @@ -141,19 +144,29 @@ export const globalItems = pgTable("global_items", { createdAt: timestamp("created_at").defaultNow().notNull(), }); -// ── Item Global Links ─────────────────────────────────────────────── +// ── Tags ─────────────────────────────────────────────────────────── -export const itemGlobalLinks = pgTable("item_global_links", { +export const tags = pgTable("tags", { id: serial("id").primaryKey(), - itemId: integer("item_id") - .notNull() - .references(() => items.id, { onDelete: "cascade" }) - .unique(), - globalItemId: integer("global_item_id") - .notNull() - .references(() => globalItems.id, { onDelete: "cascade" }), + name: text("name").notNull().unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), }); +// ── Global Item Tags ─────────────────────────────────────────────── + +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] })], +); + // ── Settings ──────────────────────────────────────────────────────── export const settings = pgTable( From e9baa8d7e0744f36a9f510506ee5900ed4689850 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 20:27:51 +0200 Subject: [PATCH 2/3] feat(19-01): update Zod schemas, types, and seed script for reference model - Add globalItemId and purchasePriceCents to createItemSchema - Add globalItemId to createCandidateSchema - Add tags param to searchGlobalItemsSchema - Remove linkItemSchema from schemas and types - Replace ItemGlobalLink with Tag and GlobalItemTag types - Convert seedGlobalItems to async, add seedTags with 30 curated tags --- src/db/seed-global-items.ts | 72 ++++++++++++++++++++++++++++++------- src/shared/schemas.ts | 8 ++--- src/shared/types.ts | 8 ++--- 3 files changed, 67 insertions(+), 21 deletions(-) diff --git a/src/db/seed-global-items.ts b/src/db/seed-global-items.ts index 162a801..7ca0f2e 100644 --- a/src/db/seed-global-items.ts +++ b/src/db/seed-global-items.ts @@ -1,27 +1,73 @@ import seedData from "./global-items-seed.json"; import { db as prodDb } from "./index.ts"; -import { globalItems } from "./schema.ts"; +import { globalItems, tags } from "./schema.ts"; type Db = typeof prodDb; +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", +]; + +/** + * Seed curated tags for outdoor/adventure gear. + * Idempotent: skips if any tags already exist. + */ +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 }); + } +} + /** * Seed the global items table with initial bikepacking gear data. * Idempotent: skips if any rows already exist. */ -export function seedGlobalItems(db: Db = prodDb) { - const existing = db.select().from(globalItems).limit(1).all(); +export async function seedGlobalItems(db: Db = prodDb) { + const existing = await db.select().from(globalItems).limit(1); if (existing.length > 0) return; for (const item of seedData) { - db.insert(globalItems) - .values({ - brand: item.brand, - model: item.model, - category: item.category ?? null, - weightGrams: item.weightGrams ?? null, - priceCents: item.priceCents ?? null, - description: item.description ?? null, - }) - .run(); + await db.insert(globalItems).values({ + brand: item.brand, + model: item.model, + category: item.category ?? null, + weightGrams: item.weightGrams ?? null, + priceCents: item.priceCents ?? null, + description: item.description ?? null, + }); } + + await seedTags(db); } diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index 0e68b9a..0a02453 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -10,6 +10,8 @@ export const createItemSchema = z.object({ 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(), }); export const updateItemSchema = createItemSchema.partial().extend({ @@ -58,6 +60,7 @@ export const createCandidateSchema = z.object({ status: candidateStatusSchema.optional(), pros: z.string().optional(), cons: z.string().optional(), + globalItemId: z.number().int().positive().optional(), }); export const updateCandidateSchema = createCandidateSchema.partial(); @@ -95,10 +98,7 @@ export const updateClassificationSchema = z.object({ // Global item schemas export const searchGlobalItemsSchema = z.object({ q: z.string().optional(), -}); - -export const linkItemSchema = z.object({ - globalItemId: z.number().int().positive(), + tags: z.string().optional(), }); // Profile schemas diff --git a/src/shared/types.ts b/src/shared/types.ts index 3069dad..a27d75b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2,10 +2,11 @@ import type { z } from "zod"; import type { categories, globalItems, - itemGlobalLinks, + globalItemTags, items, setupItems, setups, + tags, threadCandidates, threads, } from "../db/schema.ts"; @@ -15,7 +16,6 @@ import type { createItemSchema, createSetupSchema, createThreadSchema, - linkItemSchema, reorderCandidatesSchema, resolveThreadSchema, searchGlobalItemsSchema, @@ -49,7 +49,6 @@ export type UpdateClassification = z.infer; // Global item types export type SearchGlobalItems = z.infer; -export type LinkItem = z.infer; export type UpdateProfile = z.infer; // Types inferred from Drizzle schema @@ -60,4 +59,5 @@ export type ThreadCandidate = typeof threadCandidates.$inferSelect; export type Setup = typeof setups.$inferSelect; export type SetupItem = typeof setupItems.$inferSelect; export type GlobalItem = typeof globalItems.$inferSelect; -export type ItemGlobalLink = typeof itemGlobalLinks.$inferSelect; +export type Tag = typeof tags.$inferSelect; +export type GlobalItemTag = typeof globalItemTags.$inferSelect; From a7ec72a761475675e0e7395d6e84c4ab953ae232 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 20:29:27 +0200 Subject: [PATCH 3/3] 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 --- .planning/STATE.md | 17 +- .../19-01-PLAN.md | 343 ++++++++++++ .../19-01-SUMMARY.md | 117 ++++ .../19-CONTEXT.md | 137 +++++ .../19-RESEARCH.md | 526 ++++++++++++++++++ 5 files changed, 1133 insertions(+), 7 deletions(-) create mode 100644 .planning/phases/19-reference-item-model-tags-schema/19-01-PLAN.md create mode 100644 .planning/phases/19-reference-item-model-tags-schema/19-01-SUMMARY.md create mode 100644 .planning/phases/19-reference-item-model-tags-schema/19-CONTEXT.md create mode 100644 .planning/phases/19-reference-item-model-tags-schema/19-RESEARCH.md diff --git a/.planning/STATE.md b/.planning/STATE.md index bdacfb0..7e46721 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -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 diff --git a/.planning/phases/19-reference-item-model-tags-schema/19-01-PLAN.md b/.planning/phases/19-reference-item-model-tags-schema/19-01-PLAN.md new file mode 100644 index 0000000..f235f28 --- /dev/null +++ b/.planning/phases/19-reference-item-model-tags-schema/19-01-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + +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; // REMOVE +// ADD: Tag, GlobalItemTag types from new schema tables +``` + + + + + + + Task 1: Update schema.ts, generate migration with data migration step + src/db/schema.ts + + - src/db/schema.ts + - .planning/phases/19-reference-item-model-tags-schema/19-RESEARCH.md (migration order section) + + + 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). + + + 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" + + + - 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"` + + Schema has globalItemId on items and threadCandidates, purchasePriceCents on items, tags + globalItemTags tables, no itemGlobalLinks. Migration includes data migration step. + + + + Task 2: Update Zod schemas, types, test helpers, and seed script + src/shared/schemas.ts, src/shared/types.ts, tests/helpers/db.ts, src/db/seed-global-items.ts + + - 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) + + + **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;`. + 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. + + + 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" + + + - 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) + + Zod schemas accept new fields, old link schema removed, types updated, seed script creates tags, test helper works with new schema. + + + + + +```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 +``` + + + +- 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) + + + +After completion, create `.planning/phases/19-reference-item-model-tags-schema/19-01-SUMMARY.md` + diff --git a/.planning/phases/19-reference-item-model-tags-schema/19-01-SUMMARY.md b/.planning/phases/19-reference-item-model-tags-schema/19-01-SUMMARY.md new file mode 100644 index 0000000..41332df --- /dev/null +++ b/.planning/phases/19-reference-item-model-tags-schema/19-01-SUMMARY.md @@ -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* diff --git a/.planning/phases/19-reference-item-model-tags-schema/19-CONTEXT.md b/.planning/phases/19-reference-item-model-tags-schema/19-CONTEXT.md new file mode 100644 index 0000000..30b67cf --- /dev/null +++ b/.planning/phases/19-reference-item-model-tags-schema/19-CONTEXT.md @@ -0,0 +1,137 @@ +# Phase 19: Reference Item Model & Tags Schema - Context + +**Gathered:** 2026-04-05 +**Status:** Ready for planning + + +## 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. + + + + +## 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) + + + + +## 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 + + + + +## 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 + + + + +## 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. + + + + +## 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 + + + +--- + +*Phase: 19-reference-item-model-tags-schema* +*Context gathered: 2026-04-05* diff --git a/.planning/phases/19-reference-item-model-tags-schema/19-RESEARCH.md b/.planning/phases/19-reference-item-model-tags-schema/19-RESEARCH.md new file mode 100644 index 0000000..3e537e4 --- /dev/null +++ b/.planning/phases/19-reference-item-model-tags-schema/19-RESEARCH.md @@ -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 (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 + + + +## 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. | + + +## 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`COALESCE( + CASE WHEN ${items.globalItemId} IS NOT NULL + THEN ${globalItems.brand} || ' ' || ${globalItems.model} + ELSE ${items.name} + END, + ${items.name} + )`.as("name"), + weightGrams: sql`COALESCE( + ${globalItems.weightGrams}, + ${items.weightGrams} + )`.as("weight_grams"), + priceCents: sql`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`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)