29 KiB
Phase 25: Catalog Enrichment & Agent Tools - Research
Researched: 2026-04-10 Domain: Drizzle ORM upserts + Hono REST API + MCP tool registration + React detail page Confidence: HIGH
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
- D-01: Add three new columns to
globalItems:sourceUrl(text, nullable),imageCredit(text, nullable),imageSourceUrl(text, nullable). - D-02: No separate
manufacturercolumn — existingbrandfield serves this purpose. - D-03: Attribution display on catalog detail page: image credit and source link shown inline below the image, not in a collapsible section.
- D-04: Add a unique constraint on
(brand, model)toglobalItems. - D-05: Upsert semantics on conflict:
ON CONFLICT (brand, model) DO UPDATE— update all non-key fields. - D-06:
POST /api/global-items/bulk— accepts array of items, upserts all in a single transaction. Requires API key auth. - D-07: All-or-nothing transaction — if any item fails validation, reject the entire batch with detailed error response.
- D-08: Maximum 100 items per request. Return count of created vs updated items in the response.
- D-09: Request body shape:
{ items: [{ brand, model, category?, weightGrams?, priceCents?, imageUrl?, description?, sourceUrl?, imageCredit?, imageSourceUrl?, tags?: string[] }] }. - D-10:
POST /api/global-items— upsert a single catalog item with the same conflict handling as bulk. Also requires auth. - D-11: Add
upsert_catalog_itemMCP tool — writes directly toglobalItems(not user-scoped). - D-12: Add
bulk_upsert_catalogMCP tool — accepts array of items, calls the bulk import service. - D-13: MCP catalog tools require authenticated session (API key or OAuth bearer), same as all existing MCP tools.
- D-14: Register new MCP tools in
src/server/mcp/index.tsfollowing the existing pattern.
Claude's Discretion
- Drizzle migration approach for new columns and unique constraint
- Exact Zod validation schemas for bulk import payload
- MCP tool descriptions and parameter documentation
- Tag handling in upsert (create-if-not-exists vs require existing tags)
- Response shape details for bulk import (created/updated counts, item IDs)
Deferred Ideas (OUT OF SCOPE)
- SEED-04 (actual seeding run with 100+ items)
- Admin role for catalog management
- Catalog item edit UI
</user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| CATL-01 | Global items have attribution fields (sourceUrl, manufacturer, imageCredit, imageSourceUrl) | D-01: schema migration adds three columns; brand already serves manufacturer role (D-02) |
| CATL-02 | Global items have a unique constraint on (brand, model) preventing duplicates | D-04: Drizzle unique() constraint in schema; migration via bun run db:generate && db:push |
| CATL-03 | Catalog detail pages display image attribution with credit and source link | D-03: inline display below image in $globalItemId.tsx; GlobalItem interface needs new fields |
| CATL-04 | Bulk import API endpoint accepts multiple catalog items in one request | D-06: POST /api/global-items/bulk; zValidator + bulkUpsertGlobalItems service function |
| CATL-05 | Bulk import uses upsert semantics (ON CONFLICT update, not fail) | D-05: onConflictDoUpdate({ target: [brand, model], set: {...} }) — already used elsewhere |
| SEED-01 | MCP server has upsert_catalog_item tool writing to globalItems (not user-scoped) |
D-11/D-14: new catalog.ts tool file following items.ts pattern; registered in mcp/index.ts |
| SEED-02 | MCP server has bulk_upsert_catalog tool for batch catalog population |
D-12: same tool file; calls bulkUpsertGlobalItems service |
| SEED-03 | Catalog MCP tools include attribution fields (sourceUrl, manufacturer, imageCredit) as parameters | D-11/D-12: inputSchema includes all attribution fields; same auth model as existing tools |
</phase_requirements>
Summary
Phase 25 adds write capability to the global items catalog: attribution metadata columns, a uniqueness constraint on (brand, model), single and bulk upsert API endpoints, two new MCP tools, and attribution display on the catalog detail page. All patterns already exist in the codebase — this phase is entirely additive and follows established conventions.
The DB layer uses Drizzle ORM 0.45.1 with PostgreSQL (via pg driver in production, @electric-sql/pglite in tests). Drizzle's .onConflictDoUpdate() is already used in auth.service.ts (single-column conflict) and settings.ts (multi-column conflict), so the upsert pattern for (brand, model) is proven. The migration workflow is bun run db:generate (drizzle-kit) then bun run db:push.
MCP tools follow a three-part pattern: an exported *ToolDefinitions array, an exported register*Tools(db, userId) factory, and registration loops in mcp/index.ts. Catalog tools are unique in that they do NOT need userId (the catalog is shared, not user-scoped), but userId is still available for auth validation if needed.
Primary recommendation: Create src/server/mcp/tools/catalog.ts, extend global-item.service.ts with upsertGlobalItem and bulkUpsertGlobalItems, add POST routes to global-items.ts, run a schema migration, update the client hook interface, and add attribution display to $globalItemId.tsx.
Standard Stack
Core
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| drizzle-orm | 0.45.1 | ORM + upsert via onConflictDoUpdate |
Project standard; upsert already in use |
| drizzle-kit | 0.31.9 | Schema migration generation | Project standard; bun run db:generate |
| hono | 4.12.8 | HTTP routing for new POST endpoints | Project standard |
| @hono/zod-validator | 0.7.6 | Request body validation middleware | Used on every POST/PUT route |
| zod | 4.3.6 | Schema definitions for bulk payload | Project standard for shared schemas |
| @modelcontextprotocol/sdk | 1.29.0 | MCP tool registration | Project standard; used in mcp/index.ts |
Supporting
| Library | Version | Purpose | When to Use |
|---|---|---|---|
| @electric-sql/pglite | 0.4.3 | In-memory Postgres for tests | Tests only — uses createTestDb() helper |
| @tanstack/react-query | 5.90.21 | Client-side data fetching | Update useGlobalItem interface after new fields land |
Installation: No new dependencies needed. All required libraries are already installed.
Architecture Patterns
Recommended Project Structure
The phase touches these existing files (no new directories needed):
src/
├── db/
│ └── schema.ts # Add 3 columns + unique constraint to globalItems
├── shared/
│ └── schemas.ts # Add upsertGlobalItemSchema + bulkUpsertSchema
├── server/
│ ├── services/
│ │ └── global-item.service.ts # Add upsertGlobalItem + bulkUpsertGlobalItems
│ ├── routes/
│ │ └── global-items.ts # Add POST / and POST /bulk handlers
│ └── mcp/
│ ├── index.ts # Register catalog tool group
│ └── tools/
│ └── catalog.ts # NEW: upsert_catalog_item + bulk_upsert_catalog
├── client/
│ ├── hooks/
│ │ └── useGlobalItems.ts # Add sourceUrl, imageCredit, imageSourceUrl to interface
│ └── routes/
│ └── global-items/
│ └── $globalItemId.tsx # Add attribution display below image
drizzle-pg/
└── XXXX_catalog_enrichment.sql # Generated migration
Pattern 1: Drizzle Upsert on Multi-Column Conflict
The onConflictDoUpdate API with an array target is already proven in settings.ts:
// Source: src/server/routes/settings.ts (line 33)
await database
.insert(settings)
.values({ userId, key, value: body.value })
.onConflictDoUpdate({
target: [settings.userId, settings.key],
set: { value: body.value },
});
For globalItems with a unique constraint on (brand, model), the pattern is:
// Adapts from settings.ts pattern — for global-item.service.ts
const [item] = await db
.insert(globalItems)
.values(data)
.onConflictDoUpdate({
target: [globalItems.brand, globalItems.model],
set: {
category: data.category,
weightGrams: data.weightGrams,
priceCents: data.priceCents,
imageUrl: data.imageUrl,
description: data.description,
sourceUrl: data.sourceUrl,
imageCredit: data.imageCredit,
imageSourceUrl: data.imageSourceUrl,
},
})
.returning();
return item;
Key insight: The unique constraint must exist on the table for .onConflictDoUpdate({ target: [...] }) to reference it. The Drizzle migration (generated from the schema change) creates the constraint — target in onConflictDoUpdate references the schema columns, not raw strings.
Pattern 2: All-or-Nothing Transaction for Bulk Upsert
Drizzle transactions are used in setup.service.ts and thread.service.ts. The bulk upsert wraps all inserts in a single transaction:
// Adapts from src/server/services/setup.service.ts (line 164)
export async function bulkUpsertGlobalItems(
db: Db,
items: UpsertGlobalItemInput[],
): Promise<{ created: number; updated: number; items: GlobalItem[] }> {
return await db.transaction(async (tx) => {
let created = 0;
let updated = 0;
const results: GlobalItem[] = [];
for (const data of items) {
// Check if exists to determine created vs updated count
const [existing] = await tx
.select({ id: globalItems.id })
.from(globalItems)
.where(and(eq(globalItems.brand, data.brand), eq(globalItems.model, data.model)));
const [item] = await tx
.insert(globalItems)
.values(data)
.onConflictDoUpdate({
target: [globalItems.brand, globalItems.model],
set: { /* all non-key fields */ },
})
.returning();
if (existing) updated++;
else created++;
results.push(item);
// Handle tags if provided
if (data.tags && data.tags.length > 0) {
await syncGlobalItemTags(tx, item.id, data.tags);
}
}
return { created, updated, items: results };
});
}
Note: If any .insert() throws (e.g., Zod fails at service level for a structural error), the transaction rolls back automatically. Zod validation happens at the route level BEFORE the transaction, so the transaction only sees pre-validated data.
Pattern 3: MCP Tool Registration (Catalog-Specific)
The catalog tools differ from other tool groups: they do not scope to userId. The createMcpServer function signature in mcp/index.ts passes userId to every tool factory — catalog tools accept it but do not filter by it.
// Source: pattern from src/server/mcp/tools/images.ts
// images.ts already omits userId from its factory — catalog.ts follows the same approach
export const catalogToolDefinitions = [
{
name: "upsert_catalog_item",
description: "...",
inputSchema: {
brand: z.string().describe("Brand or manufacturer name"),
model: z.string().describe("Model name — combined with brand as unique identifier"),
// ... all fields
},
},
{
name: "bulk_upsert_catalog",
description: "...",
inputSchema: {
items: z.array(catalogItemSchema).max(100).describe("Array of catalog items to upsert"),
},
},
];
// Factory takes db only (no userId needed — catalog is global)
export function registerCatalogTools(db: Db) {
return {
upsert_catalog_item: async (args: UpsertArgs): Promise<ToolResult> => { ... },
bulk_upsert_catalog: async (args: { items: UpsertArgs[] }): Promise<ToolResult> => { ... },
};
}
In mcp/index.ts, register like imageHandlers (no userId dependency):
// In createMcpServer():
const catalogHandlers = registerCatalogTools(db);
for (const def of catalogToolDefinitions) {
const handler = catalogHandlers[def.name as keyof typeof catalogHandlers];
server.tool(def.name, def.description, def.inputSchema, handler);
}
Pattern 4: Schema Migration for Columns + Unique Constraint
Adding columns to an existing table and a unique constraint in Drizzle:
// In src/db/schema.ts — globalItems table
export const globalItems = pgTable(
"global_items",
{
id: serial("id").primaryKey(),
brand: text("brand").notNull(),
model: text("model").notNull(),
category: text("category"),
weightGrams: doublePrecision("weight_grams"),
priceCents: integer("price_cents"),
imageUrl: text("image_url"),
description: text("description"),
// NEW attribution columns:
sourceUrl: text("source_url"),
imageCredit: text("image_credit"),
imageSourceUrl: text("image_source_url"),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [unique().on(table.brand, table.model)], // NEW unique constraint
);
Run bun run db:generate to generate the migration SQL, then bun run db:push to apply. The generated SQL will contain ALTER TABLE "global_items" ADD COLUMN ... statements and a CREATE UNIQUE INDEX or CONSTRAINT statement.
Important: The existing seedGlobalItems function in src/db/seed-global-items.ts seeds with plain db.insert(). After the unique constraint lands, a second seed call would fail for duplicate (brand, model) pairs. The seed function is already idempotent by checking existing.length > 0, so no changes needed there.
Pattern 5: Attribution Display (Client)
The $globalItemId.tsx component renders the image in a div below which we add attribution. The useGlobalItem hook interface needs updating to include new fields, then the component can conditionally render them:
{/* After the image div, before the header */}
{(item.imageCredit || item.imageSourceUrl) && (
<p className="text-xs text-gray-400 mt-2">
{item.imageCredit && <span>Photo: {item.imageCredit}</span>}
{item.imageSourceUrl && (
<a
href={item.imageSourceUrl}
target="_blank"
rel="noopener noreferrer"
className="ml-1 underline hover:text-gray-600"
>
Source
</a>
)}
</p>
)}
Pattern 6: Tag Handling in Upsert (Claude's Discretion)
Recommendation: create-if-not-exists. The existing tags table has a unique constraint on name. Use onConflictDoUpdate (or onConflictDoNothing) on the tags table to get the tag ID, then upsert the globalItemTags junction. This lets agents pass tag names without pre-populating the tags table.
async function syncGlobalItemTags(tx: Tx, globalItemId: number, tagNames: string[]) {
// Delete existing tags for this item
await tx.delete(globalItemTags).where(eq(globalItemTags.globalItemId, globalItemId));
for (const name of tagNames) {
// Upsert tag (create if not exists)
const [tag] = await tx
.insert(tags)
.values({ name })
.onConflictDoUpdate({ target: tags.name, set: { name } })
.returning({ id: tags.id });
await tx.insert(globalItemTags).values({ globalItemId, tagId: tag.id });
}
}
Anti-Patterns to Avoid
- Do NOT modify
mcp/index.tscreateMcpServersignature to removeuserIdjust because catalog tools don't need it. Passdbonly toregisterCatalogTools— accept theuserIdparameter increateMcpServerand simply not forward it to catalog tools. - Do NOT validate tags at the Zod level as enum values. Tags are open-ended strings; only length/format validation is appropriate.
- Do NOT return partial success for bulk upsert. D-07 mandates all-or-nothing. Zod schema validation at the route level catches structural errors before any DB work begins.
- Do NOT add rate limiting to
POST /api/global-itemsorPOST /api/global-items/bulk. These are authenticated-write endpoints — the auth middleware already gates them. The rate limiting added in Phase 24 only applies to public GET endpoints.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Upsert on conflict | Manual SELECT then INSERT/UPDATE | onConflictDoUpdate() |
Drizzle handles atomicity; already used in settings.ts and auth.service.ts |
| All-or-nothing bulk write | Try/catch with manual rollback | db.transaction(async (tx) => { ... }) |
Drizzle handles rollback on throw; used in setup.service.ts |
| Tag create-if-not-exists | Check exists, insert if not | onConflictDoUpdate on tags.name |
Same conflict mechanism |
| Request body validation | Manual type checking in handler | zValidator("json", schema) from @hono/zod-validator |
All POST/PUT routes use this; Hono returns 400 automatically |
| Auth on POST endpoints | Custom auth check in handler | Existing requireAuth middleware in src/server/index.ts |
Auth middleware already gates all non-GET /api/global-items* after Phase 24 |
Key insight: The auth middleware in src/server/index.ts (lines 150-170) already exempts only GET requests on /api/global-items from auth. POST requests on that path fall through to requireAuth automatically — no changes to index.ts needed.
Common Pitfalls
Pitfall 1: Unique Constraint Not Applied to Existing Data
What goes wrong: If the database already has duplicate (brand, model) pairs (e.g., from early seeding), adding a unique constraint via migration will fail with a duplicate key error.
Why it happens: The migration tries to add UNIQUE(brand, model) to a table that already has conflicting rows.
How to avoid: Check for existing duplicates before generating the migration. In development with test data, truncate global_items or resolve duplicates first. The test database is reset between tests (TRUNCATE ... RESTART IDENTITY CASCADE) so tests are not affected.
Warning signs: drizzle-kit push fails with "could not create unique index" or similar.
Pitfall 2: Client GlobalItem Interface Missing New Fields
What goes wrong: useGlobalItems.ts defines a local GlobalItem interface. After the migration adds columns, the API returns new fields but the TypeScript interface doesn't include them, so the component can't render them.
Why it happens: The interface at the top of useGlobalItems.ts (lines 3-14) is manually maintained — it's not generated from Drizzle schema types.
How to avoid: Update the GlobalItem interface in useGlobalItems.ts to add sourceUrl: string | null, imageCredit: string | null, imageSourceUrl: string | null. The GlobalItemWithOwnerCount extends it and gets the fields for free.
Warning signs: TypeScript error Property 'imageCredit' does not exist on type 'GlobalItemWithOwnerCount' in $globalItemId.tsx.
Pitfall 3: Bulk Endpoint Registered Before Auth Middleware Applies
What goes wrong: /api/global-items/bulk is a POST endpoint. The auth middleware in src/server/index.ts exempts GET on /api/global-items (line 165-167). If the route registration order matters, the bulk POST could be accidentally exempt.
Why it happens: The middleware skip check uses c.req.path.startsWith("/api/global-items") && c.req.method === "GET" — it's already method-gated, so POST requests are not exempt. No issue in practice.
How to avoid: No special handling needed. The middleware skip condition already checks c.req.method === "GET". Verify this by reading src/server/index.ts lines 165-167 before implementing.
Warning signs: POST to /api/global-items/bulk returns 200 without X-API-Key header.
Pitfall 4: MCP Tool for Bulk Returns Success on Partial Failure
What goes wrong: If the service function for bulk upsert silently skips failed items instead of throwing, the MCP tool returns { created: X, updated: Y } even though some items were not persisted.
Why it happens: Swallowing errors inside a loop without re-throwing.
How to avoid: Zod validation happens at the route level (for HTTP) and at the MCP tool handler level (parse inputSchema). The service function should assume pre-validated data and let Drizzle throw naturally inside the transaction. The transaction auto-rolls-back on any thrown error.
Warning signs: Bulk upsert returns counts that don't add up to the input array length, with no error.
Pitfall 5: onConflictDoUpdate Target Requires Constraint, Not Arbitrary Columns
What goes wrong: Calling .onConflictDoUpdate({ target: [globalItems.brand, globalItems.model], ... }) without a unique constraint on those columns causes a Postgres error at runtime.
Why it happens: PostgreSQL's ON CONFLICT (col1, col2) DO UPDATE requires an existing unique index or constraint. Drizzle passes through to Postgres directly.
How to avoid: The unique constraint must be added to the schema and migration applied BEFORE any upsert code runs. Always run bun run db:push before testing the new service functions.
Warning signs: ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification.
Code Examples
Verified patterns from the codebase:
Drizzle Upsert (multi-column target)
// Source: src/server/routes/settings.ts lines 33-37
await database
.insert(settings)
.values({ userId, key, value: body.value })
.onConflictDoUpdate({
target: [settings.userId, settings.key],
set: { value: body.value },
});
Drizzle Transaction
// Source: src/server/services/setup.service.ts line 164
return await db.transaction(async (tx) => {
// ... multiple tx.insert / tx.select calls
// Any thrown error rolls back automatically
});
Hono Route with zValidator
// Source: src/server/routes/setups.ts line 36
app.post("/", zValidator("json", createSetupSchema), async (c) => {
const db = c.get("db");
const userId = c.get("userId")!;
const data = c.req.valid("json");
// ...
});
MCP Tool Definition + Handler (no-userId pattern)
// Source: src/server/mcp/tools/images.ts — full file
export const imageToolDefinitions = [
{
name: "upload_image_from_url",
description: "...",
inputSchema: { url: z.string().describe("...") },
},
];
export function registerImageTools() { // no db, no userId
return {
upload_image_from_url: async (args: { url: string }): Promise<ToolResult> => {
try {
const result = await fetchImageFromUrl(args.url);
return textResult(result);
} catch (err) {
return errorResult((err as Error).message);
}
},
};
}
Schema with Unique Constraint (table-level)
// Source: src/db/schema.ts line 26-38 (categories table — same pattern)
export const categories = pgTable(
"categories",
{
id: serial("id").primaryKey(),
// ...
},
(table) => [unique().on(table.userId, table.name)],
);
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
Separate item_global_links junction table |
globalItemId FK directly on items |
Phase migration 0002 | Simpler joins; one less table |
| No unique constraint on globalItems | unique().on(brand, model) |
This phase | Prevents duplicate catalog entries |
| Read-only global item API | Read + write (upsert) API | This phase | Enables agent-powered seeding |
Open Questions
-
Tag replacement vs merge on upsert
- What we know: D-09 includes
tags?: string[]as optional field - What's unclear: When tags are omitted on an upsert of an existing item, should existing tags be left untouched, or cleared?
- Recommendation: Leave existing tags untouched when
tagsfield is absent in the payload. Only sync (replace) tags whentagsis explicitly provided (even if empty array = clear all tags). This is the least-surprising behavior for agents that send partial updates.
- What we know: D-09 includes
-
imageUrlfield on globalItems vsimageFilename- What we know:
globalItems.imageUrlstores an absolute URL (unlikeitems.imageFilenamewhich stores a filename for S3). The bulk upsert input acceptsimageUrl. - What's unclear: Should agents use
upload_image_from_urlfirst, then pass the returned filename asimageUrl? Or pass the original URL directly? - Recommendation: Accept both — agents can pass any URL to
imageUrl. When the agent wants to mirror the image to S3, they callupload_image_from_urlfirst and use the result. TheimageSourceUrlattribution field is separate and intended to record the original source regardless of where the image is now stored.
- What we know:
Environment Availability
Step 2.6: SKIPPED (no external dependencies identified — this phase is purely code and schema changes within the existing stack; PostgreSQL/PGlite is already operational)
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | Bun test runner (built-in) |
| Config file | none — bun test discovers tests/**/*.test.ts |
| Quick run command | bun test tests/services/global-item.service.test.ts tests/routes/global-items.test.ts tests/mcp/tools.test.ts |
| Full suite command | bun test |
Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| CATL-01 | Attribution columns present and returnable | integration | bun test tests/services/global-item.service.test.ts |
✅ (extend) |
| CATL-02 | Duplicate (brand, model) upserts rather than errors | integration | bun test tests/services/global-item.service.test.ts |
✅ (extend) |
| CATL-03 | Attribution rendered in detail page | manual | Visual check in browser | — |
| CATL-04 | POST /api/global-items/bulk accepts array |
integration | bun test tests/routes/global-items.test.ts |
✅ (extend) |
| CATL-05 | Bulk upsert updates on conflict, returns created/updated counts | integration | bun test tests/routes/global-items.test.ts |
✅ (extend) |
| SEED-01 | upsert_catalog_item MCP tool writes to globalItems |
integration | bun test tests/mcp/tools.test.ts |
✅ (extend) |
| SEED-02 | bulk_upsert_catalog MCP tool persists all items |
integration | bun test tests/mcp/tools.test.ts |
✅ (extend) |
| SEED-03 | Attribution fields available as MCP tool parameters | unit | bun test tests/mcp/tools.test.ts |
✅ (extend) |
Sampling Rate
- Per task commit:
bun test tests/services/global-item.service.test.ts tests/routes/global-items.test.ts tests/mcp/tools.test.ts - Per wave merge:
bun test - Phase gate: Full suite green before
/gsd:verify-work
Wave 0 Gaps
None — existing test infrastructure covers all phase requirements. The three test files already exist and test the relevant modules; new test cases are added to existing describe blocks.
Project Constraints (from CLAUDE.md)
- Use
bun run db:generate+bun run db:pushfor all schema changes (not raw SQL) - Prices stored as cents (
priceCents: integer), weights as grams (doublePrecision) - Services take
(db, ...)as first argument — no HTTP awareness - Hono routes delegate to services; use
@hono/zod-validatorfor all request validation - Shared Zod schemas live in
src/shared/schemas.ts - MCP tools: definitions array + register function pattern per domain
- Route tree is auto-generated — never edit
routeTree.gen.ts - Always reuse existing components; check
src/client/components/before creating new UI elements @/*maps to./src/*- Tabs, double quotes, organized imports (Biome lint)
Sources
Primary (HIGH confidence)
- Direct codebase inspection:
src/db/schema.ts— current globalItems table definition - Direct codebase inspection:
src/server/services/global-item.service.ts— existing read patterns - Direct codebase inspection:
src/server/routes/global-items.ts— existing route handlers - Direct codebase inspection:
src/server/mcp/index.ts— tool registration loop pattern - Direct codebase inspection:
src/server/mcp/tools/items.ts— tool definition + handler pattern - Direct codebase inspection:
src/server/mcp/tools/images.ts— no-userId factory pattern - Direct codebase inspection:
src/server/routes/settings.ts— multi-columnonConflictDoUpdatepattern - Direct codebase inspection:
src/server/services/auth.service.ts— single-columnonConflictDoUpdatepattern - Direct codebase inspection:
src/server/services/setup.service.ts—db.transaction()pattern - Direct codebase inspection:
src/server/index.ts— auth middleware skip logic for global-items GET - Direct codebase inspection:
tests/helpers/db.ts— PGlite test setup with migrations - Direct codebase inspection:
tests/services/global-item.service.test.ts— existing test structure - Direct codebase inspection:
tests/routes/global-items.test.ts— existing route test structure - Direct codebase inspection:
tests/mcp/tools.test.ts— MCP tool test structure
Secondary (MEDIUM confidence)
- drizzle-orm 0.45.1 installed version confirmed via
bun pm ls— upsert API stable since 0.28
Metadata
Confidence breakdown:
- Standard stack: HIGH — all libraries confirmed installed; APIs confirmed in use
- Architecture: HIGH — all patterns directly observed in codebase, not assumed
- Pitfalls: HIGH — derived from reading the actual middleware skip logic and schema constraints
Research date: 2026-04-10 Valid until: 2026-05-10 (stable stack, no fast-moving dependencies)