convex-patterns

Convex Patterns Skill

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "convex-patterns" with this command: npx skills add atemndobs/nebula-rfp/atemndobs-nebula-rfp-convex-patterns

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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

pursuit-brief

No summary provided by upstream source.

Repository SourceNeeds Review
General

rfp-evaluate

No summary provided by upstream source.

Repository SourceNeeds Review
General

csv-export

No summary provided by upstream source.

Repository SourceNeeds Review
General

proposal-builder

No summary provided by upstream source.

Repository SourceNeeds Review