Data Model & Schema Reference
Complete database schema for MotoPartPicker — motorcycle parts compatibility platform. Hosted on Neon Postgres (serverless), managed via Drizzle ORM with timestamp-based migrations.
Entity Relationship Diagram
Solid lines = direct FK relationships | Dashed lines = cross-domain references | PII = personally identifiable data | ★ = central query table
Domain Overview
bikes, parts, compatibility_records. Every query in the application starts from compatibility_records. This is the fundamental value proposition: knowing whether a part fits a specific bike with confidence scoring.
users, verifications, user_bikes, builds, build_parts. The flywheel. Users verify fitment, which improves data quality. Builds drive engagement and sharing. Garage features increase retention.
retailers, part_prices, affiliate_clicks, retailer_subscriptions. Revenue infrastructure. Affiliate clicks tracked at part-price level. Retailer billing through Stripe with tiered subscriptions.
audit_log. Append-only compliance ledger. Partitioned by month. 2-year retention. Captures all actor-driven mutations across all entities for GDPR compliance and data integrity auditing.
Table Definitions
Year/Make/Model/Variant lookup. Primary entity for all compatibility searches. Deduplicated with a compound unique constraint.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| id | uuid | PK | gen_random_uuid() default |
| year | smallint | NOT NULL | Model year, e.g. 2020 |
| make | text | NOT NULL | e.g. "Kawasaki" |
| model | text | NOT NULL | e.g. "Ninja 400" |
| variant | text | NULLABLE | e.g. "ABS", "SE", "Final Edition" |
| engine_cc | smallint | NULLABLE | Displacement in cc |
| category | enum | ENUM NOT NULL |
sportcruiseradvnakeddualcafetouring
|
| image_url | text | NULLABLE | R2 CDN URL for bike photo |
| created_at | timestamptz | DEFAULT now() | |
| updated_at | timestamptz | DEFAULT now() | Trigger: auto-update on change |
Indexes
Aftermarket parts catalog. Brand-agnostic. Part numbers are optional as many aftermarket parts lack consistent SKUs across retailers.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| id | uuid | PK | |
| name | text | NOT NULL | e.g. "Akrapovic Slip-On Exhaust" |
| brand | text | NOT NULL | Manufacturer brand name |
| part_number | text | NULLABLE | Manufacturer SKU when available |
| category | enum | ENUM |
exhaustprotectionergonomicscosmeticelectronicssuspensionluggage
|
| subcategory | text | NOT NULL | Free-text sub-classification |
| description | text | NOT NULL | Markdown-safe product description |
| image_url | text | NULLABLE | R2 CDN URL |
| msrp_cents | integer | NULLABLE | MSRP in cents. Use part_prices for current retail. |
| created_at | timestamptz | DEFAULT now() | |
| updated_at | timestamptz | DEFAULT now() |
Indexes
The central junction table. Every compatibility query resolves here. Confidence scoring drives the UI display. The conflicts_with array enables conflict detection across build parts.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| id | uuid | PK | |
| bike_id | uuid | FK → bikes NOT NULL | ON DELETE CASCADE |
| part_id | uuid | FK → parts NOT NULL | ON DELETE CASCADE |
| confidence | enum | ENUM NOT NULL |
verified
reported
no_data
|
| verification_count | integer | DEFAULT 0 | Denormalized count from verifications table |
| notes | text | NULLABLE | Admin/curator notes on fitment |
| install_difficulty | enum | ENUM NOT NULL |
easymoderatehard
|
| install_time_minutes | smallint | NULLABLE | Estimated install time |
| tools_required | text | NULLABLE | Comma-separated tool list |
| conflicts_with | uuid[] | ARRAY NULLABLE | part_ids of incompatible parts. GIN index candidate for @> queries. |
| created_at | timestamptz | DEFAULT now() | |
| updated_at | timestamptz | DEFAULT now() |
Indexes
Rider accounts. Soft-delete pattern via deleted_at. Reputation scoring drives contributor trust. Badges are a simple text array for flexibility.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| id | uuid | PK | |
| citext | UNIQUE PII NOT NULL | Case-insensitive. RLS protected. | |
| display_name | text | NOT NULL PII | Public-facing username. Pseudonymous OK. |
| avatar_url | text | NULLABLE | R2 CDN URL |
| reputation_score | integer | DEFAULT 0 | Computed from verified contributions |
| badges | text[] | ARRAY | e.g. ["early_adopter", "top_contributor"] |
| created_at | timestamptz | DEFAULT now() | |
| updated_at | timestamptz | DEFAULT now() | |
| deleted_at | timestamptz | NULLABLE | Soft delete. NULL = active. All RLS policies filter on deleted_at IS NULL. |
Indexes
User-submitted fitment confirmations. Each verification updates the parent compatibility_record.verification_count via trigger. Photos stored in R2.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| id | uuid | PK | |
| compatibility_record_id | uuid | FK → compatibility_records | ON DELETE CASCADE |
| user_id | uuid | FK → users | ON DELETE SET NULL (preserve data) |
| fits | boolean | NOT NULL | True = confirmed fit. False = does not fit. |
| notes | text | NULLABLE | Rider's free-text install notes |
| photos | text[] | ARRAY | R2 CDN URLs. Max 5 photos per verification. |
| install_date | date | NULLABLE | When rider installed the part |
| upvotes | integer | DEFAULT 0 | Community helpfulness votes |
| created_at | timestamptz | DEFAULT now() |
Indexes
Garage: which bikes a user owns. Drives personalized compatibility feeds. is_primary flags the default bike for quick lookups.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| id | uuid | PK | |
| user_id | uuid | FK → users NOT NULL | ON DELETE CASCADE |
| bike_id | uuid | FK → bikes NOT NULL | ON DELETE RESTRICT |
| is_primary | boolean | DEFAULT false | Only one primary per user (partial unique index) |
| nickname | text | NULLABLE | User's name for their bike |
| created_at | timestamptz | DEFAULT now() |
Indexes
Saved build plans. Public builds are shareable and drive organic SEO. view_count enables trending builds feed.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| id | uuid | PK | |
| user_id | uuid | FK → users | ON DELETE CASCADE |
| bike_id | uuid | FK → bikes | ON DELETE RESTRICT |
| name | text | NOT NULL | User-given build name |
| is_public | boolean | DEFAULT true | Controls visibility. RLS enforced. |
| view_count | integer | DEFAULT 0 | Incremented via background job, not per-request |
| created_at | timestamptz | DEFAULT now() | |
| updated_at | timestamptz | DEFAULT now() |
Indexes
Parts belonging to a build. sort_order enables user-defined part sequencing. is_installed tracks real-world build progress.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| id | uuid | PK | |
| build_id | uuid | FK → builds | ON DELETE CASCADE |
| part_id | uuid | FK → parts | ON DELETE RESTRICT |
| is_installed | boolean | DEFAULT false | |
| installed_at | date | NULLABLE | Actual install date |
| notes | text | NULLABLE | Build-specific part notes |
| sort_order | smallint | NOT NULL | User-defined display order |
Indexes
Partner retailers. affiliate_url_template uses {part_url} placeholder for dynamic link generation. Inactive retailers remain for historical click data.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| id | uuid | PK | |
| name | text | NOT NULL | e.g. "RevZilla" |
| slug | text | UNIQUE NOT NULL | URL-safe identifier e.g. "revzilla" |
| website_url | text | NOT NULL | |
| affiliate_url_template | text | NOT NULL | Template with {part_url} token |
| commission_rate | numeric(4,2) | NOT NULL | Percentage, e.g. 8.50 |
| cookie_days | smallint | NOT NULL | Attribution window in days |
| logo_url | text | NULLABLE | R2 CDN URL for retailer logo |
| is_active | boolean | DEFAULT true | Controls display in price comparisons |
Indexes
Current prices at each retailer, updated periodically via price-scraping jobs. One record per part/retailer pair. Historical prices not retained in this table — use analytics DB if needed.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| id | uuid | PK | |
| part_id | uuid | FK → parts | ON DELETE CASCADE |
| retailer_id | uuid | FK → retailers | ON DELETE RESTRICT |
| price_cents | integer | NOT NULL | Current price in cents. Always store in smallest unit. |
| in_stock | boolean | NOT NULL | |
| stock_note | text | NULLABLE | e.g. "Ships in 3-5 days" |
| affiliate_url | text | NOT NULL | Pre-generated affiliate URL (not template) |
| last_checked_at | timestamptz | NOT NULL | When price scraper last verified this record |
Indexes
High-volume click tracking. Partitioned by month for query performance. ip_hash stored (not raw IP) for GDPR compliance. Feeds revenue reporting.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| id | uuid | PK | |
| part_price_id | uuid | FK → part_prices | ON DELETE SET NULL |
| user_id | uuid | FK → users NULLABLE | NULL for anonymous clicks |
| session_id | text | NOT NULL | Browser session for deduplication |
| clicked_at | timestamptz | DEFAULT now() | Partition key |
| ip_hash | text | NOT NULL | SHA-256 of IP. Not reversible. |
Indexes
PARTITION BY RANGE (clicked_at) — monthly partitions created by cron jobStripe billing records for retailer partner subscriptions. Stripe is source of truth — this table syncs via webhook. Never mutate directly; always reconcile from Stripe events.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| id | uuid | PK | |
| retailer_id | uuid | FK → retailers UNIQUE | One subscription per retailer |
| stripe_customer_id | text | NOT NULL | cus_xxx Stripe customer ID |
| stripe_subscription_id | text | NOT NULL | sub_xxx Stripe subscription ID |
| tier | enum | ENUM |
affiliateverifieddata
|
| status | enum | ENUM |
trialingactivepast_duecanceled
|
| current_period_end | timestamptz | NOT NULL | Access revokes after this timestamp |
Immutable compliance ledger. All application-layer mutations append here. No UPDATE or DELETE ever executed on this table. 2-year retention enforced by partition drop cron job.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| id | bigint | PK GENERATED ALWAYS AS IDENTITY | Sequential for temporal ordering |
| actor_id | uuid | NULLABLE | NULL for system/background actions |
| action | text | NOT NULL | e.g. "verification.created", "user.deleted" |
| entity_type | text | NOT NULL | Table name, e.g. "verifications" |
| entity_id | uuid | NOT NULL | PK of the affected row |
| metadata | jsonb | NULLABLE | Before/after state, IP, user-agent, etc. |
| created_at | timestamptz | DEFAULT now() | Partition key |
Indexes
PARTITION BY RANGE (created_at) — monthly partitions. Oldest partitions detached and dropped at 24-month boundary.Data Store Decision Matrix
MotoPartPicker starts with Neon Postgres for all structured data. Secondary stores are added only when a specific constraint demands it. Avoid premature infrastructure complexity.
| Data | Store | Reason | When to Reconsider |
|---|---|---|---|
| All structured entities (13 tables) | Neon Postgres | ACID transactions, FK enforcement, complex joins, full-text search via tsvector |
At 10M+ compat records, consider read replicas |
| User photos (verification photos, avatars) | Cloudflare R2 | Blob storage not suited for Postgres. R2 zero egress cost. CDN edge delivery. | Stay on R2 indefinitely for binary assets |
| Retailer logos, bike images, part images | Cloudflare R2 | Same as above. URLs stored as text columns in Postgres. |
Stay on R2 indefinitely |
| Session / auth tokens | Neon Postgres | Adequate at current scale. Simpler ops than Redis. | Move to Redis/Upstash at 50K+ concurrent sessions |
| Price feed cache (hot reads) | Neon Postgres | part_prices is a small table. Neon's connection pooler + warm cache handles it. | Add Redis cache layer when price queries exceed 50ms p95 |
| Bike/part search (typeahead) | Neon Postgres | pg_trgm trigram index + tsvector handles typeahead at this scale. | Evaluate Typesense/Algolia at 500K+ parts with complex filtering |
| Stripe billing state | Stripe | Stripe is source of truth. Local retailer_subscriptions is a read-through cache synced by webhooks. |
Never migrate off Stripe for billing |
| Analytics / click funnels | Neon Postgres | affiliate_clicks partitioned table handles Year 1-2 analytics queries. | Export to ClickHouse/BigQuery when queries exceed 5s or complex attribution needed |
| Rate limiting counters | Neon Postgres | Simple counters with TTL via pg_cron cleanup adequate for MVP. | Move to Upstash Redis for sub-millisecond rate limit checks at scale |
Row Count Projections
Conservative growth estimates based on motorcycle parts market size and comparable platforms (RevZilla has ~400K SKUs; PCPartPicker ~800K parts). Year 1 represents focused North American market; Year 3 includes international expansion.
| Table | Domain | Year 1 | Year 3 | Growth | Growth Driver |
|---|---|---|---|---|---|
| bikes | Core | 200 | 2,000 | 10x | Curator data entry + community requests |
| parts | Core | 5,000 | 50,000 | 10x | Retailer data feeds + community additions |
| compatibility_records | Core | 20,000 | 500,000 | 25x | bikes x parts matrix growth — non-linear expansion |
| verifications | Community | 10,000 | 200,000 | 20x | User growth x engagement rate x verifications per user |
| users | Community | 25,000 | 300,000 | 12x | SEO organic + community flywheel |
| user_bikes | Community | 35,000 | 420,000 | 12x | ~1.4 bikes per active user |
| builds | Community | 8,000 | 120,000 | 15x | ~1 active build per 3 users average |
| build_parts | Community | 56,000 | 840,000 | 15x | ~7 parts per build average |
| retailers | Commerce | 5 | 30 | 6x | Sales-driven; intentionally limited early |
| part_prices | Commerce | 50,000 | 500,000 | 10x | parts x retailers matrix |
| affiliate_clicks | Commerce | 500,000 | 10,000,000 | 20x | Highest growth table — reason for monthly partitioning |
| retailer_subscriptions | Commerce | 5 | 30 | 6x | 1:1 with retailers |
| audit_log | System | ~200,000 | ~5,000,000 | 25x | All mutation events. Rolling 2yr window = steady state. |
Top 10 Query Patterns
All queries are ranked by expected call frequency. Indexes were designed around these patterns.
Migration Strategy
Migrations are plain SQL files prefixed with a Unix timestamp (e.g. 0001_1712345600_create_bikes.sql). Drizzle tracks applied migrations in __drizzle_migrations table. All production migrations must be backward-compatible unless paired with a coordinated deploy.
{seq}_{timestamp}_{description}.sql — e.g. 0001_1712345600_create_core_tables.sql. Sequential prefix ensures ordering. Timestamp provides auditability. Description is human-readable.
Always add columns with DEFAULT or NULLABLE. Never rename columns — add new + migrate + drop old in separate deploys. Never change enum values directly — add new enum, migrate data, drop old. Index creation uses CREATE INDEX CONCURRENTLY to avoid table locks.
1. Deploy new code that reads both old and new column. 2. Run migration to add new column. 3. Backfill data. 4. Deploy code that writes to new column only. 5. Remove old column in follow-up migration after 1+ deploy cycle.
Monthly partitions created by pg_cron job on the 20th of each month for the following month. Example: CREATE TABLE affiliate_clicks_2026_05 PARTITION OF affiliate_clicks FOR VALUES FROM ('2026-05-01') TO ('2026-06-01'). Old partitions detached and dropped via retention cron.
Migrations run automatically in CI against a shadow Neon branch. If migration fails CI, deploy is blocked. Neon branch feature enables zero-risk migration testing — branch from main, apply migration, run tests, merge. Neon handles branch deletion automatically.
Neon point-in-time restore to any second in the last 7 days (Neon Pro feature). For logical rollbacks: every migration file has a corresponding down.sql that is never run automatically but available for emergency use. Drizzle drizzle-kit drop handles programmatic rollback in development.
Security & PII
- users.email — Direct identifier. citext. RLS policy: visible only to owning user and admins.
- users.display_name — Public pseudonym. PII by GDPR definition. Included in DSAR export.
- users.avatar_url — Derived PII if photo contains face. Handle as PII in export/delete flows.
- affiliate_clicks.ip_hash — SHA-256 of IP. Not reversible. Stored for fraud detection, not attribution.
- verifications.photos (R2) — User-submitted. May contain PII. GDPR deletion requires R2 object deletion.
- audit_log.metadata (jsonb) — May contain PII snapshot from before-state. Retention 2yr, then partition drop.
- users.deleted_at is set on GDPR deletion request. Row is never physically deleted.
- All RLS policies on users include
deleted_at IS NULLfilter. - 30 days after deletion, background job nullifies PII fields (email → anonymized hash, display_name → "Deleted User", avatar_url → NULL).
- Verifications and builds by deleted users retain data but display_name shows "Deleted User".
- R2 objects (photos, avatar) deleted immediately on GDPR request, not at 30-day mark.
- audit_log retains actor_id but metadata is scrubbed of PII values at anonymization time.
Row Level Security (RLS) Policies
Admin users operate under a privileged Postgres role that bypasses RLS (BYPASSRLS privilege or SET ROLE app_admin within transactions). Never expose admin role credentials to frontend or public API paths. Admin mutations are double-logged in audit_log with actor_id set.
Seed Data
Development and staging seed data for testing all major code paths. Production seed contains only the 5 initial retailers and curated bikes/parts — no fake user accounts.
| Year | Make | Model | Variant | CC | Category |
|---|---|---|---|---|---|
| 2020 | Kawasaki | Ninja 400 | ABS | 399 | sport |
| 2021 | Kawasaki | Ninja 400 | — | 399 | sport |
| 2023 | Kawasaki | Z900 | ABS | 948 | naked |
| 2022 | Yamaha | MT-07 | — | 689 | naked |
| 2023 | Yamaha | MT-07 | — | 689 | naked |
| 2021 | Yamaha | R7 | — | 689 | sport |
| 2023 | Honda | CB500F | ABS | 471 | naked |
| 2022 | Honda | CB650R | — | 649 | naked |
| 2023 | Honda | Africa Twin | Adventure Sports | 1084 | adv |
| 2022 | Ducati | Monster | — | 937 | naked |
| 2023 | Ducati | Scrambler | Icon | 803 | cafe |
| 2022 | BMW | R1250GS | Adventure | 1254 | adv |
| 2023 | BMW | S1000R | — | 999 | naked |
| 2021 | KTM | 390 Duke | — | 373 | naked |
| 2023 | KTM | 890 Duke R | — | 889 | naked |
| 2022 | Triumph | Street Triple RS | — | 765 | naked |
| 2023 | Royal Enfield | Meteor 350 | — | 349 | cruiser |
| 2022 | Harley-Davidson | Iron 883 | — | 883 | cruiser |
| 2023 | Suzuki | GSX-8S | — | 776 | naked |
| 2022 | Aprilia | RS 660 | — | 659 | sport |
| Name | Slug | Commission | Cookie Days | Tier |
|---|---|---|---|---|
| RevZilla | revzilla | 8.00% | 30 | verified |
| Cycle Gear | cycle-gear | 6.50% | 14 | affiliate |
| Twisted Throttle | twisted-throttle | 9.00% | 45 | verified |
| Amazon Moto | amazon-moto | 4.00% | 1 | affiliate |
| BikeBandit | bikebandit | 7.50% | 30 | data |
| Display Name | Role | Reputation | Badges | Notes | |
|---|---|---|---|---|---|
free@test.motopartpicker.com |
FreeRider | free | 0 | — | No bikes, no builds. Tests empty states. |
contributor@test.motopartpicker.com |
SpeedWrench | contributor | 1250 | early_adopter, top_contributor | 2 bikes, 1 public build, 23 verifications. Tests all community features. |
admin@test.motopartpicker.com |
SiteAdmin | admin | 9999 | admin, early_adopter | Bypasses RLS. Tests admin workflows: adding bikes, editing compat records, managing retailers. |
deleted@test.motopartpicker.com |
DeletedUser | deleted | 0 | — | deleted_at is set. Tests soft-delete display behavior (verifications show "Deleted User"). |
retailer@test.motopartpicker.com |
RetailerRep | retailer | 0 | — | Linked to RevZilla retailer account. Tests retailer dashboard, subscription management. |
All test accounts use password TestPass123! in development/staging. These accounts are never seeded in production. The seed script checks NODE_ENV !== 'production' before inserting user rows.
MotoPartPicker Data Model v1.0 · Last updated 2026-04-08 · Neon Postgres (serverless) · Drizzle ORM