Seed Data Parity: Dev ↔ Prod
The Problem
Dev and prod can drift out of sync because:
- Config data seeds are blocked on prod.
seedBrands,seedVideos, andseedEntitlementsall callassertNotProduction(), but they contain reference/config data that prod actually needs (brand dropdowns, video blog, vendor plans). - No single command seeds all config data. You have to remember 6+ separate
npx convex runcommands and their order. - 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
| Type | Tables | Should run on prod? |
|---|---|---|
| Config | defaultTaskTemplates, defaultQuestionTemplates, defaultQuestionTemplateOptions, qualificationTemplates, vendorPlans, planEntitlementRules, accountStatusModifierRules, brands, videos, homepageContent | Yes |
| Demo | users (demo accounts), growers, vendors, trials, products, trialParticipants, shipments, etc. created by seedAccounts/seedDemoAdmin/seedDemoGrower | No |
Changes to Make
1. Remove assertNotProduction() from config seeds
These files call it but shouldn't:
convex/seed/seedBrands.ts— Remove fromseedBrandshandlerconvex/seed/seedVideos.ts— Remove fromseedVideoshandlerconvex/seed/seedEntitlements.ts— Remove fromseedEntitlementshandler
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:
seedEntitlementsalready 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:seedDemoGrowerFor prod, just the config:
1npx convex run --prod seed/seedConfigData:seedConfigDataChecklist
- Remove
assertNotProduction()fromseedBrands,seedVideos,seedEntitlements - Change
seedDefaultTasksto upsert bynameinstead of skip-if-any-exist - Change
seedDefaultQuestionsto upsert byname(and recreate options) - Change
seedDefaultQualificationsto upsert byname - Change
seedBrandsto upsert byslug - Create
convex/seed/seedConfigData.tsunified action - Run
seedConfigDataon prod to backfill any missing config data - Update
convex/seed/CLAUDE.mdwith the new commands