convex-pro-max
SKILL.md
Convex Pro Max
The definitive guide for building production-ready Convex applications.
Critical Rules
- Always use new function syntax with
args,returns, andhandler. - Always validate args AND returns on public functions.
- Always use indexes — never
.filter()on the database. Use.withIndex(). - Always
awaitpromises — enable@typescript-eslint/no-floating-promises. - Use
internal*functions for scheduled jobs, crons, and sensitive operations. - Never use
ctx.dbin actions — usectx.runQuery/ctx.runMutation. - Actions are NOT transactional — consolidate reads into single queries, writes into single mutations.
- Return
nullexplicitly if a function returns nothing (returns: v.null()). - Use
v.id("table")notv.string()for document IDs. - Install
@convex-dev/eslint-plugin— enforces object syntax, arg validators, explicit table IDs, correct runtime imports.
Function Types
| Type | DB Access | External APIs | Transactional | Cached/Reactive |
|---|---|---|---|---|
query |
Read-only via ctx.db |
No | Yes | Yes |
mutation |
Read/Write via ctx.db |
No | Yes | No |
action |
Via runQuery/runMutation |
Yes | No | No |
httpAction |
Via runQuery/runMutation |
Yes | No | No |
Query
import { query } from "./_generated/server";
import { v } from "convex/values";
export const getUser = query({
args: { userId: v.id("users") },
returns: v.union(v.object({
_id: v.id("users"), _creationTime: v.number(),
name: v.string(), email: v.string(),
}), v.null()),
handler: async (ctx, args) => {
return await ctx.db.get(args.userId);
},
});
Mutation
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export const createTask = mutation({
args: { title: v.string(), userId: v.id("users") },
returns: v.id("tasks"),
handler: async (ctx, args) => {
return await ctx.db.insert("tasks", {
title: args.title, userId: args.userId,
completed: false, createdAt: Date.now(),
});
},
});
Action (external APIs)
"use node"; // Required for Node.js APIs
import { action } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
export const processPayment = action({
args: { orderId: v.id("orders"), amount: v.number() },
returns: v.null(),
handler: async (ctx, args) => {
const order = await ctx.runQuery(internal.orders.get, { orderId: args.orderId });
const result = await fetch("https://api.stripe.com/...", { method: "POST", /* ... */ });
await ctx.runMutation(internal.orders.updateStatus, {
orderId: args.orderId, status: result.ok ? "paid" : "failed",
});
return null;
},
});
Internal functions
import { internalMutation, internalQuery, internalAction } from "./_generated/server";
import { internal } from "./_generated/api"; // for referencing internal functions
import { api } from "./_generated/api"; // for referencing public functions
Only callable by other Convex functions, crons, and the dashboard — never by clients.
Schema & Indexes
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
users: defineTable({
name: v.string(),
email: v.string(),
role: v.union(v.literal("admin"), v.literal("member")),
settings: v.object({ theme: v.union(v.literal("light"), v.literal("dark")) }),
})
.index("by_email", ["email"])
.index("by_role", ["role"]),
messages: defineTable({
channelId: v.id("channels"),
authorId: v.id("users"),
content: v.string(),
})
.index("by_channel", ["channelId"])
.index("by_channel_and_author", ["channelId", "authorId"])
.searchIndex("search_content", { searchField: "content", filterFields: ["channelId"] }),
});
Index rules:
- Compound indexes are prefix-searchable:
by_channel_and_authoralso serves queries bychannelIdalone. - Don't create
by_channelseparately ifby_channel_and_authoralready exists. - Name convention:
by_field1_and_field2. - Nested fields use dot notation:
.index("by_theme", ["settings.theme"]). - System fields
_idand_creationTimeare automatically available.
Database Operations
// Read
const doc = await ctx.db.get(id); // by ID (null if not found)
const docs = await ctx.db.query("table").collect(); // all (bounded)
const first = await ctx.db.query("table").first(); // first or null
const one = await ctx.db.query("table").withIndex(...).unique(); // exactly one (throws if 0 or >1)
const top10 = await ctx.db.query("table").order("desc").take(10);
// Indexed query
const results = await ctx.db.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
.order("desc")
.take(50);
// Write
const id = await ctx.db.insert("table", { ...fields });
await ctx.db.patch(id, { field: newValue }); // partial update
await ctx.db.replace(id, { ...allFields }); // full replace (keeps _id, _creationTime)
await ctx.db.delete(id);
Validators Quick Reference
v.string() v.number() v.boolean() v.null()
v.id("tableName") v.int64() v.bytes() v.any()
v.array(v.string()) v.record(v.string(), v.number())
v.object({ name: v.string(), age: v.optional(v.number()) })
v.union(v.literal("a"), v.literal("b")) // enum-like
v.optional(v.string()) // field can be omitted
v.nullable(v.string()) // shorthand for v.union(v.string(), v.null())
Reusable validators:
const roleValidator = v.union(v.literal("admin"), v.literal("member"));
const profileValidator = v.object({ name: v.string(), bio: v.optional(v.string()) });
Extract TypeScript types:
import { Infer } from "convex/values";
type Role = Infer<typeof roleValidator>; // "admin" | "member"
Generated types:
import { Doc, Id } from "./_generated/dataModel";
import { QueryCtx, MutationCtx, ActionCtx } from "./_generated/server";
Authentication & Security
// Reusable auth helper
export async function getCurrentUser(ctx: QueryCtx | MutationCtx) {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new ConvexError({ code: "UNAUTHORIZED", message: "Must be logged in" });
const user = await ctx.db.query("users")
.withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier))
.unique();
if (!user) throw new ConvexError({ code: "USER_NOT_FOUND", message: "User not found" });
return user;
}
Security rules:
- Never trust client-provided identifiers — derive user from
ctx.auth. - Use granular mutations (separate
setTeamName,transferOwnership) instead of one genericupdateTeam. - Use
internalMutationfor crons, scheduled jobs, webhooks — clients cannot call them. - Convex IDs are unguessable, but still verify authorization.
Error Handling
import { ConvexError } from "convex/values";
// Throw structured errors
throw new ConvexError({ code: "NOT_FOUND", message: "Task not found" });
// Client catches
try { await createUser({ email }); }
catch (error) {
if (error instanceof ConvexError) { /* error.data.code, error.data.message */ }
}
- Dev: full error messages sent to client. Prod: only
ConvexErrordata is forwarded; other errors show "Server Error". - Mutation errors roll back the entire transaction.
Code Organization
convex/
├── schema.ts # Schema + indexes
├── auth.ts # getCurrentUser, requireTeamMember helpers
├── users.ts # Public user API (thin wrappers)
├── teams.ts # Public team API
├── model/
│ ├── users.ts # User business logic (pure TS functions)
│ └── teams.ts # Team business logic
├── http.ts # HTTP actions (webhooks, APIs)
├── crons.ts # Cron jobs
└── convex.config.ts # Component registration
Use plain TypeScript functions in model/ instead of ctx.runAction for code organization.
Only use ctx.runAction when calling Convex components or crossing runtimes.
Reference Files
- HTTP actions, scheduling, crons, file storage, runtimes: See references/functions-deep.md
- Denormalization, OCC, sharding, N+1, aggregates, query optimization: See references/performance.md
- Common patterns, anti-patterns, testing, migration: See references/patterns.md
Weekly Installs
11
Repository
imfa-solutions/skillsGitHub Stars
1
First Seen
Feb 24, 2026
Security Audits
Installed on
opencode11
gemini-cli11
github-copilot11
codex11
amp11
kimi-cli11