AgentStack
SKILL verified MIT Self-run

Convex Patterns

skill-jasondocton-rad-claude-convex-patterns · by JasonDocton

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.

No reviews yet
0 installs
4 views
0.0% view→install

Install

$ agentstack add skill-jasondocton-rad-claude-convex-patterns

✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures

What it can access

  • Network access No
  • Filesystem access No
  • Shell / process execution No
  • Environment & secrets No
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

Are you the author of Convex Patterns? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

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.

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

Reviews

No reviews yet — be the first.

Versions

  • v0.1.0 Imported from the upstream source.