From 1f9d51f99f2aaa492d5872aa5607f123e63f9e35 Mon Sep 17 00:00:00 2001 From: "Chau (Joe) Nguyen" <5130533+chauey@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:50:12 -0600 Subject: [PATCH] feat: add comprehensive Convex reactive backend skill - Schema design with validators, indexes (standard, compound, search, vector) - All function types: queries, mutations, actions, HTTP actions - Cursor-based pagination with usePaginatedQuery - Auth: Convex Auth, Clerk, Better Auth component - Angular integration with ConvexClient + Signals - React/Next.js client provider and hooks - Scheduling, cron jobs, file storage, environment variables - CLI/deployment commands reference - Best practices, anti-patterns, common pitfalls, limitations - ~795 lines of actionable, copy-pasteable content --- skills/convex/SKILL.md | 797 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 797 insertions(+) create mode 100644 skills/convex/SKILL.md diff --git a/skills/convex/SKILL.md b/skills/convex/SKILL.md new file mode 100644 index 00000000..64c8c938 --- /dev/null +++ b/skills/convex/SKILL.md @@ -0,0 +1,797 @@ +--- +name: convex +description: "Convex reactive backend expert: schema design, TypeScript functions, real-time subscriptions, auth, file storage, scheduling, and deployment." +risk: safe +source: "https://docs.convex.dev" +--- + +# Convex + +You are an expert in Convex — the open-source, reactive backend platform where queries are TypeScript code. You have deep knowledge of schema design, function authoring (queries, mutations, actions), real-time data subscriptions, authentication, file storage, scheduling, and deployment workflows across React, Next.js, Angular, Vue, Svelte, React Native, and server-side environments. + +## When to Use + +- Use when building a new project with Convex as the backend +- Use when adding Convex to an existing React, Next.js, Angular, Vue, Svelte, or React Native app +- Use when designing schemas for a Convex document-relational database +- Use when writing or debugging Convex functions (queries, mutations, actions) +- Use when implementing real-time/reactive data patterns +- Use when setting up authentication with Convex Auth or third-party providers (Clerk, Auth0, etc.) +- Use when working with Convex file storage, scheduled functions, or cron jobs +- Use when deploying or managing Convex projects + +## Core Concepts + +Convex is a **document-relational** database with a fully managed backend. Key differentiators: + +- **Reactive by default**: Queries automatically re-run and push updates to all connected clients when underlying data changes +- **TypeScript-first**: All backend logic — queries, mutations, actions, schemas — is written in TypeScript +- **ACID transactions**: Serializable isolation with optimistic concurrency control +- **No infrastructure to manage**: Serverless, scales automatically, zero config +- **End-to-end type safety**: Types flow from schema → backend functions → client hooks + +### Function Types + +| Type | Purpose | Can Read DB | Can Write DB | Can Call External APIs | Cached/Reactive | +| :-------------- | :------------------------ | :------------- | :---------------- | :--------------------- | :-------------- | +| **Query** | Read data | ✅ | ❌ | ❌ | ✅ | +| **Mutation** | Write data | ✅ | ✅ | ❌ | ❌ | +| **Action** | Side effects | via `runQuery` | via `runMutation` | ✅ | ❌ | +| **HTTP Action** | Webhooks/custom endpoints | via `runQuery` | via `runMutation` | ✅ | ❌ | + +## Project Setup + +### New Project (Next.js) + +```bash +npx create-next-app@latest my-app +cd my-app && npm install convex +npx convex dev +``` + +### Add to Existing Project + +```bash +npm install convex +npx convex dev +``` + +The `npx convex dev` command: + +1. Prompts you to log in (GitHub) +2. Creates a project and deployment +3. Generates `convex/` folder for backend functions +4. Syncs functions to your dev deployment in real-time +5. Creates `.env.local` with `CONVEX_DEPLOYMENT` and `NEXT_PUBLIC_CONVEX_URL` + +### Folder Structure + +``` +my-app/ +├── convex/ +│ ├── _generated/ ← Auto-generated (DO NOT EDIT) +│ │ ├── api.d.ts +│ │ ├── dataModel.d.ts +│ │ └── server.d.ts +│ ├── schema.ts ← Database schema definition +│ ├── tasks.ts ← Query/mutation functions +│ └── http.ts ← HTTP actions (optional) +├── .env.local ← CONVEX_DEPLOYMENT, NEXT_PUBLIC_CONVEX_URL +└── convex.json ← Project config (optional) +``` + +## Schema Design + +Define your schema in `convex/schema.ts` using the validator library: + +```typescript +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export default defineSchema({ + users: defineTable({ + name: v.string(), + email: v.string(), + avatarUrl: v.optional(v.string()), + tokenIdentifier: v.string(), + }) + .index("by_token", ["tokenIdentifier"]) + .index("by_email", ["email"]), + + messages: defineTable({ + authorId: v.id("users"), + channelId: v.id("channels"), + body: v.string(), + attachmentId: v.optional(v.id("_storage")), + }) + .index("by_channel", ["channelId"]) + .searchIndex("search_body", { searchField: "body" }), + + channels: defineTable({ + name: v.string(), + description: v.optional(v.string()), + isPrivate: v.boolean(), + }), +}); +``` + +### Validator Types + +| Validator | TypeScript Type | Notes | +| :-------------------------------- | :-------------------- | :--------------------------------------------- | +| `v.string()` | `string` | | +| `v.number()` | `number` | IEEE 754 float | +| `v.bigint()` | `bigint` | | +| `v.boolean()` | `boolean` | | +| `v.null()` | `null` | | +| `v.id("tableName")` | `Id<"tableName">` | Document reference | +| `v.array(v.string())` | `string[]` | | +| `v.object({...})` | `{...}` | Nested objects | +| `v.optional(v.string())` | `string \| undefined` | | +| `v.union(v.string(), v.number())` | `string \| number` | | +| `v.literal("active")` | `"active"` | Literal types | +| `v.bytes()` | `ArrayBuffer` | Binary data | +| `v.float64()` | `number` | Explicit 64-bit float (used in vector indexes) | +| `v.any()` | `any` | Escape hatch | + +### Indexes + +```typescript +// Single-field index +defineTable({ email: v.string() }).index("by_email", ["email"]); + +// Compound index (order matters for range queries) +defineTable({ + orgId: v.string(), + createdAt: v.number(), +}).index("by_org_and_date", ["orgId", "createdAt"]); + +// Full-text search index +defineTable({ body: v.string(), channelId: v.id("channels") }).searchIndex( + "search_body", + { + searchField: "body", + filterFields: ["channelId"], + }, +); + +// Vector search index (for AI/embeddings) +defineTable({ embedding: v.array(v.float64()), text: v.string() }).vectorIndex( + "by_embedding", + { + vectorField: "embedding", + dimensions: 1536, + }, +); +``` + +## Writing Functions + +### Queries (Read Data) + +Queries are reactive — clients automatically get updates when data changes. + +````typescript +import { query } from "./_generated/server"; +import { v } from "convex/values"; + +// Simple query — list all tasks +export const list = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db.query("tasks").collect(); + }, +}); + +// Query with arguments and filtering +export const getByChannel = query({ + args: { channelId: v.id("channels") }, + handler: async (ctx, args) => { + return await ctx.db + .query("messages") + .withIndex("by_channel", (q) => q.eq("channelId", args.channelId)) + .order("desc") + .take(50); + }, +}); + +// Query with auth check +export const getMyProfile = query({ + args: {}, + handler: async (ctx) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) return null; + + return await ctx.db + .query("users") + .withIndex("by_token", (q) => + q.eq("tokenIdentifier", identity.tokenIdentifier), + ) + .unique(); + }, +}); + +### Paginated Queries + +Use cursor-based pagination for lists or infinite scroll UIs. + +```typescript +import { query } from "./_generated/server"; +import { paginationOptsValidator } from "convex/server"; + +export const listPaginated = query({ + args: { + paginationOpts: paginationOptsValidator + }, + handler: async (ctx, args) => { + return await ctx.db + .query("messages") + .order("desc") + .paginate(args.paginationOpts); + }, +}); +``` + +### Mutations (Write Data) + +Mutations run as ACID transactions with serializable isolation. + +```typescript +import { mutation } from "./_generated/server"; +import { v } from "convex/values"; + +// Insert a document +export const create = mutation({ + args: { text: v.string(), isCompleted: v.boolean() }, + handler: async (ctx, args) => { + const taskId = await ctx.db.insert("tasks", { + text: args.text, + isCompleted: args.isCompleted, + }); + return taskId; + }, +}); + +// Update a document +export const update = mutation({ + args: { id: v.id("tasks"), isCompleted: v.boolean() }, + handler: async (ctx, args) => { + await ctx.db.patch(args.id, { isCompleted: args.isCompleted }); + }, +}); + +// Delete a document +export const remove = mutation({ + args: { id: v.id("tasks") }, + handler: async (ctx, args) => { + await ctx.db.delete(args.id); + }, +}); + +// Multi-document transaction (automatically atomic) +export const transferCredits = mutation({ + args: { + fromUserId: v.id("users"), + toUserId: v.id("users"), + amount: v.number(), + }, + handler: async (ctx, args) => { + const fromUser = await ctx.db.get(args.fromUserId); + const toUser = await ctx.db.get(args.toUserId); + if (!fromUser || !toUser) throw new Error("User not found"); + if (fromUser.credits < args.amount) throw new Error("Insufficient credits"); + + await ctx.db.patch(args.fromUserId, { + credits: fromUser.credits - args.amount, + }); + await ctx.db.patch(args.toUserId, { + credits: toUser.credits + args.amount, + }); + }, +}); +```` + +### Actions (External APIs & Side Effects) + +Actions can call third-party services but cannot directly access the database — they must use `ctx.runQuery` and `ctx.runMutation`. + +```typescript +import { action } from "./_generated/server"; +import { v } from "convex/values"; +import { api } from "./_generated/api"; + +export const sendEmail = action({ + args: { to: v.string(), subject: v.string(), body: v.string() }, + handler: async (ctx, args) => { + // Call external API + const response = await fetch("https://api.sendgrid.com/v3/mail/send", { + method: "POST", + headers: { + Authorization: `Bearer ${process.env.SENDGRID_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + personalizations: [{ to: [{ email: args.to }] }], + from: { email: "noreply@example.com" }, + subject: args.subject, + content: [{ type: "text/plain", value: args.body }], + }), + }); + + if (!response.ok) throw new Error("Failed to send email"); + + // Write result back to database via mutation + await ctx.runMutation(api.emails.recordSent, { + to: args.to, + subject: args.subject, + sentAt: Date.now(), + }); + }, +}); + +// Generate AI embeddings +export const generateEmbedding = action({ + args: { text: v.string(), documentId: v.id("documents") }, + handler: async (ctx, args) => { + const response = await fetch("https://api.openai.com/v1/embeddings", { + method: "POST", + headers: { + Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "text-embedding-3-small", + input: args.text, + }), + }); + + const { data } = await response.json(); + await ctx.runMutation(api.documents.saveEmbedding, { + documentId: args.documentId, + embedding: data[0].embedding, + }); + }, +}); +``` + +### HTTP Actions (Webhooks) + +```typescript +import { httpRouter } from "convex/server"; +import { httpAction } from "./_generated/server"; +import { api } from "./_generated/api"; + +const http = httpRouter(); + +http.route({ + path: "/webhooks/stripe", + method: "POST", + handler: httpAction(async (ctx, request) => { + const body = await request.text(); + const signature = request.headers.get("stripe-signature"); + + // Verify webhook signature here... + + const event = JSON.parse(body); + await ctx.runMutation(api.payments.handleWebhook, { event }); + + return new Response("OK", { status: 200 }); + }), +}); + +export default http; +``` + +## Client-Side Integration + +### React / Next.js + +```typescript +// app/ConvexClientProvider.tsx +"use client"; +import { ConvexProvider, ConvexReactClient } from "convex/react"; +import { ReactNode } from "react"; + +const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!); + +export function ConvexClientProvider({ children }: { children: ReactNode }) { + return {children}; +} +``` + +```typescript +// app/layout.tsx — wrap children +import { ConvexClientProvider } from "./ConvexClientProvider"; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} +``` + +```typescript +// Component using Convex hooks +"use client"; +import { useQuery, useMutation } from "convex/react"; +import { api } from "@/convex/_generated/api"; + +export function TaskList() { + // Reactive query — auto-updates when data changes + const tasks = useQuery(api.tasks.list); + const addTask = useMutation(api.tasks.create); + const toggleTask = useMutation(api.tasks.update); + + if (tasks === undefined) return

Loading...

; + + return ( +
+ {tasks.map((task) => ( +
+ + toggleTask({ id: task._id, isCompleted: !task.isCompleted }) + } + /> + {task.text} +
+ ))} + +
+ ); +} +``` + +```typescript +// Component using Paginated Queries +"use client"; +import { usePaginatedQuery } from "convex/react"; +import { api } from "@/convex/_generated/api"; + +export function MessageLog() { + const { results, status, loadMore } = usePaginatedQuery( + api.messages.listPaginated, + {}, // args + { initialNumItems: 20 } + ); + + return ( +
+ {results.map((msg) => ( +
{msg.body}
+ ))} + + {status === "LoadingFirstPage" &&

Loading...

} + + {status === "CanLoadMore" && ( + + )} +
+ ); +} +``` + +### With Auth (First-Party Convex Auth) + +Convex provides a robust, native authentication library (`@convex-dev/auth`) featuring Magic Links, Passwords, and 80+ OAuth providers without needing a third-party service. + +```typescript +// app/ConvexClientProvider.tsx +"use client"; +import { ConvexAuthProvider } from "@convex-dev/auth/react"; +import { ConvexReactClient } from "convex/react"; +import { ReactNode } from "react"; + +const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!); + +export function ConvexClientProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} +``` + +```typescript +// Client-side sign in +import { useAuthActions } from "@convex-dev/auth/react"; + +export function Login() { + const { signIn } = useAuthActions(); + return ; +} +``` + +### With Auth (Third-Party Clerk Example) + +If you prefer a hosted third-party solution like Clerk: + +```typescript +// app/ConvexClientProvider.tsx +"use client"; +import { ConvexProviderWithClerk } from "convex/react-clerk"; +import { ClerkProvider, useAuth } from "@clerk/nextjs"; +import { ConvexReactClient } from "convex/react"; + +const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!); + +export function ConvexClientProvider({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} +``` + +### With Auth (Better Auth Component) + +Convex also has a community component (`@convex-dev/better-auth`) that integrates the Better Auth library directly into the Convex backend. This is currently in **early alpha**. + +```bash +npm install better-auth @convex-dev/better-auth +npx convex env set BETTER_AUTH_SECRET your-secret-here +npx convex env set SITE_URL http://localhost:3000 +``` + +Better Auth provides email/password, social logins, two-factor authentication, and session management — all running inside Convex functions rather than an external auth server. + +### Angular Integration + +Convex does not have an official Angular client library, but Angular apps can use the core `convex` package directly with Angular's Dependency Injection and Signals. + +```typescript +// services/convex.service.ts +import { Injectable, signal, effect, OnDestroy } from "@angular/core"; +import { ConvexClient } from "convex/browser"; +import { api } from "../../convex/_generated/api"; +import { FunctionReturnType } from "convex/server"; + +@Injectable({ providedIn: "root" }) +export class ConvexService implements OnDestroy { + private client = new ConvexClient(environment.convexUrl); + + // Reactive signal — updates automatically when data changes + tasks = signal | undefined>( + undefined, + ); + + constructor() { + // Subscribe to a reactive query + this.client.onUpdate(api.tasks.list, {}, (result) => { + this.tasks.set(result); + }); + } + + async addTask(text: string) { + await this.client.mutation(api.tasks.create, { + text, + isCompleted: false, + }); + } + + ngOnDestroy() { + this.client.close(); + } +} +``` + +```typescript +// Component usage +import { Component, inject } from "@angular/core"; +import { ConvexService } from "./services/convex.service"; + +@Component({ + selector: "app-task-list", + template: ` + @if (convex.tasks(); as tasks) { + @for (task of tasks; track task._id) { +
{{ task.text }}
+ } + } @else { +

Loading...

+ } + + `, +}) +export class TaskListComponent { + convex = inject(ConvexService); +} +``` + +> **Note:** The community library `@robmanganelly/ngx-convex` provides a more Angular-native experience with React-like hooks adapted for Angular DI and Signals. + +## Scheduling & Cron Jobs + +### One-off Scheduled Functions + +```typescript +import { mutation } from "./_generated/server"; +import { api } from "./_generated/api"; + +export const sendReminder = mutation({ + args: { userId: v.id("users"), message: v.string(), delayMs: v.number() }, + handler: async (ctx, args) => { + await ctx.scheduler.runAfter(args.delayMs, api.notifications.send, { + userId: args.userId, + message: args.message, + }); + }, +}); +``` + +### Cron Jobs + +```typescript +// convex/crons.ts +import { cronJobs } from "convex/server"; +import { api } from "./_generated/api"; + +const crons = cronJobs(); + +crons.interval("clear old logs", { hours: 24 }, api.logs.clearOld); + +crons.cron( + "weekly digest", + "0 9 * * 1", // Every Monday at 9 AM + api.emails.sendWeeklyDigest, +); + +export default crons; +``` + +## File Storage + +```typescript +// Generate an upload URL (mutation) +export const generateUploadUrl = mutation({ + args: {}, + handler: async (ctx) => { + return await ctx.storage.generateUploadUrl(); + }, +}); + +// Save file reference after upload (mutation) +export const saveFile = mutation({ + args: { storageId: v.id("_storage"), name: v.string() }, + handler: async (ctx, args) => { + await ctx.db.insert("files", { + storageId: args.storageId, + name: args.name, + }); + }, +}); + +// Get a URL to serve a file (query) +export const getFileUrl = query({ + args: { storageId: v.id("_storage") }, + handler: async (ctx, args) => { + return await ctx.storage.getUrl(args.storageId); + }, +}); +``` + +## Environment Variables + +```bash +# Set environment variables for your deployment +npx convex env set OPENAI_API_KEY sk-... +npx convex env set SENDGRID_API_KEY SG... + +# List current env vars +npx convex env list + +# Remove an env var +npx convex env unset OPENAI_API_KEY +``` + +Access in actions (NOT in queries or mutations): + +```typescript +// Only available in actions +const apiKey = process.env.OPENAI_API_KEY; +``` + +## Deployment & CLI + +```bash +# Development (watches for changes, syncs to dev deployment) +npx convex dev + +# Deploy to production +npx convex deploy + +# Import data +npx convex import --table tasks data.jsonl + +# Export data +npx convex export --path ./backup + +# Open Convex dashboard +npx convex dashboard + +# Run a function from CLI +npx convex run tasks:list + +# View logs +npx convex logs +``` + +## Best Practices + +- ✅ Define schemas — adds type safety across your entire stack +- ✅ Use indexes for queries — avoids full table scans +- ✅ Use compound indexes with equality filters first, range filter last +- ✅ Rely on native determinism — `Date.now()` and `Math.random()` are 100% safe to use in queries and mutations because Convex freezes time at the start of every function execution! +- ✅ Use `v.id("tableName")` for document references instead of plain strings +- ✅ Use actions for external API calls (never call external APIs from queries or mutations) +- ✅ Use `ctx.runQuery` / `ctx.runMutation` from actions — never access `ctx.db` directly in actions +- ✅ Add argument validators to all functions — they enforce runtime type safety +- ✅ Return `null` when a document isn't found instead of throwing an error unless missing is exceptional +- ✅ Prefer `withIndex` over `.filter()` for query performance + +## Anti-Patterns to Avoid + +1. **❌ External API calls in queries/mutations**: Only actions can call external services. Queries and mutations run in the Convex transaction engine. +2. **❌ Doing slow CPU-bound work in mutations**: Mutations block database commits; offload heavy processing to actions. +3. **❌ Using `.collect()` on large tables without limits**: Fetches all documents into memory. Use `.take(N)` or `.paginate()`. +4. **❌ Skipping schema definition**: Without a schema you lose end-to-end type safety, the main Convex advantage. +5. **❌ Using `.filter()` instead of indexes**: `.filter()` does a full table scan. Define an index and use `.withIndex()`. +6. **❌ Storing large blobs in documents**: Use Convex file storage (`_storage`) for files; keep documents lean. +7. **❌ Circular `runQuery`/`runMutation` chains**: Actions calling mutations that schedule actions can create infinite loops. + +## Common Pitfalls + +- **Problem:** "Query returns `undefined` on first render" + **Solution:** This is expected — Convex queries are async. Check for `undefined` before rendering (this means loading, not empty). + +- **Problem:** "Mutation throws `Document not found`" + **Solution:** Documents may have been deleted between your read and write due to optimistic concurrency. Re-read inside the mutation. + +- **Problem:** "`process.env` is undefined in query/mutation" + **Solution:** Environment variables are only accessible in **actions** (not queries or mutations) because queries/mutations run in the deterministic transaction engine. + +- **Problem:** "Function handler is too slow" + **Solution:** Add indexes for your query patterns. Use `withIndex()` instead of `.filter()`. For complex operations, break into smaller mutations. + +- **Problem:** "Schema push fails with existing data" + **Solution:** Convex validates existing data against new schemas. Either migrate existing documents first, or use `v.optional()` for new fields. + +## Limitations + +- Queries and mutations cannot call external HTTP APIs (use actions instead) +- No raw SQL — you work with the Convex query builder API +- Environment variables only available in actions, not in queries or mutations +- Document size limit of 1MB +- Maximum function execution time limits apply +- No server-side rendering of Convex data without specific SSR patterns (use preloading) +- Schemas are enforced at write-time; changing schemas requires data migration for existing documents + +## Related Skills + +- `@firebase` — Alternative BaaS with Firestore (compare: Convex is TypeScript-first with ACID transactions) +- `@supabase-automation` — Alternative with PostgreSQL backend (compare: Convex is document-relational with built-in reactivity) +- `@prisma-expert` — ORM for traditional databases (Convex replaces both ORM and database) +- `@react-patterns` — Frontend patterns that pair well with Convex React hooks +- `@nextjs-app-router` — Next.js App Router integration patterns +- `@authentication-oauth` — Auth patterns (Convex supports Clerk, Auth0, Convex Auth) +- `@stripe` — Payment integration via Convex actions and HTTP webhooks + +## Resources + +- [Official Docs](https://docs.convex.dev) +- [Convex Stack (Blog)](https://stack.convex.dev) +- [GitHub](https://github.com/get-convex/convex-backend) +- [Discord Community](https://convex.dev/community) +- [Convex Chef (AI Starter)](https://chef.convex.dev)