skills/atemndobs/nebula-rfp/convex-patterns

convex-patterns

SKILL.md

Convex Patterns Skill

Overview

This skill provides patterns and best practices for implementing Convex backend functions in the RFP Discovery platform.

Schema Design

Complete Schema

// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  // Users (synced from Clerk)
  users: defineTable({
    clerkId: v.string(),
    name: v.string(),
    email: v.string(),
    imageUrl: v.optional(v.string()),
    role: v.string(), // "admin" | "user" | "viewer"
    createdAt: v.number(),
    updatedAt: v.number(),
  })
    .index("by_clerk_id", ["clerkId"])
    .index("by_email", ["email"]),

  // RFP Opportunities
  rfps: defineTable({
    externalId: v.string(),
    source: v.string(),
    title: v.string(),
    description: v.string(),
    summary: v.optional(v.string()),
    location: v.string(),
    category: v.string(),
    naicsCode: v.optional(v.string()),
    setAside: v.optional(v.string()),
    postedDate: v.number(),
    expiryDate: v.number(),
    url: v.string(),
    eligibilityFlags: v.optional(v.array(v.string())),
    rawData: v.optional(v.any()),
    ingestedAt: v.number(),
    updatedAt: v.number(),
  })
    .index("by_external_id", ["externalId", "source"])
    .index("by_source", ["source"])
    .index("by_expiry", ["expiryDate"])
    .searchIndex("search_title", {
      searchField: "title",
      filterFields: ["source", "category"],
    }),

  // Evaluations
  evaluations: defineTable({
    rfpId: v.id("rfps"),
    userId: v.string(),
    evaluationType: v.string(),
    score: v.number(),
    isFit: v.boolean(),
    criteriaResults: v.array(
      v.object({
        criterionId: v.string(),
        criterionName: v.string(),
        weight: v.number(),
        met: v.boolean(),
        score: v.number(),
        matchedKeywords: v.array(v.string()),
        details: v.string(),
      })
    ),
    eligibility: v.object({
      eligible: v.boolean(),
      status: v.string(),
      disqualifiers: v.array(v.string()),
    }),
    reasoning: v.optional(v.string()),
    evaluatedAt: v.number(),
  })
    .index("by_rfp", ["rfpId"])
    .index("by_user", ["userId"])
    .index("by_score", ["score"]),

  // Pursuits
  pursuits: defineTable({
    rfpId: v.id("rfps"),
    userId: v.string(),
    status: v.string(),
    decision: v.optional(v.string()),
    decisionBy: v.optional(v.string()),
    decisionAt: v.optional(v.number()),
    brief: v.optional(v.string()),
    complianceMatrix: v.optional(v.string()),
    notes: v.optional(v.string()),
    teamMembers: v.optional(v.array(v.string())),
    createdAt: v.number(),
    updatedAt: v.number(),
  })
    .index("by_rfp", ["rfpId"])
    .index("by_user", ["userId"])
    .index("by_status", ["status"]),

  // Criteria Configuration
  criteria: defineTable({
    name: v.string(),
    displayName: v.string(),
    weight: v.number(),
    enabled: v.boolean(),
    keywords: v.array(
      v.object({
        value: v.string(),
        enabled: v.boolean(),
      })
    ),
    minMatches: v.number(),
    systemInstruction: v.optional(v.string()),
    order: v.number(),
  }).index("by_order", ["order"]),

  // Ingestion Logs
  ingestionLogs: defineTable({
    source: v.string(),
    status: v.string(),
    recordsProcessed: v.number(),
    recordsInserted: v.number(),
    recordsUpdated: v.number(),
    errors: v.optional(v.array(v.string())),
    startedAt: v.number(),
    completedAt: v.optional(v.number()),
  }).index("by_source", ["source"]),
});

Query Patterns

Basic Query with Pagination

// ✅ Good: Uses limit and proper typing
export const list = query({
  args: {
    limit: v.optional(v.number()),
    cursor: v.optional(v.id("rfps")),
  },
  handler: async (ctx, args) => {
    const limit = args.limit ?? 50;

    let q = ctx.db.query("rfps").order("desc");

    if (args.cursor) {
      const cursorDoc = await ctx.db.get(args.cursor);
      if (cursorDoc) {
        q = q.filter((q) =>
          q.lt(q.field("_creationTime"), cursorDoc._creationTime)
        );
      }
    }

    const items = await q.take(limit + 1);
    const hasMore = items.length > limit;

    return {
      items: items.slice(0, limit),
      nextCursor: hasMore ? items[limit - 1]._id : null,
    };
  },
});

// ❌ Bad: Collects all without limit
export const listAll = query({
  handler: async (ctx) => {
    return await ctx.db.query("rfps").collect(); // Don't do this!
  },
});

Query with Index

// ✅ Good: Uses index for efficient filtering
export const listBySource = query({
  args: { source: v.string() },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("rfps")
      .withIndex("by_source", (q) => q.eq("source", args.source))
      .order("desc")
      .take(50);
  },
});

// ❌ Bad: Full table scan with filter
export const listBySourceBad = query({
  args: { source: v.string() },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("rfps")
      .filter((q) => q.eq(q.field("source"), args.source))
      .collect();
  },
});

Full-Text Search

export const search = query({
  args: {
    searchTerm: v.string(),
    source: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    let q = ctx.db
      .query("rfps")
      .withSearchIndex("search_title", (q) => {
        let sq = q.search("title", args.searchTerm);
        if (args.source) {
          sq = sq.eq("source", args.source);
        }
        return sq;
      });

    return await q.take(20);
  },
});

Mutation Patterns

Authenticated Mutation

// ✅ Good: Checks auth before any operation
export const create = mutation({
  args: {
    rfpId: v.id("rfps"),
    status: v.string(),
  },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) {
      throw new Error("Not authenticated");
    }

    return await ctx.db.insert("pursuits", {
      rfpId: args.rfpId,
      userId: identity.subject,
      status: args.status,
      createdAt: Date.now(),
      updatedAt: Date.now(),
    });
  },
});

Upsert Pattern

export const upsert = mutation({
  args: {
    externalId: v.string(),
    source: v.string(),
    title: v.string(),
    // ... other fields
  },
  handler: async (ctx, args) => {
    const existing = await ctx.db
      .query("rfps")
      .withIndex("by_external_id", (q) =>
        q.eq("externalId", args.externalId).eq("source", args.source)
      )
      .first();

    const now = Date.now();

    if (existing) {
      await ctx.db.patch(existing._id, {
        ...args,
        updatedAt: now,
      });
      return { id: existing._id, action: "updated" as const };
    }

    const id = await ctx.db.insert("rfps", {
      ...args,
      ingestedAt: now,
      updatedAt: now,
    });
    return { id, action: "inserted" as const };
  },
});

Transactional Updates

export const updatePursuitWithHistory = mutation({
  args: {
    pursuitId: v.id("pursuits"),
    status: v.string(),
    notes: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("Not authenticated");

    const pursuit = await ctx.db.get(args.pursuitId);
    if (!pursuit) throw new Error("Pursuit not found");

    // Update pursuit
    await ctx.db.patch(args.pursuitId, {
      status: args.status,
      notes: args.notes,
      updatedAt: Date.now(),
    });

    // Log activity (both happen in same transaction)
    await ctx.db.insert("activityLog", {
      userId: identity.subject,
      action: "status_change",
      entityType: "pursuit",
      entityId: args.pursuitId,
      details: {
        from: pursuit.status,
        to: args.status,
      },
      timestamp: Date.now(),
    });

    return { success: true };
  },
});

Action Patterns

Authenticated Action (Client-Callable)

Actions called from the client MUST verify authentication before processing:

// ✅ Good: Auth check for client-callable action
export const uploadData = action({
  args: { data: v.string() },
  handler: async (ctx, args) => {
    // CRITICAL: Always verify auth for client-callable actions
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) {
      throw new Error("Not authenticated");
    }

    // Delegate to internal action for processing
    return await ctx.runAction(internal.ingestion.processData, args);
  },
});

// ✅ Good: Use internalAction for background processing
export const processData = internalAction({
  args: { data: v.string() },
  handler: async (ctx, args) => {
    // Internal actions are only callable from other Convex functions
    // No auth check needed here - the calling action handles it
    // ... process data
  },
});

Why separate action vs internalAction?

  • action - Callable from client, needs auth check
  • internalAction - Only callable from server, can skip auth check
  • Pattern: Client calls action (with auth) → action calls internalAction (for processing)

External API Call

// convex/actions/samGov.ts
import { action } from "../_generated/server";
import { v } from "convex/values";
import { internal } from "../_generated/api";

export const fetchOpportunities = action({
  args: { daysBack: v.number() },
  handler: async (ctx, args) => {
    const apiKey = process.env.SAM_GOV_API_KEY;
    if (!apiKey) {
      throw new Error("SAM_GOV_API_KEY not configured");
    }

    const fromDate = new Date();
    fromDate.setDate(fromDate.getDate() - args.daysBack);

    const response = await fetch(
      `https://api.sam.gov/opportunities/v2/search?` +
        `api_key=${apiKey}&postedFrom=${fromDate.toISOString().split("T")[0]}`,
      {
        headers: { Accept: "application/json" },
      }
    );

    if (!response.ok) {
      throw new Error(`API error: ${response.status}`);
    }

    const data = await response.json();

    // Process in batches to avoid timeout
    const BATCH_SIZE = 10;
    const opportunities = data.opportunitiesData ?? [];

    for (let i = 0; i < opportunities.length; i += BATCH_SIZE) {
      const batch = opportunities.slice(i, i + BATCH_SIZE);

      await Promise.all(
        batch.map((opp: any) =>
          ctx.runMutation(internal.rfps.upsert, {
            externalId: opp.noticeId,
            source: "sam.gov",
            title: opp.title,
            // ... map other fields
          })
        )
      );
    }

    return { processed: opportunities.length };
  },
});

React Integration

useQuery with Loading State

import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";

function RfpList() {
  const rfps = useQuery(api.rfps.list, { limit: 50 });

  if (rfps === undefined) {
    return <LoadingSpinner />;
  }

  if (rfps.items.length === 0) {
    return <EmptyState message="No RFPs found" />;
  }

  return (
    <div className="grid gap-4">
      {rfps.items.map((rfp) => (
        <RfpCard key={rfp._id} rfp={rfp} />
      ))}
    </div>
  );
}

useMutation with Optimistic Updates

import { useMutation, useQuery } from "convex/react";
import { api } from "../convex/_generated/api";

function PursuitActions({ pursuitId }: { pursuitId: Id<"pursuits"> }) {
  const updateStatus = useMutation(api.pursuits.updateStatus);
  const [isPending, setIsPending] = useState(false);

  const handleStatusChange = async (newStatus: string) => {
    setIsPending(true);
    try {
      await updateStatus({ pursuitId, status: newStatus });
    } finally {
      setIsPending(false);
    }
  };

  return (
    <select
      disabled={isPending}
      onChange={(e) => handleStatusChange(e.target.value)}
    >
      <option value="new">New</option>
      <option value="triage">Triage</option>
      <option value="bid">Bid</option>
      <option value="no-bid">No Bid</option>
    </select>
  );
}

Common Patterns

Auth Helper

// convex/lib/auth.ts
import { QueryCtx, MutationCtx } from "./_generated/server";

export async function requireAuth(ctx: QueryCtx | MutationCtx) {
  const identity = await ctx.auth.getUserIdentity();
  if (!identity) {
    throw new Error("Not authenticated");
  }
  return identity;
}

export async function requireAdmin(ctx: QueryCtx | MutationCtx) {
  const identity = await requireAuth(ctx);

  const user = await ctx.db
    .query("users")
    .withIndex("by_clerk_id", (q) => q.eq("clerkId", identity.subject))
    .first();

  if (!user || user.role !== "admin") {
    throw new Error("Admin access required");
  }

  return { identity, user };
}

Scheduled Jobs

// convex/crons.ts
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";

const crons = cronJobs();

// Run every 6 hours
crons.interval(
  "ingest-sam-gov",
  { hours: 6 },
  internal.ingestion.runSamGovIngestion
);

// Run daily at 6 AM UTC
crons.daily(
  "cleanup-expired",
  { hourUTC: 6, minuteUTC: 0 },
  internal.maintenance.archiveExpiredRfps
);

export default crons;

Bandwidth Optimization Patterns

Stats Aggregation Table

Pre-compute counts to avoid querying thousands of documents. This is critical for free tier limits (1GB/month).

// ❌ Bad: Reads entire table to count
const all = await ctx.db.query("evaluations").collect();
return { total: all.length, eligible: all.filter(e => e.status === "ELIGIBLE").length };

// ✅ Good: Read single aggregation document
const cached = await ctx.db
  .query("statsAggregation")
  .withIndex("by_key", (q) => q.eq("key", "eligibility"))
  .first();

return {
  total: cached?.counts.total ?? 0,
  eligible: cached?.counts.eligible ?? 0,
};

Schema for aggregation table:

statsAggregation: defineTable({
  key: v.string(), // e.g., "eligibility", "opportunities"
  counts: v.object({
    total: v.number(),
    eligible: v.optional(v.number()),
    // ... other counts
  }),
  lastUpdatedAt: v.number(),
}).index("by_key", ["key"]),

Update stats incrementally when records change:

// In your create/update/delete mutations:
async function updateStatsOnIncrement(ctx: MutationCtx, status: string) {
  const existing = await ctx.db.query("statsAggregation")
    .withIndex("by_key", (q) => q.eq("key", "eligibility")).first();

  if (!existing) {
    await ctx.db.insert("statsAggregation", {
      key: "eligibility",
      counts: { total: 1, eligible: status === "ELIGIBLE" ? 1 : 0 },
      lastUpdatedAt: Date.now(),
    });
    return;
  }

  const counts = { ...existing.counts };
  counts.total = (counts.total ?? 0) + 1;
  if (status === "ELIGIBLE") counts.eligible = (counts.eligible ?? 0) + 1;
  await ctx.db.patch(existing._id, { counts, lastUpdatedAt: Date.now() });
}

Conditional Query Loading (Skip Pattern)

Only load data when user actually needs it:

// ✅ Good: Data won't load until user clicks export
const [wantsExport, setWantsExport] = useState(false);
const exportData = useQuery(
  api.eligibilityRules.exportRules,
  wantsExport ? {} : "skip"  // "skip" prevents the query from running
);

// User clicks button → query runs → data loads
<button onClick={() => setWantsExport(true)}>Export Rules</button>

// ❌ Bad: Loads all data on component mount even if rarely used
const exportData = useQuery(api.eligibilityRules.exportRules, {});

Batch Operations with hasMore Pattern

For deleting or processing large datasets:

// ✅ Good: Process in batches, return hasMore flag
export const resetAllEvaluations = mutation({
  args: { batchSize: v.optional(v.number()) },
  handler: async (ctx, args) => {
    const batchSize = args.batchSize ?? 100;
    const evaluations = await ctx.db.query("evaluations").take(batchSize);

    for (const evaluation of evaluations) {
      await ctx.db.delete(evaluation._id);
    }

    return {
      deleted: evaluations.length,
      hasMore: evaluations.length === batchSize  // True if there might be more
    };
  },
});

Client-side loop:

const handleReset = async () => {
  let hasMore = true;
  let totalDeleted = 0;

  while (hasMore) {
    const result = await resetEvaluations({ batchSize: 100 });
    totalDeleted += result.deleted;
    hasMore = result.hasMore;
  }

  console.log(`Deleted ${totalDeleted} evaluations`);
};

Indexed Lookups for Joins

When joining tables, use indexed lookups per-record instead of loading entire tables:

// ❌ Bad: Loads ALL evaluations, then filters in JS
const allEvaluations = await ctx.db.query("evaluations").take(1000);
const evaluationMap = new Map(allEvaluations.map(e => [e.opportunityId, e]));
return opportunities.map(opp => ({
  ...opp,
  evaluation: evaluationMap.get(opp._id),
}));

// ✅ Good: Use indexed lookup per opportunity (N queries, but each is tiny)
const evaluationPromises = opportunities.map(opp =>
  ctx.db.query("evaluations")
    .withIndex("by_opportunity", (q) => q.eq("opportunityId", opp._id))
    .first()
);
const evaluations = await Promise.all(evaluationPromises);
return opportunities.map((opp, i) => ({
  ...opp,
  evaluation: evaluations[i],
}));

Deduplication with Sets

For upsert operations, use Set-based lookups instead of array includes:

// ❌ Bad: O(n) lookup for each check
const recentIds = recentOpportunities.map(o => o.externalIds[0]?.externalId);
if (recentIds.includes(record.externalId)) continue;

// ✅ Good: O(1) lookup with Set
const existingIds = new Set(
  recentOpportunities.flatMap(o => o.externalIds.map(e => e.externalId))
);
if (existingIds.has(record.externalId)) continue;

Anti-Patterns to Avoid

❌ Avoid ✅ Do Instead
.collect() without limit .take(limit)
Large .take(1000) on heavyweight tables Smaller limits (50-200) with pagination
Loading full table to count records Stats aggregation table
Filtering in JS after fetch Use indexes
Storing derived data Compute in queries (unless for stats)
any types in args Proper v.* validators
Multiple awaits in loops Promise.all for batches
Env vars in queries Only in actions
Loading unused data on mount Conditional queries with "skip"
Full table scan for joins Indexed lookups per record
Weekly Installs
3
First Seen
Feb 26, 2026
Installed on
gemini-cli3
github-copilot3
codex3
amp3
kimi-cli3
openclaw3