AgentSkillsCN

Convex Feature

Convex 功能

SKILL.md

Convex Feature Development Skill

Use this skill when adding new Convex queries, mutations, actions, or database tables for Tech for Iran.

File Organization

code
src/convex/
├── _helpers/              # Shared utilities
│   ├── errors.ts         # Custom error classes
│   └── server.ts         # Auth wrapper functions
├── domain-name/          # Domain-specific functions
│   ├── query.ts          # Read-only queries
│   ├── mutate.ts         # Database mutations
│   └── action.ts         # External API calls
├── aggregates.ts         # Counter definitions
├── triggers.ts           # Aggregate triggers
├── ratelimits.ts         # Rate limiting
├── http.ts               # Webhook handlers
├── migrations.ts         # Data migrations
├── convex.config.ts      # Component registration
└── schema.ts             # Database schema

Domain Model

Tables

  • signatures: People who signed the letter (name, title, company, xUsername, because, commitment, pinned, upvoteCount, referredBy)
  • upvotes: Upvote records (signatureId, voterId) with unique constraint on pair

Key Concepts

  • xUsername: X (Twitter) username, used for deduplication (one signature per X account)
  • voterId: Anonymous cookie-based ID (anon_<uuid>) for upvoting
  • pinned: Featured signatures that always appear first
  • referredBy: Signature ID of who referred them (viral tracking)

Function Wrapper Selection

Public Queries (No Auth Required)

typescript
// List signatures for the wall - no auth needed
export const list = query({
  args: {
    sort: v.union(v.literal("upvotes"), v.literal("recent")),
    cursor: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    // Pinned first, then sorted
  }
})

// Get single signature by ID - no auth needed
export const get = query({
  args: { signatureId: v.id("signatures") },
  handler: async (ctx, args) => {
    return ctx.db.get(args.signatureId)
  }
})

// Count total signatures
export const count = query({
  args: {},
  handler: async (ctx) => {
    return await totalSignatures.count(ctx, {})
  }
})

Public Mutations (No Auth Required)

typescript
// Create signature - no auth, uses xUsername for deduplication
export const create = mutation({
  args: {
    name: v.string(),
    title: v.string(),
    company: v.string(),
    xUsername: v.string(),
    because: v.string(),
    commitment: v.string(),
    referredBy: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    // Check for duplicate xUsername
    const existing = await ctx.db
      .query("signatures")
      .withIndex("by_xUsername", (q) => q.eq("xUsername", args.xUsername))
      .first()

    if (existing) {
      return { signatureId: null, error: "This X username has already signed." }
    }

    const signatureId = await ctx.db.insert("signatures", {
      ...args,
      pinned: false,
      upvoteCount: 0,
    })

    return { signatureId, success: "You've signed the letter!" }
  }
})

Anonymous Upvoting

typescript
// Upvote - anyone can upvote using anonymous voterId
export const upvote = mutation({
  args: {
    signatureId: v.id("signatures"),
    voterId: v.string(), // Cookie-based anon_<uuid>
  },
  handler: async (ctx, args) => {
    // Check for duplicate upvote
    const existing = await ctx.db
      .query("upvotes")
      .withIndex("by_signatureId_voterId", (q) =>
        q.eq("signatureId", args.signatureId).eq("voterId", args.voterId)
      )
      .first()

    if (existing) {
      return { error: "You've already upvoted this commitment." }
    }

    // Insert upvote and increment count
    await ctx.db.insert("upvotes", {
      signatureId: args.signatureId,
      voterId: args.voterId,
    })

    const signature = await ctx.db.get(args.signatureId)
    if (signature) {
      await ctx.db.patch(args.signatureId, {
        upvoteCount: signature.upvoteCount + 1,
      })
    }

    return { success: "Upvoted!" }
  }
})

Internal Functions

typescript
export const seedPinned = internalMutation({
  args: { signatures: v.array(v.object({ ... })) },
  handler: async (ctx, args) => {
    // Seed featured signatures - called from backend only
  }
})

Return Type Pattern (Discriminated Unions)

typescript
type CreateResult =
  | { signatureId: Id<"signatures">; success: string }
  | { signatureId: null; error: string }

export const create = mutation({
  handler: async (ctx, args): Promise<CreateResult> => {
    if (duplicateXUsername) {
      return { signatureId: null, error: "This X username has already signed." }
    }
    return { signatureId, success: "You've signed the letter!" }
  }
})

Validation with Zod

typescript
import * as z from "zod"
import { errorMessage } from "@/convex/_helpers/errors"
import { CreateSignature } from "@/schemas/signature"

export const create = mutation({
  args: {
    name: v.string(),
    title: v.string(),
    company: v.string(),
    xUsername: v.string(),
    because: v.string(),
    commitment: v.string(),
    referredBy: v.optional(v.string()),
  },
  handler: async (ctx, args): Promise<CreateResult> => {
    // Server-side validation with shared schema
    const { data, success, error } = CreateSignature.safeParse(args)
    if (!success) {
      return { signatureId: null, error: errorMessage(error) }
    }
    // Use validated data
  }
})

Error Handling

typescript
import { NotFoundError, ConflictError, BadRequestError } from "@/convex/_helpers/errors"
import { errorMessage } from "@/convex/_helpers/errors"

// Throw custom errors
const signature = await ctx.db.get(signatureId)
if (!signature) throw new NotFoundError("signatures/query:get")

// Extract error messages
try {
  await someOperation()
  return { success: "Done!" }
} catch (error) {
  return { error: errorMessage(error) }
}

Query Patterns

Paginated List with Sort

typescript
export const list = query({
  args: {
    sort: v.union(v.literal("upvotes"), v.literal("recent")),
    paginationOpts: paginationOptsValidator,
  },
  handler: async (ctx, args) => {
    // Always show pinned first
    const pinned = await ctx.db
      .query("signatures")
      .withIndex("by_pinned", (q) => q.eq("pinned", true))
      .collect()

    // Then paginate the rest
    const index = args.sort === "upvotes" ? "by_upvoteCount" : "by_createdAt"
    const results = await ctx.db
      .query("signatures")
      .withIndex(index)
      .order("desc")
      .paginate(args.paginationOpts)

    return {
      ...results,
      page: [...pinned, ...results.page.filter(s => !s.pinned)],
    }
  }
})

Referral Count

typescript
export const getReferralCount = query({
  args: { signatureId: v.id("signatures") },
  handler: async (ctx, args) => {
    const referrals = await ctx.db
      .query("signatures")
      .withIndex("by_referredBy", (q) => q.eq("referredBy", args.signatureId))
      .collect()
    return referrals.length
  }
})

Advanced Patterns

Aggregates

Define counters in aggregates.ts:

typescript
import { TableAggregate } from "@convex-dev/aggregate"

// Count total signatures
export const totalSignatures = new TableAggregate<{
  Key: [number]
  DataModel: DataModel
  TableName: "signatures"
}>(components.totalSignatures, {
  sortKey: (doc) => [doc._creationTime],
})

// Count upvotes per signature
export const signatureUpvotes = new TableAggregate<{
  Key: [Id<"signatures">, number]
  DataModel: DataModel
  TableName: "upvotes"
}>(components.signatureUpvotes, {
  sortKey: (doc) => [doc.signatureId, doc._creationTime],
})

Use in queries:

typescript
const totalCount = await totalSignatures.count(ctx, {})

const upvoteCount = await signatureUpvotes.count(ctx, {
  bounds: { prefix: [signatureId] }
})

Triggers

Auto-update aggregates in triggers.ts:

typescript
const triggers = new Triggers<DataModel>()

// Track signature count
triggers.register("signatures", totalSignatures.trigger())

// Track upvotes
triggers.register("upvotes", signatureUpvotes.trigger())

// Cascade delete upvotes when signature deleted
triggers.register("signatures", async (ctx, change) => {
  if (change.operation === "delete") {
    const upvotes = await ctx.db
      .query("upvotes")
      .withIndex("by_signatureId", (q) => q.eq("signatureId", change.id))
      .collect()

    await pmap(upvotes, async ({ _id }) => ctx.db.delete(_id))
  }
})

HTTP Webhooks

typescript
// Clerk user verification webhook
http.route({
  path: "/webhooks/clerk",
  method: "POST",
  handler: httpAction(async (ctx, request) => {
    const { success, data } = ClerkWebhookSchema.safeParse(await request.json())
    if (!success) {
      console.warn("unexpected webhook payload")
      return new Response(null, { status: 201 }) // Always ack
    }

    // Handle user event
    await ctx.runMutation(internal.signatures.mutate.handleUserEvent, data)
    return new Response(null, { status: 201 })
  }),
})

Rate Limiting

typescript
import { ratelimits } from "@/convex/ratelimits"

// Limit upvote attempts per voter
const { ok, retryAfter } = await ratelimits.check(ctx, ...upvoteRateLimit(voterId))
if (!ok) {
  return { error: `Too many attempts, try again in ${Math.ceil(retryAfter / 1000)}s` }
}

Component Configuration

Register all components in convex.config.ts:

typescript
import aggregate from "@convex-dev/aggregate/convex.config"

const app = defineApp()
app.use(aggregate, { name: "totalSignatures" })
app.use(aggregate, { name: "signatureUpvotes" })
export default app

Migrations

typescript
export const migrations = new Migrations<DataModel>(components.migrations)

export const repairAggregates = migrations.define({
  table: "signatures",
  migrateOne: async (ctx, doc) => {
    await totalSignatures.insertIfDoesNotExist(ctx, doc)
  },
})

Schema Example

typescript
export default defineSchema({
  signatures: defineTable({
    name: v.string(),
    title: v.string(),
    company: v.string(),
    xUsername: v.string(),
    because: v.string(),
    commitment: v.string(),
    pinned: v.boolean(),
    upvoteCount: v.number(),
    referredBy: v.optional(v.string()),
  })
    .index("by_xUsername", ["xUsername"])
    .index("by_pinned", ["pinned"])
    .index("by_pinned_upvoteCount", ["pinned", "upvoteCount"])
    .index("by_referredBy", ["referredBy"]),

  upvotes: defineTable({
    signatureId: v.id("signatures"),
    voterId: v.string(),
  })
    .index("by_signatureId_voterId", ["signatureId", "voterId"])
    .index("by_voterId_signatureId", ["voterId", "signatureId"]),
})

Checklist

  • Use discriminated union return types
  • Validate with Zod safeParse() using shared schema
  • Check xUsername for deduplication on create
  • Check for duplicate upvotes before inserting
  • Always show pinned signatures first in lists
  • Track referredBy for viral attribution
  • Use appropriate indexes for queries
  • Register aggregates in convex.config.ts
  • Add triggers for automatic counting
  • Rate limit sensitive operations (upvoting)