MDX Limo
Seed Data Parity: Dev ↔ Prod

Seed Data Parity: Dev ↔ Prod

The Problem

Dev and prod can drift out of sync because:

  1. Config data seeds are blocked on prod. seedBrands, seedVideos, and seedEntitlements all call assertNotProduction(), but they contain reference/config data that prod actually needs (brand dropdowns, video blog, vendor plans).
  2. No single command seeds all config data. You have to remember 6+ separate npx convex run commands and their order.
  3. Config seeds aren't truly idempotent. They check "does any record exist?" and bail entirely. If templates change in code, you have to manually clear* then re-seed — easy to forget on one environment.

What's Config Data vs Demo Data

TypeTablesShould run on prod?
ConfigdefaultTaskTemplates, defaultQuestionTemplates, defaultQuestionTemplateOptions, qualificationTemplates, vendorPlans, planEntitlementRules, accountStatusModifierRules, brands, videos, homepageContentYes
Demousers (demo accounts), growers, vendors, trials, products, trialParticipants, shipments, etc. created by seedAccounts/seedDemoAdmin/seedDemoGrowerNo

Changes to Make

1. Remove assertNotProduction() from config seeds

These files call it but shouldn't:

  • convex/seed/seedBrands.ts — Remove from seedBrands handler
  • convex/seed/seedVideos.ts — Remove from seedVideos handler
  • convex/seed/seedEntitlements.ts — Remove from seedEntitlements handler

seedDefaultTasks, seedDefaultQuestions, and seedDefaultQualifications already don't have the guard — no change needed.

2. Make config seeds upsert by name instead of skip-if-any-exist

Currently every config seed does this:

1const existing = await ctx.db.query("someTable").first(); 2if (existing) return "already exists";

This means if one record exists, the entire seed is skipped — even if new templates were added to the code. Change each to upsert by name/key:

seedDefaultTasks.ts — For each task in DEFAULT_TASKS:

1const existing = await ctx.db 2 .query("defaultTaskTemplates") 3 .filter((q) => q.eq(q.field("name"), task.name)) 4 .first(); 5 6if (existing) { 7 await ctx.db.patch(existing._id, { ...task, updatedAt: now }); 8} else { 9 await ctx.db.insert("defaultTaskTemplates", { ...task, createdAt: now, updatedAt: now }); 10}

seedDefaultQuestions.ts — Same pattern, match on name:

1const existing = await ctx.db 2 .query("defaultQuestionTemplates") 3 .filter((q) => q.eq(q.field("name"), question.name)) 4 .first(); 5 6if (existing) { 7 await ctx.db.patch(existing._id, { ...questionData, updatedAt: now }); 8 // Delete + recreate options for this template 9} else { 10 // Insert template + options 11}

seedDefaultQualifications.ts — Same pattern, match on name.

seedBrands.ts — Already somewhat handled (skip-if-any), but should upsert per slug:

1const existing = await ctx.db 2 .query("brands") 3 .withIndex("by_slug", (q) => q.eq("slug", brand.slug)) 4 .first(); 5 6if (existing) { 7 await ctx.db.patch(existing._id, { name: brand.name, category, updatedAt: now }); 8} else { 9 await ctx.db.insert("brands", { ...brand, category, isActive: true, createdAt: now, updatedAt: now }); 10}

Note: seedEntitlements already upserts correctly — no change needed there.

3. Create a unified seedConfigData action

Create convex/seed/seedConfigData.ts:

1import { action } from "../_generated/server"; 2import { api, internal } from "../_generated/api"; 3 4/** 5 * Seed all config/reference data. Safe to run on both dev and prod. 6 * Idempotent — upserts everything, never duplicates. 7 * 8 * Run with: npx convex run seed/seedConfigData:seedConfigData 9 * For prod: npx convex run --prod seed/seedConfigData:seedConfigData 10 */ 11export const seedConfigData = action({ 12 args: {}, 13 handler: async (ctx) => { 14 const results: Record<string, unknown> = {}; 15 16 // 1. Entitlements (plans + rules + modifiers) 17 results.entitlements = await ctx.runMutation(api.seed.seedEntitlements.seedEntitlements, {}); 18 19 // 2. Default task templates 20 results.tasks = await ctx.runMutation(api.seed.seedDefaultTasks.seedDefaultTasks, {}); 21 22 // 3. Default question templates 23 results.questions = await ctx.runMutation(api.seed.seedDefaultQuestions.seedDefaultQuestions, {}); 24 25 // 4. Default qualification templates 26 results.qualifications = await ctx.runMutation( 27 api.seed.seedDefaultQualifications.seedDefaultQualifications, {} 28 ); 29 30 // 5. Brands 31 results.brands = await ctx.runMutation(api.seed.seedBrands.seedBrands, {}); 32 33 // 6. Videos 34 results.videos = await ctx.runMutation(api.seed.seedVideos.seedVideos, {}); 35 36 // 7. Homepage content 37 results.homepage = await ctx.runMutation(internal.homepage.seedDefaultHomepageContent, {}); 38 39 return { success: true, results }; 40 }, 41});

4. Update the seed run order docs

After these changes, the full setup for either environment is:

1# Config data (safe on both dev and prod) 2npx convex run seed/seedConfigData:seedConfigData 3 4# Demo accounts (dev only — blocked on prod by assertNotProduction) 5npx convex run seed/seedAccounts:seedAccounts 6npx convex run seed/seedDemoAdmin:seedDemoAdmin 7npx convex run seed/seedDemoGrower:seedDemoGrower

For prod, just the config:

1npx convex run --prod seed/seedConfigData:seedConfigData

Checklist

  • Remove assertNotProduction() from seedBrands, seedVideos, seedEntitlements
  • Change seedDefaultTasks to upsert by name instead of skip-if-any-exist
  • Change seedDefaultQuestions to upsert by name (and recreate options)
  • Change seedDefaultQualifications to upsert by name
  • Change seedBrands to upsert by slug
  • Create convex/seed/seedConfigData.ts unified action
  • Run seedConfigData on prod to backfill any missing config data
  • Update convex/seed/CLAUDE.md with the new commands
Seed Data Parity: Dev ↔ Prod | MDX Limo