$npx -y skills add waynesutton/convexskills --skill convex-schema-validatorDefining and validating database schemas with proper typing, index configuration, optional fields, unions, and migration strategies for schema changes
| 1 | # Convex Schema Validator |
| 2 | |
| 3 | Define and validate database schemas in Convex with proper typing, index configuration, optional fields, unions, and strategies for schema migrations. |
| 4 | |
| 5 | ## Documentation Sources |
| 6 | |
| 7 | Before implementing, do not assume; fetch the latest documentation: |
| 8 | |
| 9 | - Primary: https://docs.convex.dev/database/schemas |
| 10 | - Indexes: https://docs.convex.dev/database/indexes |
| 11 | - Data Types: https://docs.convex.dev/database/types |
| 12 | - For broader context: https://docs.convex.dev/llms.txt |
| 13 | |
| 14 | ## Instructions |
| 15 | |
| 16 | ### Basic Schema Definition |
| 17 | |
| 18 | ```typescript |
| 19 | // convex/schema.ts |
| 20 | import { defineSchema, defineTable } from "convex/server"; |
| 21 | import { v } from "convex/values"; |
| 22 | |
| 23 | export default defineSchema({ |
| 24 | users: defineTable({ |
| 25 | name: v.string(), |
| 26 | email: v.string(), |
| 27 | avatarUrl: v.optional(v.string()), |
| 28 | createdAt: v.number(), |
| 29 | }), |
| 30 | |
| 31 | tasks: defineTable({ |
| 32 | title: v.string(), |
| 33 | description: v.optional(v.string()), |
| 34 | completed: v.boolean(), |
| 35 | userId: v.id("users"), |
| 36 | priority: v.union( |
| 37 | v.literal("low"), |
| 38 | v.literal("medium"), |
| 39 | v.literal("high") |
| 40 | ), |
| 41 | }), |
| 42 | }); |
| 43 | ``` |
| 44 | |
| 45 | ### Validator Types |
| 46 | |
| 47 | | Validator | TypeScript Type | Example | |
| 48 | |-----------|----------------|---------| |
| 49 | | `v.string()` | `string` | `"hello"` | |
| 50 | | `v.number()` | `number` | `42`, `3.14` | |
| 51 | | `v.boolean()` | `boolean` | `true`, `false` | |
| 52 | | `v.null()` | `null` | `null` | |
| 53 | | `v.int64()` | `bigint` | `9007199254740993n` | |
| 54 | | `v.bytes()` | `ArrayBuffer` | Binary data | |
| 55 | | `v.id("table")` | `Id<"table">` | Document reference | |
| 56 | | `v.array(v)` | `T[]` | `[1, 2, 3]` | |
| 57 | | `v.object({})` | `{ ... }` | `{ name: "..." }` | |
| 58 | | `v.optional(v)` | `T \| undefined` | Optional field | |
| 59 | | `v.union(...)` | `T1 \| T2` | Multiple types | |
| 60 | | `v.literal(x)` | `"x"` | Exact value | |
| 61 | | `v.any()` | `any` | Any value | |
| 62 | | `v.record(k, v)` | `Record<K, V>` | Dynamic keys | |
| 63 | |
| 64 | ### Index Configuration |
| 65 | |
| 66 | ```typescript |
| 67 | export default defineSchema({ |
| 68 | messages: defineTable({ |
| 69 | channelId: v.id("channels"), |
| 70 | authorId: v.id("users"), |
| 71 | content: v.string(), |
| 72 | sentAt: v.number(), |
| 73 | }) |
| 74 | // Single field index |
| 75 | .index("by_channel", ["channelId"]) |
| 76 | // Compound index |
| 77 | .index("by_channel_and_author", ["channelId", "authorId"]) |
| 78 | // Index for sorting |
| 79 | .index("by_channel_and_time", ["channelId", "sentAt"]), |
| 80 | |
| 81 | // Full-text search index |
| 82 | articles: defineTable({ |
| 83 | title: v.string(), |
| 84 | body: v.string(), |
| 85 | category: v.string(), |
| 86 | }) |
| 87 | .searchIndex("search_content", { |
| 88 | searchField: "body", |
| 89 | filterFields: ["category"], |
| 90 | }), |
| 91 | }); |
| 92 | ``` |
| 93 | |
| 94 | ### Complex Types |
| 95 | |
| 96 | ```typescript |
| 97 | export default defineSchema({ |
| 98 | // Nested objects |
| 99 | profiles: defineTable({ |
| 100 | userId: v.id("users"), |
| 101 | settings: v.object({ |
| 102 | theme: v.union(v.literal("light"), v.literal("dark")), |
| 103 | notifications: v.object({ |
| 104 | email: v.boolean(), |
| 105 | push: v.boolean(), |
| 106 | }), |
| 107 | }), |
| 108 | }), |
| 109 | |
| 110 | // Arrays of objects |
| 111 | orders: defineTable({ |
| 112 | customerId: v.id("users"), |
| 113 | items: v.array(v.object({ |
| 114 | productId: v.id("products"), |
| 115 | quantity: v.number(), |
| 116 | price: v.number(), |
| 117 | })), |
| 118 | status: v.union( |
| 119 | v.literal("pending"), |
| 120 | v.literal("processing"), |
| 121 | v.literal("shipped"), |
| 122 | v.literal("delivered") |
| 123 | ), |
| 124 | }), |
| 125 | |
| 126 | // Record type for dynamic keys |
| 127 | analytics: defineTable({ |
| 128 | date: v.string(), |
| 129 | metrics: v.record(v.string(), v.number()), |
| 130 | }), |
| 131 | }); |
| 132 | ``` |
| 133 | |
| 134 | ### Discriminated Unions |
| 135 | |
| 136 | ```typescript |
| 137 | export default defineSchema({ |
| 138 | events: defineTable( |
| 139 | v.union( |
| 140 | v.object({ |
| 141 | type: v.literal("user_signup"), |
| 142 | userId: v.id("users"), |
| 143 | email: v.string(), |
| 144 | }), |
| 145 | v.object({ |
| 146 | type: v.literal("purchase"), |
| 147 | userId: v.id("users"), |
| 148 | orderId: v.id("orders"), |
| 149 | amount: v.number(), |
| 150 | }), |
| 151 | v.object({ |
| 152 | type: v.literal("page_view"), |
| 153 | sessionId: v.string(), |
| 154 | path: v.string(), |
| 155 | }) |
| 156 | ) |
| 157 | ).index("by_type", ["type"]), |
| 158 | }); |
| 159 | ``` |
| 160 | |
| 161 | ### Optional vs Nullable Fields |
| 162 | |
| 163 | ```typescript |
| 164 | export default defineSchema({ |
| 165 | items: defineTable({ |
| 166 | // Optional: field may not exist |
| 167 | description: v.optional(v.string()), |
| 168 | |
| 169 | // Nullable: field exists but can be null |
| 170 | deletedAt: v.union(v.number(), v.null()), |
| 171 | |
| 172 | // Optional and nullable |
| 173 | notes: v.optional(v.union(v.string(), v.null())), |
| 174 | }), |
| 175 | }); |
| 176 | ``` |
| 177 | |
| 178 | ### Index Naming Convention |
| 179 | |
| 180 | Always include all indexed fields in the index name: |
| 181 | |
| 182 | ```typescript |
| 183 | export default defineSchema({ |
| 184 | posts: defineTable({ |
| 185 | authorId: v.id("users"), |
| 186 | categoryId: v.id("categories"), |
| 187 | publishedAt: v.number(), |
| 188 | status: v.string(), |
| 189 | }) |
| 190 | // Good: descriptive names |
| 191 | .index("by_author", ["authorId"]) |
| 192 | .index("b |