PluginBench
Skill
Pass
Audit score 90

convex-functions

waynesutton/builder-skills

Write type-safe Convex queries, mutations, and actions with validators and proper error handling.

What is convex-functions?

A skill for writing Convex backend functions in the object form with args and returns validators. Use when adding or modifying any exported function in convex/*.ts files to ensure correct function type selection, database access patterns, runtime boundaries, and error handling.

  • Determines the correct function type (query, mutation, action, or internal variants) based on database and external call requirements
  • Enforces object-form declarations with args and returns validators on every function
  • Guides efficient database reads using indexed queries and appropriate terminal methods (.unique(), .first(), .take(), .collect(), .paginate())
  • Implements safe write patterns with patch, replace, insert, and delete operations
  • Manages action runtime boundaries with ctx.runQuery and ctx.runMutation for external calls
  • Handles errors with ConvexError for client-readable messages and proper null returns for expected absences

How to install convex-functions

npx skills add https://github.com/waynesutton/builder-skills --skill convex-functions
Prerequisites
  • Convex project initialized with convex/ directory and schema.ts
  • Understanding of your data model and defined indexes in convex/schema.ts
Claude Code
Cursor
Windsurf
Cline

How to use convex-functions

  1. 1.Identify whether your function reads data (query), writes data (mutation), calls external services (action), or runs internally (internalQuery/internalMutation/internalAction)
  2. 2.Declare args and returns validators using v.object(), v.id(), v.string(), etc. from convex/values
  3. 3.For reads: use ctx.db.get(id) for single documents or ctx.db.query().withIndex() for multiple documents, avoiding .filter() scans
  4. 4.For writes: use ctx.db.insert(), patch(), replace(), or delete() directly without reading first unless necessary
  5. 5.For actions: use ctx.runQuery() and ctx.runMutation() to access the database, and fetch() for external APIs
  6. 6.Throw ConvexError for client-readable errors; return null for expected absences like missing documents

Use cases

Good for
  • Adding a new query to fetch user tasks with proper indexing and pagination
  • Converting a mutation to an action when it needs to call an external payment API
  • Scheduling internal functions for background work like sending notifications after a message is posted
  • Implementing webhook handlers as httpAction functions that process external requests
  • Refactoring a table scan into an indexed query to improve performance
Who it's for
  • Backend developers building Convex applications
  • Full-stack developers adding server-side functions to existing Convex projects
  • Teams standardizing function patterns and error handling across a codebase

convex-functions FAQ

When should I use a query vs. a mutation vs. an action?

Use query for reads (cached and reactive), mutation for writes to the database, and action only when you need to call external APIs or Node.js built-ins. Default to query or mutation.

Why should I avoid .filter() on table queries?

Calling .filter() scans the entire table, which is inefficient. Instead, define an index in convex/schema.ts and use .withIndex() to query only the documents you need.

How do I call one Convex function from another?

Reference public functions via api.* (e.g., api.tasks.get) and internal functions via internal.* (e.g., internal.tasks.markPaid) from ./_generated/api. Always schedule internal functions for background jobs.

What is the difference between throwing ConvexError and returning null?

Throw ConvexError for real failures (auth, validation, external errors) so the client sees the message. Return null for expected absences like a document lookup that finds nothing.

Can I use Node.js APIs in a Convex function?

Only in actions with "use node"; as the first line. Actions in the default runtime can use fetch but not Node built-ins. Queries and mutations cannot use "use node".

Full instructions (SKILL.md)

Source of truth, from waynesutton/builder-skills.


name: convex-functions description: Writes Convex queries, mutations, actions, and internal functions in the object form with args and returns validators, correct ctx usage, runtime boundaries, and error handling. Use when adding or changing anything in convex/*.ts that exports a function, or when deciding between query, mutation, and action.

Convex functions

Every exported function in convex/ uses the object form with args and returns validators. Pick the type by what the handler touches: queries read, mutations write, actions call out.

Pick the function type

TypeDatabaseExternal callsCallable byUse for
queryReadNoClients, other functionsReads. Cached and reactive.
mutationRead and writeNoClients, other functionsWrites. One transaction.
actionOnly via runQuery and runMutationYesClients, scheduler, other actionsfetch, third party SDKs, Node APIs
internalQuery, internalMutation, internalActionSame as the public formSameOnly other Convex functionsScheduled work, crons, privileged writes
httpActionOnly via runQuery and runMutationYesHTTP requests in convex/http.tsWebhooks, REST endpoints

Default to query or mutation. Reach for an action only when the handler must talk to something outside Convex.

The object form

Declare args and returns on every function. A function that returns nothing declares returns: v.null() and returns null. Hoist a shared document validator when several functions return the same shape.

// convex/tasks.ts
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";

const taskValidator = v.object({
  _id: v.id("tasks"),
  _creationTime: v.number(),
  userId: v.id("users"),
  title: v.string(),
  completed: v.boolean(),
});

export const get = query({
  args: { taskId: v.id("tasks") },
  returns: v.union(taskValidator, v.null()),
  handler: async (ctx, args) => {
    return await ctx.db.get(args.taskId);
  },
});

export const remove = mutation({
  args: { taskId: v.id("tasks") },
  returns: v.null(),
  handler: async (ctx, args) => {
    await ctx.db.delete(args.taskId);
    return null;
  },
});

Reading data

Use ctx.db.get(id) for one document by id. For everything else use withIndex against an index defined in convex/schema.ts. Never call .filter() on a table query; it scans the whole table.

export const listByUser = query({
  args: { userId: v.id("users") },
  returns: v.array(taskValidator),
  handler: async (ctx, args) => {
    return await ctx.db
      .query("tasks")
      .withIndex("by_user", (q) => q.eq("userId", args.userId))
      .order("desc")
      .take(50);
  },
});

Pick the terminal method by how many documents you expect:

MethodReturnsUse when
.unique()One doc or null, throws on more than oneThe index guarantees at most one match
.first()First doc or nullYou want the newest or oldest match
.take(n)Up to n docsA bounded list such as a recent feed
.collect()Every matchThe result set is small and stays small
.paginate(opts)A page plus cursorThe table is unbounded

Paginated queries take paginationOpts: paginationOptsValidator (from convex/server) as an argument.

Writing data

MethodWhat it does
ctx.db.insert("tasks", doc)Inserts and returns the new id
ctx.db.patch(id, fields)Shallow merges fields. Throws if the doc is missing
ctx.db.replace(id, doc)Replaces the whole doc. Throws if missing
ctx.db.delete(id)Deletes the doc

Patch directly when you do not need the old value. Reading first widens the window for write conflicts. Make mutations safe to retry.

export const rename = mutation({
  args: { taskId: v.id("tasks"), title: v.string() },
  returns: v.null(),
  handler: async (ctx, args) => {
    await ctx.db.patch(args.taskId, { title: args.title });
    return null;
  },
});

Internal functions and references

query, mutation, and action are public. Anyone with the deployment URL can call them. Use internalQuery, internalMutation, and internalAction for code that should only run from other Convex code: scheduled jobs, crons, webhook handlers, privileged writes.

Reference functions through the generated objects in ./_generated/api:

  • api.tasks.get points at a public function in convex/tasks.ts
  • internal.tasks.markPaid points at an internal function in the same file
  • Folders map to paths: convex/billing/invoices.ts gives api.billing.invoices.list

Always schedule internal.*. Scheduled functions and crons run without a client, so a public reference there skips the auth checks a client call would hit.

// convex/messages.ts
import { mutation, internalMutation } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";

export const send = mutation({
  args: { channelId: v.id("channels"), content: v.string() },
  returns: v.id("messages"),
  handler: async (ctx, args) => {
    const messageId = await ctx.db.insert("messages", args);
    await ctx.scheduler.runAfter(0, internal.messages.notifySubscribers, {
      channelId: args.channelId,
      messageId,
    });
    return messageId;
  },
});

export const notifySubscribers = internalMutation({
  args: { channelId: v.id("channels"), messageId: v.id("messages") },
  returns: v.null(),
  handler: async (ctx, args) => {
    const subs = await ctx.db
      .query("subscriptions")
      .withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
      .collect();
    await Promise.all(
      subs.map((sub) =>
        ctx.db.insert("notifications", {
          userId: sub.userId,
          messageId: args.messageId,
          read: false,
        }),
      ),
    );
    return null;
  },
});

Actions and runtime boundaries

Actions have no ctx.db. They read through ctx.runQuery and write through ctx.runMutation. Each call is its own transaction, so keep the count low and do related reads and writes inside one mutation.

fetch works in the default runtime. Add "use node"; as the first line of a file only when an action needs Node built ins or a Node only SDK. A "use node" file can export actions only; queries and mutations go in a separate file.

// convex/orders.ts (default runtime)
import { action } from "./_generated/server";
import { internal } from "./_generated/api";
import { v, ConvexError } from "convex/values";
import { Doc } from "./_generated/dataModel";

export const charge = action({
  args: { orderId: v.id("orders") },
  returns: v.null(),
  handler: async (ctx, args) => {
    // Same file call: annotate the result so TypeScript does not hit a circular type
    const order: Doc<"orders"> | null = await ctx.runQuery(
      internal.orders.getForCharge,
      { orderId: args.orderId },
    );
    if (!order) {
      throw new ConvexError("Order not found");
    }
    const res = await fetch("https://api.payments.example/charge", {
      method: "POST",
      body: JSON.stringify({ amount: order.total }),
    });
    await ctx.runMutation(internal.orders.setStatus, {
      orderId: args.orderId,
      status: res.ok ? "paid" : "failed",
    });
    return null;
  },
});

Doc and Id come from ./_generated/dataModel. The annotation is only needed when the called function lives in the same file.

Errors

Throw ConvexError from convex/values for anything a client should read. Its data reaches the client; a plain Error message is redacted in production. Return null for expected absences such as a lookup that finds nothing. Throw for real failures: not authenticated, not authorized, invalid input.

import { ConvexError } from "convex/values";

throw new ConvexError({ code: "NOT_FOUND", message: "Task not found" });

Thin wrappers

Keep handlers short. Put auth lookups, validation, and business logic in plain async functions that take ctx first, then call them from the wrapper. Plain helpers are testable and shared between queries and mutations without a ctx.runQuery hop.

import { QueryCtx, MutationCtx } from "./_generated/server";
import { ConvexError } from "convex/values";

export async function getCurrentUser(ctx: QueryCtx | MutationCtx) {
  const identity = await ctx.auth.getUserIdentity();
  if (!identity) {
    throw new ConvexError("Not authenticated");
  }
  const user = await ctx.db
    .query("users")
    .withIndex("by_token", (q) =>
      q.eq("tokenIdentifier", identity.tokenIdentifier),
    )
    .unique();
  if (!user) {
    throw new ConvexError("User not found");
  }
  return user;
}

From a query or mutation, call the helper directly. ctx.runQuery and ctx.runMutation are for actions and component boundaries.

Common mistakes

MistakeWhy it breaksDo instead
No returns validatorReturn shape drifts and client types lieDeclare returns, use v.null() for nothing
.filter() on a table queryFull table scanAdd an index, use withIndex
ctx.db inside an actionActions have no database handlectx.runQuery and ctx.runMutation
fetch inside a query or mutationTransactions must be deterministicMove it to an action
Scheduling api.*Runs public code without a client, skips authSchedule internal.*
"use node" in a file with queriesBundler rejects the fileSplit actions into their own file
Date.now() in a queryBreaks caching and reactivityPass time as an arg or store a status field
Many runQuery calls from one actionEach is a separate transaction, races appearOne mutation that does the related work
Plain Error for user messagesMessage is hidden in productionConvexError
Missing await on ctx.db or schedulerWrite may not commitAwait every ctx call

Checklist

  • Object form with args and returns on every exported function
  • returns: v.null() and return null when there is nothing to return
  • Reads use ctx.db.get(id) or withIndex, never .filter()
  • Unbounded tables use .paginate() or .take(n), not .collect()
  • Mutations patch directly and are safe to retry
  • Scheduled and cron targets are internal.*
  • Actions never touch ctx.db
  • "use node" only in files that export actions and need Node
  • Same file runQuery and runMutation results have a type annotation
  • Client visible errors are ConvexError
  • Every ctx.* promise is awaited

Docs