Files
GearBox/.planning/phases/25-catalog-enrichment-agent-tools/25-RESEARCH.md

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 manufacturer column — existing brand field 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) to globalItems.
  • 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_item MCP tool — writes directly to globalItems (not user-scoped).
  • D-12: Add bulk_upsert_catalog MCP 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.ts following 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

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.ts createMcpServer signature to remove userId just because catalog tools don't need it. Pass db only to registerCatalogTools — accept the userId parameter in createMcpServer and 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-items or POST /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

  1. 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 tags field is absent in the payload. Only sync (replace) tags when tags is explicitly provided (even if empty array = clear all tags). This is the least-surprising behavior for agents that send partial updates.
  2. imageUrl field on globalItems vs imageFilename

    • What we know: globalItems.imageUrl stores an absolute URL (unlike items.imageFilename which stores a filename for S3). The bulk upsert input accepts imageUrl.
    • What's unclear: Should agents use upload_image_from_url first, then pass the returned filename as imageUrl? 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 call upload_image_from_url first and use the result. The imageSourceUrl attribution field is separate and intended to record the original source regardless of where the image is now stored.

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:push for 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-validator for 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-column onConflictDoUpdate pattern
  • Direct codebase inspection: src/server/services/auth.service.ts — single-column onConflictDoUpdate pattern
  • Direct codebase inspection: src/server/services/setup.service.tsdb.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)