# Convex Patterns

> Convex backend patterns and type safety. Applies to schema design, validators, mutations, queries, actions, auth checks, indexes, HTTP webhooks, rate limiting, type derivation. Also applies to scheduled tasks, cron jobs, realtime subscriptions, live queries, server action integration, business logic placement, and webhook handlers.

- **Type:** Skill
- **Install:** `agentstack add skill-jasondocton-rad-claude-convex-patterns`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [JasonDocton](https://agentstack.voostack.com/s/jasondocton)
- **Installs:** 0
- **Category:** [Agent Skills](https://agentstack.voostack.com/c/agent-skills)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [JasonDocton](https://github.com/JasonDocton)
- **Source:** https://github.com/JasonDocton/rad-claude/tree/main/.claude/skills/convex-patterns

## Install

```sh
agentstack add skill-jasondocton-rad-claude-convex-patterns
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# Convex Patterns

Schema is single source of truth. All types derive from it.

## Quick Reference

- Files: `snake_case.ts` (kebab-case fails deploy)
- Fields: `snake_case`
- Types: derive from schema, never duplicate
- Validators: derive with `.fields.fieldName`, never manual
- Partials: `satisfies Partial`, never `Pick<>` or manual interface
- Auth: check every function
- Queries: `.withIndex()` not `.filter()`

## Type Derivation

`Doc` - includes `_id`, `_creationTime` - use when reading from DB
`Infer` - excludes system fields - use for mutation args
`WithoutSystemFields>` - use when spreading docs for insert

## Schema Exports

```
// schema.ts - define tables and validators
export const sessionsValidator = schema.tables.sessions.validator
export default schema

// types.ts - derive ALL types from schema
export const platformProviderValidator = platformTokensValidator.fields.provider
export type PlatformProvider = Infer
export type FraudFlagType = Infer
```

## Schema-Driven Mutations

Args from schema:
`args: tableValidator.fields`

Reusable field validators:
`export const providerValidator = tableValidator.fields.provider`

Upsert pattern:

```
if (existing) {
  const { user_id: _, provider: __, ...updateFields } = args
  await ctx.db.patch(existing._id, updateFields)
  return existing._id
}
return await ctx.db.insert("table", args)
```

Explicit returns:
`returns: v.id("table"), handler: async (ctx, args): Promise> => { ... }`

## Schema-Validated Returns

```
// ✅ validates fields exist in schema, infers return type
return { type, severity, description } satisfies Partial

// ❌ manual types duplicate schema
type FlagData = Pick
interface FlagData { type: FraudFlagType; severity: FraudFlagSeverity }
```

Usage at call site - spread validated partial into full record:

```
const flagData = buildFlagData(result)
if (flagData) await ctx.db.insert("fraudFlags", { ...flagData, user_id, status: "pending" })
```

## File Locations

`schema.ts`: tables, exported validators. Never business logic.
`types.ts`: derived types via `Infer<>`. Never manual definitions.
`*.ts`: import from types, use `Doc<>`. Never local types mirroring schema.

Convex type inference can handle subfolders importing from root. !!It can't handle subfolders importing from other subfolders when custom builders are involved!!

## Security

All functions PUBLIC by default. Every mutation/query must:

1. Validate args with `v.object({...})`
2. Check auth: `const userId = await getAuthUserId(ctx); if (!userId) throw new Error('Unauthorized')`
3. Verify ownership before operations
4. Validate beyond schema (ranges, business rules)

## Error Handling

Review if src/utils/errors.ts or src/utils/logger.ts should be used!
Expected failures: `throw new ConvexError({ code: "ERROR_CODE", message: "User-safe message" })`
Observability: `logger.error("Context for debugging", { status, provider })`
Never in ConvexError.data: tokens, IDs, PII

## Auth Pattern

```
export const updateOrder = mutation({
  args: { order_id: v.id("orders"), status: statusValidator },
  handler: async (ctx, { order_id, status }) => {
    const userId = await getAuthUserId(ctx)
    if (!userId) throw new Error('Unauthorized')
    const order = await ctx.db.get(order_id)
    if (!order) throw new Error('Not found')
    if (order.user_id !== userId) throw new Error('Access denied')
    await ctx.db.patch(order_id, { status })
  }
})
```

## Validator Reuse

```
// ✅ From schema
args: sessionFieldsExport
args: { id: v.id("sessions"), ...schema.tables.sessions.validator.fields }

// ❌ Copy-paste
args: { name: v.string(), status: v.union(...) }
```

## Index Usage

```
// ✅ O(log n)
await ctx.db.query('users').withIndex('by_email', q => q.eq('email', email)).first()

// ❌ O(n) scan
await ctx.db.query('users').filter(q => q.eq(q.field('email'), email)).first()
```

## Helper Composition

Atomic → Composite → Relationship → Query. Each layer composes the one below.

```
// Atomic
async function getPlatformToken(db, userId, provider) {
  return await db.query("platformTokens").withIndex("by_user_provider", q => q.eq("user_id", userId).eq("provider", provider)).first()
}

// Composite
async function getAllPlatformTokens(db, userId) {
  const [twitch, kick] = await Promise.all([getPlatformToken(db, userId, "twitch"), getPlatformToken(db, userId, "kick")])
  return { twitch, kick }
}

// Query - business logic only
export const getEntryDetails = query({
  args: { entry_id: v.id("entries") },
  handler: async (ctx, { entry_id }) => {
    const details = await getEntryWithDetails(ctx, entry_id)
    if (!details) return null
    return { ...details, trustScore: computeTrustScore(details) }
  },
})
```

Generic constraints: `` not `extends string`. Return `null` for optional, throw for required. Use `readonly` on return types. Only create for patterns used 2+ times.

## Idempotency

Check-before-insert:

```
const existing = await ctx.db.query('entries').withIndex('by_user_session', q => q.eq('user_id', userId).eq('session_id', sessionId)).first()
if (existing) return existing._id
return await ctx.db.insert('entries', { user_id: userId, session_id: sessionId })
```

Idempotency keys: query by key before processing, store with key after.
Webhook dedup: index on `event_id`, check before processing, always return 200.

## HTTP Actions

Use `CONVEX_SITE_URL` for webhooks (not `CONVEX_URL`).

```
http.route({
  path: "/stripe/webhook",
  method: "POST",
  handler: httpAction(async (ctx, request) => {
    const signature = request.headers.get("stripe-signature")
    const body = await request.text()
    if (!verifyStripeSignature(body, signature, WEBHOOK_SECRET)) return new Response("Invalid signature", { status: 401 })
    const event = JSON.parse(body)
    await ctx.runMutation(api.webhooks.process, { event_id: event.id, event_type: event.type, data: event.data.object })
    return new Response("OK", { status: 200 })
  })
})
```

## Rate Limiting

```
const limiter = new RateLimiter(components.rateLimiter, { createOrder: { kind: 'token bucket', rate: 10, period: 60_000 } })
await limiter.limit(ctx, 'createOrder', { key: userId })
```

Common: Registration 3/IP/24h, Login 10/email/5min, Entries 1/user/session.

## Anti-Patterns

Type duplication: `interface UserData { email?: string }` → `import type { User } from "./types"`
Manual mapping: `return { _id: entry._id, ... }` → `return { ...entry, email: redact(entry.email) }`
Loose enums: `status: v.string()` → `status: v.union(v.literal("draft"), v.literal("active"))`
Manual validators: `v.union(v.literal("twitch"), v.literal("kick"))` → `tableValidator.fields.provider`
Manual partials: `Pick` or `interface FlagData {}` → `satisfies Partial`

## Troubleshooting Protocol

If "circular dependency" occurs, verify no subfolder is importing from another subfolder.

## Critical Facts

- `_creationTime` auto-appended to all indexes — don't add custom `createdAt`
- Index fields must match query order, most selective first (multi-tenant: `user_id` first)
- `undefined` = missing field, `null` = explicit empty — use `v.optional()` vs `v.union(type, v.null())`
- No DB-level unique constraints — that's why check-before-insert pattern exists
- `.collect()` bandwidth includes filtered-out docs — filter at index level
- 1MB document limit — watch unbounded arrays
- Don't load data to count it — paginate with `{ numItems: 50 }`, show "50+" if `!page.isDone`

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [JasonDocton](https://github.com/JasonDocton)
- **Source:** [JasonDocton/rad-claude](https://github.com/JasonDocton/rad-claude)
- **License:** MIT

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** no
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-jasondocton-rad-claude-convex-patterns
- Seller: https://agentstack.voostack.com/s/jasondocton
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
