Files
GearBox/.planning/research/PITFALLS.md

34 KiB

Pitfalls Research

Domain: Single-user to multi-user gear platform migration (GearBox v2.0) Researched: 2026-04-03 Confidence: HIGH (based on direct codebase analysis of v1.4 + established migration patterns)

Critical Pitfalls

Pitfall 1: Missing userId Filters Leak Data Between Users

What goes wrong: Every query in the existing codebase operates without a userId filter. After adding userId columns to items, categories, threads, setups, and settings, any service function not updated to filter by userId will return or mutate other users' data. The current getAllItems() returns db.select().from(items).innerJoin(...) with zero WHERE clauses. One missed function means User A sees User B's gear.

The surface area is large: 6 service files, 19 MCP tools, 7 route files, aggregate queries in totals, the duplicateItem function, the getCollectionSummary MCP resource, setup-item joins, and thread resolution (which creates a new item).

Why it happens: Developers add userId to the schema, update the obvious CRUD functions, but miss edge cases. The codebase has enough query sites (~30+) that manual "find all queries" misses something. Thread resolution is particularly dangerous because it creates an item as a side effect of updating a thread.

How to avoid:

  1. Enable Postgres Row-Level Security (RLS) as a safety net -- even if the app filters by userId, RLS prevents cross-user access at the database level.
  2. Add userId as NOT NULL to the Drizzle schema first, then use TypeScript compiler errors to find every query that needs updating (insert calls will fail where userId is required but not provided).
  3. Write one integration test per entity: create data as User A, query as User B, assert empty results.
  4. Grep the codebase for every .from(items), .from(categories), .from(threads), .from(setups), .from(settings) and verify each has a userId filter.

Warning signs:

  • Any service function that does not accept a userId parameter after migration.
  • Tests that pass without specifying which user is performing the action.
  • MCP tools that work without user context.

Phase to address: Multi-user data model phase. This is the single most important thing to get right. Do not add public content or discovery features until every query is provably user-scoped.


Pitfall 2: Category Name Uniqueness Breaks in Multi-User

What goes wrong: The current schema has name: text("name").notNull().unique() on the categories table -- a global unique constraint. When User A creates a "Bikepacking" category, User B cannot. The migration must change this to a composite unique constraint on (userId, name).

Why it happens: Single-user apps use simple unique constraints. Developers add userId to the table but forget to update the unique constraint from unique(name) to unique(userId, name). The migration runs fine on an empty database but fails the moment a second user creates a category with a common name.

How to avoid: Audit every .unique() constraint in the schema during migration. categories.name must become a composite unique on (userId, name). The users.username unique stays global (desired). No other tables currently have unique constraints, but new tables (reviews, products) should use composite uniqueness from the start.

Warning signs:

  • Database constraint errors when a second user creates categories.
  • Tests that only ever use one user.

Phase to address: Multi-user data model phase, during schema migration.


Pitfall 3: Drizzle Schema Rewrite Is a Replacement, Not a Migration

What goes wrong: Drizzle ORM schemas are dialect-specific. The current schema imports from drizzle-orm/sqlite-core and uses sqliteTable, integer().primaryKey({ autoIncrement: true }), and real(). The Postgres schema must import from drizzle-orm/pg-core and use pgTable, serial() or integer().generatedAlwaysAsIdentity(), and doublePrecision(). This is not a migration Drizzle can auto-generate -- it requires a full schema rewrite and a fresh migration history.

Specific differences that will cause bugs if missed:

  • integer("id").primaryKey({ autoIncrement: true }) becomes serial("id").primaryKey() or integer("id").primaryKey().generatedAlwaysAsIdentity().
  • integer("created_at", { mode: "timestamp" }) -- SQLite stores timestamps as integers. Postgres has native timestamp type. Must decide: keep integer storage or switch to Postgres timestamp().
  • real("weight_grams") -- SQLite REAL is 8-byte float. Postgres real is 4-byte float (less precision). Use doublePrecision() for equivalent behavior.
  • SQLite text("status") with string values works as pseudo-enum. Postgres has native pgEnum for type safety.
  • The Db type alias (typeof prodDb) changes entirely -- every service file and MCP tool imports this type.

Why it happens: Developers assume Drizzle abstracts away database differences. It does not at the schema layer. The query builder is mostly compatible, but schema definition is dialect-specific by design.

How to avoid:

  1. Write a new schema.ts from scratch using pg-core, not edit the existing one.
  2. Start a fresh Drizzle migration history for Postgres. SQLite migrations are irrelevant.
  3. Write a data migration script that reads from old SQLite and inserts into new Postgres.
  4. Update the Db type alias in all service files.
  5. Use doublePrecision() not real() for weight values to maintain precision parity with SQLite.

Warning signs:

  • Weight values losing precision (245.5g becoming 245.49999...).
  • Timestamps behaving differently (integer epoch vs. native timestamp).
  • drizzle-kit refusing to generate migrations against the wrong dialect.

Phase to address: Database migration phase. Must complete before any other v2.0 feature.


Pitfall 4: Test Infrastructure Collapses During Database Switch

What goes wrong: The entire test infrastructure is built on SQLite. createTestDb() uses bun:sqlite with Database(":memory:") and drizzle-orm/bun-sqlite. E2E tests use a file-based SQLite (e2e/test.db). After switching to Postgres, every test needs a Postgres connection -- no more in-memory databases.

The MCP server hard-codes db as prodDb which is an SQLite Drizzle instance. The Hono context variable type for db changes. Every route handler that does c.get("db") gets a different type.

Why it happens: In-memory SQLite is the best testing story in the Bun ecosystem -- fast, isolated, no external services. Postgres testing requires either: (a) a running Postgres instance, (b) testcontainers with Docker, or (c) PGlite (lightweight Postgres in WebAssembly). Developers delay updating tests and end up with a broken test suite for weeks.

How to avoid:

  1. Adopt PGlite (@electric-sql/pglite) for unit/integration tests. It provides in-memory Postgres without Docker. Drizzle supports PGlite via drizzle-orm/pglite.
  2. Update createTestDb() to use PGlite instead of bun:sqlite.
  3. For E2E tests, use Docker Compose with a test Postgres instance, or PGlite if performance is acceptable.
  4. Update the Hono context variable type to the new Postgres Drizzle instance type.
  5. Migrate test infrastructure in the same phase as the schema, not after.

Warning signs:

  • bun test fails across the board after schema change.
  • "Type 'BunSQLiteDatabase' is not assignable to type 'PgDatabase'" errors everywhere.
  • E2E tests silently skipped or disabled "temporarily."

Phase to address: Database migration phase. Tests must migrate alongside the schema.


Pitfall 5: Auth Provider Integration Breaks Existing Sessions, API Keys, and MCP

What goes wrong: The current auth stores users, sessions, and API keys in the local database. Switching to an external auth provider means: (1) user identity moves external, (2) session management changes (JWT or OAuth flow vs. cookie sessions), (3) existing API keys become orphaned because they reference the old user table, (4) the MCP server authenticates via API keys stored locally, (5) E2E tests authenticate via POST /api/auth/login with a seeded user, (6) the onboarding flow (POST /api/auth/setup) creates the first user.

Why it happens: Auth migration is treated as "swap the login page" when it touches the entire authentication surface: user identity, session lifecycle, API key management, MCP authentication, E2E test setup, and onboarding.

How to avoid:

  1. Keep API keys in the local database even after auth moves external. API keys are long-lived credentials managed by the application, not the auth provider.
  2. Map external provider user IDs to a local users table. The external provider handles authentication; the local table handles application-level data (userId foreign keys, API keys, preferences). Foreign keys reference local users.id, not the provider's UUID.
  3. Replace the onboarding flow: instead of "create admin account," it becomes "sign up via external provider, first user gets admin role."
  4. Update E2E tests to either mock the auth provider or use API key authentication exclusively for E2E.

Warning signs:

  • MCP server stops working after auth migration.
  • E2E tests that log in via POST /api/auth/login all fail.
  • API keys created before migration stop working.
  • No local users table -- everything delegated to external provider.

Phase to address: Auth migration phase. Should be done early because user identity is the foundation.


Pitfall 6: Global Item Database Creates a Data Model Fork

What goes wrong: The current items table represents user-owned gear. The v2.0 vision includes a "global item database" with manufacturer specs. These are fundamentally different entities: a user's item has quantity, personal notes, setup associations, and belongs to a user. A global item is a product definition with canonical specs, owned by nobody. Conflating them in one table (via isGlobal flag or NULL userId) creates an unmaintainable mess. Separating them creates a sync problem.

Why it happens: It seems efficient to add an isGlobal flag. But then queries need to handle both cases, user items need to link to global items for spec inheritance, and the API surface doubles with different permission models.

How to avoid:

  1. Create a separate products table for the global database. A product has: name, manufacturer, canonical weight, canonical price, product URL, image, category.
  2. User items gets a nullable productId foreign key. When set, the item inherits specs from the product but can override them (user's measured weight vs. manufacturer spec).
  3. User items without a productId are standalone (backward-compatible with all existing items).
  4. Reviews, owner counts, and setup appearances link to products, not user items.

Warning signs:

  • items table query complexity increases beyond what is reasonable.
  • Ambiguity about whether an operation affects "my item" or "the global product."
  • Permission model becomes unclear (who can edit a global product?).

Phase to address: Global item database phase. Must come after multi-user data model is stable.


Pitfall 7: Image Storage Migration Breaks Existing URLs and the MCP Tool

What goes wrong: Images are stored in ./uploads/ on the filesystem, served via app.use("/uploads/*", serveStatic({ root: "./" })), and referenced by imageFilename in the database. Moving to object storage changes URLs from /uploads/uuid.jpg to https://bucket.s3.region.amazonaws.com/uuid.jpg. Every existing imageFilename reference becomes a broken image.

Both items and threadCandidates have imageFilename and imageSourceUrl fields. The MCP tool upload_image_from_url saves to the local filesystem. The image route POST /api/images saves to ./uploads/.

Why it happens: The current design stores only the filename, not the full URL. The serving path is implicit (prepend /uploads/). When storage moves to S3, the "prepend /uploads/" pattern breaks.

How to avoid:

  1. Add a reverse proxy route: keep /uploads/* working but proxy to S3 instead of local filesystem. This maintains backward compatibility during transition.
  2. Or migrate imageFilename to store full URLs. Existing filenames get prefixed with the S3 URL during data migration.
  3. Write a migration script that uploads all ./uploads/ files to S3 and updates database references.
  4. Update POST /api/images, POST /api/images/from-url, and the MCP upload_image_from_url tool to write to S3.
  5. Create an image storage abstraction layer so dev can use local filesystem and production uses S3.

Warning signs:

  • Broken images after deployment.
  • Mixed URLs (some /uploads/, some https://s3...) in the database.
  • MCP tool upload_image_from_url silently failing.

Phase to address: Infrastructure phase. Should be done before discovery/public profiles (which serve images to many users).


Pitfall 8: Thread Resolution Creates Items Without Proper User Scoping

What goes wrong: Thread resolution copies a candidate's data into a new item. In multi-user, the newly created item must inherit the thread owner's userId. If the resolution logic does not explicitly set userId on the new item, it either fails (NOT NULL constraint) or creates an orphaned item.

This is a specific instance of Pitfall 1 but deserves its own callout because resolution is a multi-step transaction: update thread status, set resolvedCandidateId, create new item. Any step that forgets userId breaks the chain.

Why it happens: The resolution logic is tested as a unit but the test does not set a userId because none existed. After adding userId, the test still passes if using a default/NULL value. The bug only surfaces with a second user.

How to avoid:

  1. Make userId NOT NULL on all entity tables from day one.
  2. Update resolveThread to accept and propagate userId.
  3. Write a test: resolve thread as User A, verify created item belongs to User A.

Warning signs:

  • Items appearing in the wrong user's collection after resolution.
  • Thread resolution failing with constraint violations.

Phase to address: Multi-user data model phase.


Pitfall 9: Public Content Without Explicit Privacy Controls

What goes wrong: The v2.0 plan includes "public user profiles with shared setups" and a "discovery feed." Without explicit visibility controls, the default state is ambiguous: are new setups public? Are all items in a public setup visible? Can someone discover gear a user has not chosen to share? Users expecting a private gear tracker are surprised when their collection appears in search results.

Why it happens: The developer defaults to "everything public" because it is simpler to build discovery features. Privacy controls are added as an afterthought, requiring a retroactive audit of all existing data.

How to avoid:

  1. Default to private. Every entity (setup, profile) is private unless explicitly published.
  2. Add a visibility column (private | public) to setups. Items are visible publicly only through public setups.
  3. User profiles are private by default. Public profile is opt-in.
  4. Public API endpoints (discovery, search) only query entities with visibility = 'public'.
  5. Build the visibility model in the data layer before building any discovery UI.

Warning signs:

  • No visibility or isPublic column in the schema.
  • Discovery queries that do not filter by visibility.
  • User complaints about unexpected data exposure.

Phase to address: Multi-user data model phase (add visibility columns) and discovery phase (enforce in queries).


Pitfall 10: SQLite-Specific Patterns That Silently Break on Postgres

What goes wrong: The codebase has SQLite-specific patterns that will not error but will behave differently on Postgres:

  • src/db/index.ts runs PRAGMA journal_mode = WAL and PRAGMA foreign_keys = ON -- Postgres has no PRAGMAs. Foreign keys are always enforced. WAL is always on.
  • bun:sqlite is used as the driver. Postgres needs postgres (postgres.js) or pg (node-postgres) as the driver.
  • The existing Drizzle migrator import is drizzle-orm/bun-sqlite/migrator. Postgres uses drizzle-orm/node-postgres/migrator or drizzle-orm/postgres-js/migrator.
  • SQLite allows inserting strings into integer columns silently. Postgres will error.
  • SQLite AUTOINCREMENT guarantees IDs never reuse. Postgres serial reuses IDs after deletions if the sequence is not explicitly configured.
  • The test helper's Database(":memory:") has no Postgres equivalent without PGlite.

Why it happens: These patterns are invisible in a working SQLite app. They only surface during or after the switch, often as runtime errors in production.

How to avoid:

  1. Remove all PRAGMA statements when switching to Postgres.
  2. Replace bun:sqlite driver with postgres (postgres.js is recommended for Bun compatibility).
  3. Update all migrator imports.
  4. Run the full test suite against Postgres to catch type strictness differences.
  5. Use serial or identity columns for auto-increment; accept that IDs may be reused after deletion (this should not matter for a web app).

Warning signs:

  • "PRAGMA" in the Postgres codebase.
  • bun:sqlite imports anywhere in production code after migration.
  • Tests passing against SQLite but failing against Postgres.

Phase to address: Database migration phase.


Pitfall 11: Setup-Item Delete-All-Reinsert Pattern Causes Phantom Reads

What goes wrong: The current setup item sync uses delete-all-then-re-insert: DELETE FROM setup_items WHERE setupId = X, then re-insert all items. In single-user SQLite this is fine. In multi-user Postgres with concurrent writes: (a) race conditions if two users modify setups simultaneously, (b) brief windows where a public setup appears empty to concurrent readers.

Why it happens: The pattern was chosen for simplicity (noted in CLAUDE.md: "Simpler than diffing, atomic in transaction"). "Atomic in transaction" only holds if the transaction isolation level prevents phantom reads, which is not the default in Postgres (READ COMMITTED).

How to avoid:

  1. Wrap in an explicit transaction with SERIALIZABLE or REPEATABLE READ isolation for the sync operation.
  2. Or switch to diff-based approach for public setups: compare existing vs. new list, delete removed, insert added.
  3. For private setups, the delete-reinsert pattern with a basic transaction is acceptable.

Warning signs:

  • Public setups briefly appearing empty.
  • Foreign key violations in concurrent scenarios.

Phase to address: Multi-user data model phase, when updating the setup service.


Pitfall 12: Existing Data Has No Owner After Multi-User Migration

What goes wrong: The existing SQLite database has items, categories, threads, setups -- all without a userId column. When the schema adds userId NOT NULL, the existing data needs an owner. If the migration script does not assign existing data to the original user, the data is either lost (NOT NULL violation prevents migration) or orphaned.

Why it happens: The developer writes the new schema with userId NOT NULL, runs db:push, and the migration fails because existing rows have no userId. The "fix" is to make userId nullable, which undermines the entire data isolation model.

How to avoid:

  1. The data migration script must: (a) create the original user in the new system, (b) assign all existing data to that user's ID, (c) then apply the NOT NULL constraint.
  2. Migration order: create tables with userId nullable, insert data with the owner's userId, then ALTER to NOT NULL.
  3. Verify row counts match before and after migration.

Warning signs:

  • userId column is nullable in the final schema "because of migration."
  • Existing data missing after migration.
  • Migration script that only handles schema, not data.

Phase to address: Database migration phase, specifically the data migration step.


Technical Debt Patterns

Shortcut Immediate Benefit Long-term Cost When Acceptable
Keeping SQLite test infrastructure while developing Postgres features Tests keep passing during migration Two database dialects to maintain, false confidence from tests that do not match production Never -- migrate tests alongside schema
Storing both old /uploads/ paths and new S3 URLs Avoid data migration script Every image-rendering component handles both URL formats forever Only as a 1-2 week transition
Using userId as nullable during migration Existing data does not need backfilling Every query must handle NULL userId, privacy bugs when userId is missing Only during the migration transaction itself, then enforce NOT NULL
Skipping RLS and relying only on app-level userId filtering Faster to implement Single missed WHERE clause = data leak Never for multi-user platforms
Deferring visibility controls to "after discovery ships" Ship discovery faster Retroactive privacy audit, potential data exposure, user trust damage Never
Keeping the local users table password hash after external auth Avoid migration complexity Dead column confuses future developers, potential security liability Never -- remove password hash column after auth migration

Integration Gotchas

Integration Common Mistake Correct Approach
External auth provider Removing the local users table entirely Keep a local users table with externalId (from auth provider) + local fields (preferences, API keys). Foreign keys reference local users.id, not the external provider's UUID.
External auth provider Storing user profile data in the auth provider and querying it at runtime Store only identity in auth provider. Sync user profile to local users table on login. Application queries local table only.
External auth provider Using auth provider's session tokens directly as API authentication Auth provider handles login/logout. Application mints its own session after verifying the auth provider's token. This decouples session lifecycle from the provider.
S3-compatible object storage Using the S3 SDK directly in route handlers Create an image storage abstraction (interface with upload, getUrl, delete). Swap implementations (local filesystem for dev, S3 for production) via environment config.
Postgres driver Assuming bun:sqlite patterns work with Postgres Postgres uses postgres (postgres.js) or pg. Connection pooling, async queries, and error handling differ. SQLite is synchronous; Postgres is async. Service functions may need to become async.
Postgres Assuming SQLite PRAGMA behaviors exist Postgres has no PRAGMAs. Foreign keys are always on. WAL is always on. Remove all PRAGMA code.
Drizzle ORM Postgres driver Using synchronous .get() and .all() query methods SQLite Drizzle uses .get() (sync). Postgres Drizzle uses .execute() or await on queries. Every service function that calls .get() or .all() must be updated.

Performance Traps

Trap Symptoms Prevention When It Breaks
N+1 queries in discovery feed Feed page takes 2+ seconds Use joins or batch queries for setups with items and categories 50+ setups in feed, each with 10+ items
Unindexed userId columns All queries slow after adding userId filtering Add indexes on userId for every table. Composite indexes for (userId, categoryId) on items. 1000+ items across 50+ users
Full-table scans for aggregates Dashboard slow for large collections Current aggregates are computed via SQL on read. Add materialized views or cache for public setup totals. 100+ items per user, or public setups viewed by 100+ visitors
Image serving from app server Server CPU/bandwidth saturated Serve images from S3/CDN. Current serveStatic for uploads hits the app server for every request. 100+ concurrent users browsing image-heavy pages
Global product search without full-text index Product search slow or inaccurate Use Postgres full-text search (tsvector/tsquery) or pg_trgm trigram index. 10,000+ products
Synchronous service functions on Postgres Request timeouts, connection pool exhaustion SQLite Drizzle is sync. Postgres Drizzle is async. Service functions that were sync must become async. Any usage under load

Security Mistakes

Mistake Risk Prevention
No RLS, relying only on app-level userId filtering Single missed WHERE clause exposes all user data Enable Postgres RLS on all user-owned tables. App filtering is primary; RLS is safety net.
Public setup exposes private item details Users share a setup but private notes/pricing leak Public setup views project only public fields (name, weight, category). Define a "public item projection" and enforce it.
API keys not scoped to users after auth migration API key created by User A operates on User B's data API keys must associate with a userId. After validation, the key's userId scopes all operations.
Auth provider misconfigured for open self-registration Random users create accounts without approval Configure auth provider for admin-approval or invite-only registration. Test explicitly.
Image upload accepts any file type Stored XSS via SVG uploads, executable content Validate MIME type on upload (JPEG, PNG, WebP only). Set Content-Type and Content-Disposition headers. Strip EXIF metadata.
External auth provider callback URL not validated OAuth redirect attack Whitelist exact callback URLs in auth provider config. Never use wildcard redirect URIs.

UX Pitfalls

Pitfall User Impact Better Approach
Forcing existing single user to re-register via external auth User loses access to their own data until they figure out new login Migration path: on first visit after upgrade, guide user to create auth provider account and automatically link to existing data.
Public profiles default to showing everything Users surprised their gear list is public Default profile to private. Public is opt-in with clear preview of what others see.
Review system with only star ratings Ratings without context are useless for gear decisions Structured reviews with predefined fields (durability, weight accuracy, value) per category. "Weight is 15g heavier than listed" is actionable; a 4-star rating is not.
Discovery feed dominated by one hobby Users in other hobbies see irrelevant content Category-based feed filtering. Show content relevant to user's categories.
No indication of data ownership when browsing others' setups User tries to edit someone else's setup and gets error Clear visual distinction between "my setup" and "someone else's setup." Read-only view with "copy to my setups" action.
Settings lost during migration User's weight unit preference, onboarding state disappear Migrate the settings table data alongside everything else. Map settings to the original user.

"Looks Done But Isn't" Checklist

  • Multi-user data model: Often missing userId on the settings table -- verify settings are user-scoped (weight unit preference, onboarding state).
  • Multi-user data model: Often missing userId filter on threadCandidates queries that join through threads -- verify candidates are not directly queryable across users.
  • Multi-user data model: Often missing userId on thread resolution -- verify resolveThread propagates userId to the newly created item.
  • Auth migration: Often missing MCP server auth update -- verify MCP tools operate in context of the authenticated user, not as global admin.
  • Auth migration: Often missing E2E test auth update -- verify E2E tests authenticate against new auth system or use API keys.
  • Auth migration: Often missing API key userId association -- verify API keys created after migration are scoped to the creating user.
  • Database migration: Often missing data migration script -- verify existing SQLite data is actually moved to Postgres, not just the schema.
  • Database migration: Often missing timestamp conversion -- verify SQLite integer timestamps are correctly handled in Postgres schema.
  • Database migration: Often missing weight precision check -- verify real() vs doublePrecision() does not lose decimal precision.
  • Database migration: Often missing sync-to-async conversion -- verify all service functions are async after Postgres switch.
  • Image migration: Often missing MCP tool update -- verify upload_image_from_url writes to S3, not local filesystem.
  • Image migration: Often missing imageSourceUrl field -- verify source URL metadata is preserved during migration.
  • Public content: Often missing visibility filtering on aggregate endpoints -- verify /api/totals only counts requesting user's items.
  • Reviews: Often missing rate limiting -- verify a user cannot submit 100 reviews in a minute.
  • Discovery feed: Often missing pagination -- verify feed does not load all public setups at once.
  • Global items: Often missing product-vs-item distinction -- verify adding a product to global database does not add it to anyone's collection.

Recovery Strategies

Pitfall Recovery Cost Recovery Steps
Data leaked between users (missing userId filter) HIGH Audit all queries, add RLS immediately, notify affected users, review access logs. Reputation damage is the real cost.
Broken images after storage migration MEDIUM Keep old uploads directory as fallback. Re-upload missing images. Update database references.
Test suite broken for weeks during DB migration MEDIUM Pause feature work. Set up PGlite test infrastructure. Port tests one file at a time.
Auth migration breaks MCP server LOW MCP server can fall back to API key auth (already implemented). Fix isolated to MCP auth middleware.
Category unique constraint failures LOW Drop old unique constraint, add composite unique. Single transaction.
Weight precision loss (SQLite real to Postgres real) LOW Alter column to doublePrecision. One-time verification script.
Public data exposure before visibility controls HIGH Emergency: set all entities to private, deploy, then build visibility controls properly. Cannot undo exposure.
Existing data orphaned after migration MEDIUM Re-run data migration script with correct userId assignment. Verify row counts.
Service functions still sync after Postgres switch MEDIUM Systematic conversion of all service functions to async. Update all callers. TypeScript will catch most issues.

Pitfall-to-Phase Mapping

Pitfall Prevention Phase Verification
Missing userId filters (P1) Multi-user data model Integration tests: create as User A, query as User B, assert empty. RLS policies active.
Category uniqueness (P2) Multi-user data model Two users create identically-named categories without constraint violations.
Drizzle schema rewrite (P3) Database migration Schema compiles with pg-core. drizzle-kit generates valid Postgres migrations. Weight values maintain precision.
Test infrastructure collapse (P4) Database migration bun test passes with PGlite. E2E tests pass against Postgres. No SQLite imports in test code.
Auth provider breaks sessions/keys (P5) Auth migration Existing API keys work. MCP server authenticates. E2E tests pass. First-time setup works via external provider.
Global item data model fork (P6) Global item database Separate products table exists. User items optionally reference a product. CRUD operations distinct.
Image URL breakage (P7) Infrastructure / Image storage Existing images render. New uploads go to S3. MCP upload tool works.
Thread resolution userId (P8) Multi-user data model Resolving a thread creates an item owned by the thread's owner. Tested with multiple users.
Privacy/visibility (P9) Multi-user data model + Discovery Default is private. Public queries filter by visibility. No private data in discovery feed.
SQLite-specific patterns (P10) Database migration No PRAGMAs in codebase. No bun:sqlite imports. All queries async.
Setup sync race conditions (P11) Multi-user data model Concurrent setup modifications do not produce empty setups or constraint violations.
Existing data ownership (P12) Database migration All existing data assigned to original user. Row counts match. userId NOT NULL enforced.

Sources


Pitfalls research for: GearBox v2.0 -- Single-user to multi-user platform migration Researched: 2026-04-03