Convex schema design at scale: 87+ tables, index strategy, validator patterns, and migration awareness.
Forge’s Convex backend has 87+ tables spanning workflow execution, agent management, governance, chat, budgets, analytics, and more. This page documents the patterns and conventions that keep the schema organized, performant, and safe to evolve.
Every table lives in its own file inside convex/schema/. The root convex/schema.ts imports each table definition and composes them into a single defineSchema() call using the spread operator.Why one file per table:
Each table definition is independently readable and reviewable
Git diffs stay scoped to the table that changed
Enum constants and TypeScript types are co-located with the table they belong to
Each schema file exports an object with a single key (the table name) whose value is a defineTable(...) call. The root schema spreads them all together:
// convex/schema/budgets.tsimport { defineTable } from "convex/server";import { v } from "convex/values";import { literals } from "./helpers";export const BUDGET_SCOPES = ["global", "gateway", "agent"] as const;export type BudgetScope = (typeof BUDGET_SCOPES)[number];export const BUDGET_PERIODS = ["daily", "weekly", "monthly"] as const;export type BudgetPeriod = (typeof BUDGET_PERIODS)[number];export const budgetsTable = { budgets: defineTable({ scope: literals(BUDGET_SCOPES), scopeId: v.optional(v.string()), period: literals(BUDGET_PERIODS), amount: v.number(), enabled: v.boolean(), spentCents: v.optional(v.number()), periodStart: v.optional(v.number()), lastResetAt: v.optional(v.number()), createdAt: v.number(), updatedAt: v.number(), }) .index("by_scope", ["scope", "scopeId"]) .index("by_scope_period", ["scope", "scopeId", "period"]) .index("by_enabled", ["enabled"]),};
// convex/schema.ts (root — abbreviated)import { defineSchema } from "convex/server";import { budgetsTable } from "./schema/budgets";import { factoriesTable } from "./schema/factories";import { workflowRunsTable } from "./schema/workflow_runs";// ... 80+ more importsexport default defineSchema({ // Budget Management ...budgetsTable, ...budgetHierarchyTable, ...budgetTransactionsTable, ...budgetAlertsTable, // Workflow Execution ...workflowsTable, ...workflowRunsTable, ...stepResultsTable, // ...});
The root file groups imports by domain (Gateway & Infrastructure, Workflow Execution, Budget Management, etc.) with comments acting as section headers. This makes it easy to find where a new table should be registered.
convex/schema/index.ts re-exports every enum constant array and its derived TypeScript type from every schema file. This lets consuming code import enums from a single path:
// Consumer code can import enums cleanlyimport { STEP_STATUSES, BUDGET_SCOPES } from "../schema";import type { StepStatus, BudgetScope } from "../schema";
The convex/schema/helpers.ts file provides a literals() helper that converts a TypeScript as const tuple into a Convex v.union(v.literal(...)) validator:
// convex/schema/helpers.tsexport function literals<const T extends readonly [string, string, ...string[]]>( values: T,): Validator<T[number]> { const [first, second, ...rest] = values.map((val) => v.literal(val)); return v.union(first!, second!, ...rest) as unknown as Validator<T[number]>;}
Every enum field follows the same three-line pattern:
export const FACTORY_STATUSES = ["PROVISIONING", "ACTIVE", "PAUSED", "ARCHIVED"] as const;export type FactoryStatus = (typeof FACTORY_STATUSES)[number];// In the table definition:status: literals(FACTORY_STATUSES),
This gives you:
A runtime-accessible array of valid values (for dropdowns, validation, etc.)
A TypeScript union type for compile-time safety
A Convex validator that enforces the constraint at the database level
References to other tables use v.id("tableName"). This creates a typed, validated foreign key:
// Hard reference — this document MUST exist in the referenced tablerunId: v.id("workflowRuns"),// Optional reference — may or may not be setgatewayId: v.optional(v.id("gatewayRegistry")),budgetHierarchyId: v.optional(v.id("budgetHierarchy")),parentRunId: v.optional(v.id("workflowRuns")), // self-referentiallogStorageId: v.optional(v.id("_storage")), // Convex system table
v.id() validates that the string is a valid document ID for that table, but does not enforce referential integrity at the database level. Deleting a referenced document will not cascade or fail — your application code must handle dangling references.
// From workflowRuns — discriminated by runTypegoal: v.optional(v.string()), // only for delegated runsdefinition: v.optional(v.string()), // only for prescriptive runsguardrails: v.optional(v.object({...})), // only for delegated runs// Progressive enrichmentcompletedAt: v.optional(v.number()),totalTokens: v.optional(v.number()),totalCost: v.optional(v.number()),
Some fields intentionally accept arbitrary shapes. These are rare and always documented:
// DYNAMIC: user-defined runtime arguments, shape varies per workflowargs: v.optional(v.any()),// DYNAMIC: agent output is unpredictable by designoutputs: v.optional(v.any()),// DYNAMIC: factory-type-specific configuration, shape varies per factory typeconfig: v.optional(v.any()),
Avoid v.any() unless the data shape is genuinely unpredictable. If you can enumerate the possible shapes, use v.union() with v.object() variants instead. When v.any() is unavoidable, add a comment explaining why.
For scoped queries where you filter on one field and range-scan on another. The equality fields come first, followed by the range field:
// "Find all token events for this agent, ordered by time".index("by_agent", ["agentId", "timestamp"])// "Find all token events for this model, ordered by time".index("by_model", ["model", "timestamp"])// "Find budgets for this scope and period".index("by_scope_period", ["scope", "scopeId", "period"])// "Find all runs of this type with this status".index("by_runType", ["runType", "status"])
For hierarchical data with multiple scoping levels:
// Channels scoped by type, entity type, and entity ID.index("by_scope", ["scope", "scopeEntityType", "scopeEntityId"])// Factories scoped by owner type and owner ID.index("by_owner", ["ownerType", "ownerId"])// Workflow runs by status and completion time (for cleanup/retention queries).index("by_status_completedAt", ["status", "completedAt"])
You query a field with .withIndex("by_field", q => q.eq("field", value))
You need to range-scan within a scoped entity (e.g., “all events for run X after time T”)
A dashboard page lists records filtered by status, type, or owner
You run retention/cleanup jobs that scan by timestamp within a status
Skip an index when...
The table is small (< 1,000 documents) and full scans are acceptable
You only ever look up documents by _id
The field has very low cardinality and the table is large (index won’t help much)
You can reuse an existing composite index (Convex supports prefix matching on composite indexes)
Convex composite indexes support prefix queries. An index on ["scope", "scopeId", "period"] also efficiently serves queries that only filter on ["scope"] or ["scope", "scopeId"]. You do not need separate single-field indexes for prefix subsets of a composite index.
The .lobsterX workflow format is validated with Zod in packages/shared/src/lobsterx-schema.ts. It demonstrates several patterns:Regex-enforced naming conventions:
const snakeCasePattern = /^[a-z][a-z0-9_]*$/;const kebabCasePattern = /^[a-z][a-z0-9-]*$/;id: z.string().regex(snakeCasePattern, "Step id must be snake_case"),
For breaking changes, use the widen-migrate-narrow pattern:
1
Widen
Make the new field optional alongside the old one. Deploy.
2
Migrate
Run a migration to backfill the new field on all existing documents.
3
Narrow
Once all documents have the new field, make it required and remove the old field. Deploy.
Never make a required field optional or remove a field in a single deployment if existing documents use it. Convex validates all existing documents against the new schema at deploy time.
convex/_generated/ contains auto-generated TypeScript types, the api object, and the dataModel type. Never edit files in this directory. They are regenerated on every npx convex dev or npx convex deploy.
Use v.id("tableName") in schemas and Id<"tableName"> in TypeScript code:
// In schemarunId: v.id("workflowRuns"),// In function codeimport type { Id } from "./_generated/dataModel";function getSteps(runId: Id<"workflowRuns">) { ... }
Convex module files must use underscores, not hyphens. Convex uses the file path as the function’s API path, and hyphens are not valid in Convex identifiers:
convex/schema/workflow_runs.ts // correctconvex/schema/workflow-runs.ts // WRONG — will not work
Standard Convex functions (queries, mutations) run in a V8 isolate without Node.js APIs. If you need fetch, crypto, or other Node.js built-ins, use an action file with the "use node" directive. Never import Node.js APIs in convex/lib/ or schema files.