diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..cbdf589 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,10 @@ +{ + "enabledPlugins": { + "payload@payload-marketplace": true, + "frontend-design@claude-plugins-official": true, + "superpowers@claude-plugins-official": true, + "context7@claude-plugins-official": true, + "claude-md-management@claude-plugins-official": true, + "waynesutton-convex-skills@cpd-waynesutton-convex-skills": true + } +} diff --git a/.claude/skills/convex-best-practices/SKILL.md b/.claude/skills/convex-best-practices/SKILL.md new file mode 100644 index 0000000..deddcea --- /dev/null +++ b/.claude/skills/convex-best-practices/SKILL.md @@ -0,0 +1,369 @@ +--- +name: convex-best-practices +description: Guidelines for building production-ready Convex apps covering function organization, query patterns, validation, TypeScript usage, error handling, and the Zen of Convex design philosophy +--- + +# Convex Best Practices + +Build production-ready Convex applications by following established patterns for function organization, query optimization, validation, TypeScript usage, and error handling. + +## Code Quality + +All patterns in this skill comply with `@convex-dev/eslint-plugin`. Install it for build-time validation: + +```bash +npm i @convex-dev/eslint-plugin --save-dev +``` + +```js +// eslint.config.js +import { defineConfig } from "eslint/config"; +import convexPlugin from "@convex-dev/eslint-plugin"; + +export default defineConfig([ + ...convexPlugin.configs.recommended, +]); +``` + +The plugin enforces four rules: + +| Rule | What it enforces | +| ----------------------------------- | --------------------------------- | +| `no-old-registered-function-syntax` | Object syntax with `handler` | +| `require-argument-validators` | `args: {}` on all functions | +| `explicit-table-ids` | Table name in db operations | +| `import-wrong-runtime` | No Node imports in Convex runtime | + +Docs: https://docs.convex.dev/eslint + +## Documentation Sources + +Before implementing, do not assume; fetch the latest documentation: + +- Primary: https://docs.convex.dev/understanding/best-practices/ +- Error Handling: https://docs.convex.dev/functions/error-handling +- Write Conflicts: https://docs.convex.dev/error#1 +- For broader context: https://docs.convex.dev/llms.txt + +## Instructions + +### The Zen of Convex + +1. **Convex manages the hard parts** - Let Convex handle caching, real-time sync, and consistency +2. **Functions are the API** - Design your functions as your application's interface +3. **Schema is truth** - Define your data model explicitly in schema.ts +4. **TypeScript everywhere** - Leverage end-to-end type safety +5. **Queries are reactive** - Think in terms of subscriptions, not requests + +### Function Organization + +Organize your Convex functions by domain: + +```typescript +// convex/users.ts - User-related functions +import { query, mutation } from "./_generated/server"; +import { v } from "convex/values"; + +export const get = query({ + args: { userId: v.id("users") }, + returns: v.union( + v.object({ + _id: v.id("users"), + _creationTime: v.number(), + name: v.string(), + email: v.string(), + }), + v.null(), + ), + handler: async (ctx, args) => { + return await ctx.db.get("users", args.userId); + }, +}); +``` + +### Argument and Return Validation + +Always define validators for arguments AND return types: + +```typescript +export const createTask = mutation({ + args: { + title: v.string(), + description: v.optional(v.string()), + priority: v.union(v.literal("low"), v.literal("medium"), v.literal("high")), + }, + returns: v.id("tasks"), + handler: async (ctx, args) => { + return await ctx.db.insert("tasks", { + title: args.title, + description: args.description, + priority: args.priority, + completed: false, + createdAt: Date.now(), + }); + }, +}); +``` + +### Query Patterns + +Use indexes instead of filters for efficient queries: + +```typescript +// Schema with index +export default defineSchema({ + tasks: defineTable({ + userId: v.id("users"), + status: v.string(), + createdAt: v.number(), + }) + .index("by_user", ["userId"]) + .index("by_user_and_status", ["userId", "status"]), +}); + +// Query using index +export const getTasksByUser = query({ + args: { userId: v.id("users") }, + returns: v.array( + v.object({ + _id: v.id("tasks"), + _creationTime: v.number(), + userId: v.id("users"), + status: v.string(), + createdAt: v.number(), + }), + ), + handler: async (ctx, args) => { + return await ctx.db + .query("tasks") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .order("desc") + .collect(); + }, +}); +``` + +### Error Handling + +Use ConvexError for user-facing errors: + +```typescript +import { ConvexError } from "convex/values"; + +export const updateTask = mutation({ + args: { + taskId: v.id("tasks"), + title: v.string(), + }, + returns: v.null(), + handler: async (ctx, args) => { + const task = await ctx.db.get("tasks", args.taskId); + + if (!task) { + throw new ConvexError({ + code: "NOT_FOUND", + message: "Task not found", + }); + } + + await ctx.db.patch("tasks", args.taskId, { title: args.title }); + return null; + }, +}); +``` + +### Avoiding Write Conflicts (Optimistic Concurrency Control) + +Convex uses OCC. Follow these patterns to minimize conflicts: + +```typescript +// GOOD: Make mutations idempotent +export const completeTask = mutation({ + args: { taskId: v.id("tasks") }, + returns: v.null(), + handler: async (ctx, args) => { + const task = await ctx.db.get("tasks", args.taskId); + + // Early return if already complete (idempotent) + if (!task || task.status === "completed") { + return null; + } + + await ctx.db.patch("tasks", args.taskId, { + status: "completed", + completedAt: Date.now(), + }); + return null; + }, +}); + +// GOOD: Patch directly without reading first when possible +export const updateNote = mutation({ + args: { id: v.id("notes"), content: v.string() }, + returns: v.null(), + handler: async (ctx, args) => { + // Patch directly - ctx.db.patch throws if document doesn't exist + await ctx.db.patch("notes", args.id, { content: args.content }); + return null; + }, +}); + +// GOOD: Use Promise.all for parallel independent updates +export const reorderItems = mutation({ + args: { itemIds: v.array(v.id("items")) }, + returns: v.null(), + handler: async (ctx, args) => { + const updates = args.itemIds.map((id, index) => + ctx.db.patch("items", id, { order: index }), + ); + await Promise.all(updates); + return null; + }, +}); +``` + +### TypeScript Best Practices + +```typescript +import { Id, Doc } from "./_generated/dataModel"; + +// Use Id type for document references +type UserId = Id<"users">; + +// Use Doc type for full documents +type User = Doc<"users">; + +// Define Record types properly +const userScores: Record, number> = {}; +``` + +### Internal vs Public Functions + +```typescript +// Public function - exposed to clients +export const getUser = query({ + args: { userId: v.id("users") }, + returns: v.union( + v.null(), + v.object({ + /* ... */ + }), + ), + handler: async (ctx, args) => { + // ... + }, +}); + +// Internal function - only callable from other Convex functions +export const _updateUserStats = internalMutation({ + args: { userId: v.id("users") }, + returns: v.null(), + handler: async (ctx, args) => { + // ... + }, +}); +``` + +## Examples + +### Complete CRUD Pattern + +```typescript +// convex/tasks.ts +import { query, mutation } from "./_generated/server"; +import { v } from "convex/values"; +import { ConvexError } from "convex/values"; + +const taskValidator = v.object({ + _id: v.id("tasks"), + _creationTime: v.number(), + title: v.string(), + completed: v.boolean(), + userId: v.id("users"), +}); + +export const list = query({ + args: { userId: v.id("users") }, + returns: v.array(taskValidator), + handler: async (ctx, args) => { + return await ctx.db + .query("tasks") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .collect(); + }, +}); + +export const create = mutation({ + args: { + title: v.string(), + userId: v.id("users"), + }, + returns: v.id("tasks"), + handler: async (ctx, args) => { + return await ctx.db.insert("tasks", { + title: args.title, + completed: false, + userId: args.userId, + }); + }, +}); + +export const update = mutation({ + args: { + taskId: v.id("tasks"), + title: v.optional(v.string()), + completed: v.optional(v.boolean()), + }, + returns: v.null(), + handler: async (ctx, args) => { + const { taskId, ...updates } = args; + + // Remove undefined values + const cleanUpdates = Object.fromEntries( + Object.entries(updates).filter(([_, v]) => v !== undefined), + ); + + if (Object.keys(cleanUpdates).length > 0) { + await ctx.db.patch("tasks", taskId, cleanUpdates); + } + return null; + }, +}); + +export const remove = mutation({ + args: { taskId: v.id("tasks") }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.delete("tasks", args.taskId); + return null; + }, +}); +``` + +## Best Practices + +- Never run `npx convex deploy` unless explicitly instructed +- Never run any git commands unless explicitly instructed +- Always define return validators for functions +- Use indexes for all queries that filter data +- Make mutations idempotent to handle retries gracefully +- Use ConvexError for user-facing error messages +- Organize functions by domain (users.ts, tasks.ts, etc.) +- Use internal functions for sensitive operations +- Leverage TypeScript's Id and Doc types + +## Common Pitfalls + +1. **Using filter instead of withIndex** - Always define indexes and use withIndex +2. **Missing return validators** - Always specify the returns field +3. **Non-idempotent mutations** - Check current state before updating +4. **Reading before patching unnecessarily** - Patch directly when possible +5. **Not handling null returns** - Document IDs might not exist + +## References + +- Convex Documentation: https://docs.convex.dev/ +- Convex LLMs.txt: https://docs.convex.dev/llms.txt +- Best Practices: https://docs.convex.dev/understanding/best-practices/ +- Error Handling: https://docs.convex.dev/functions/error-handling +- Write Conflicts: https://docs.convex.dev/error#1 diff --git a/.claude/skills/convex-component-authoring/SKILL.md b/.claude/skills/convex-component-authoring/SKILL.md new file mode 100644 index 0000000..00046d6 --- /dev/null +++ b/.claude/skills/convex-component-authoring/SKILL.md @@ -0,0 +1,457 @@ +--- +name: convex-component-authoring +displayName: Convex Component Authoring +description: How to create, structure, and publish self-contained Convex components with proper isolation, exports, and dependency management +version: 1.0.0 +author: Convex +tags: [convex, components, reusable, packages, npm] +--- + +# Convex Component Authoring + +Create self-contained, reusable Convex components with proper isolation, exports, and dependency management for sharing across projects. + +## Documentation Sources + +Before implementing, do not assume; fetch the latest documentation: + +- Primary: https://docs.convex.dev/components +- Component Authoring: https://docs.convex.dev/components/authoring +- For broader context: https://docs.convex.dev/llms.txt + +## Instructions + +### What Are Convex Components? + +Convex components are self-contained packages that include: +- Database tables (isolated from the main app) +- Functions (queries, mutations, actions) +- TypeScript types and validators +- Optional frontend hooks + +### Component Structure + +``` +my-convex-component/ +├── package.json +├── tsconfig.json +├── README.md +├── src/ +│ ├── index.ts # Main exports +│ ├── component.ts # Component definition +│ ├── schema.ts # Component schema +│ └── functions/ +│ ├── queries.ts +│ ├── mutations.ts +│ └── actions.ts +└── convex.config.ts # Component configuration +``` + +### Creating a Component + +#### 1. Component Configuration + +```typescript +// convex.config.ts +import { defineComponent } from "convex/server"; + +export default defineComponent("myComponent"); +``` + +#### 2. Component Schema + +```typescript +// src/schema.ts +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export default defineSchema({ + // Tables are isolated to this component + items: defineTable({ + name: v.string(), + data: v.any(), + createdAt: v.number(), + }).index("by_name", ["name"]), + + config: defineTable({ + key: v.string(), + value: v.any(), + }).index("by_key", ["key"]), +}); +``` + +#### 3. Component Definition + +```typescript +// src/component.ts +import { defineComponent, ComponentDefinition } from "convex/server"; +import schema from "./schema"; +import * as queries from "./functions/queries"; +import * as mutations from "./functions/mutations"; + +const component = defineComponent("myComponent", { + schema, + functions: { + ...queries, + ...mutations, + }, +}); + +export default component; +``` + +#### 4. Component Functions + +```typescript +// src/functions/queries.ts +import { query } from "../_generated/server"; +import { v } from "convex/values"; + +export const list = query({ + args: { + limit: v.optional(v.number()), + }, + returns: v.array(v.object({ + _id: v.id("items"), + name: v.string(), + data: v.any(), + createdAt: v.number(), + })), + handler: async (ctx, args) => { + return await ctx.db + .query("items") + .order("desc") + .take(args.limit ?? 10); + }, +}); + +export const get = query({ + args: { name: v.string() }, + returns: v.union(v.object({ + _id: v.id("items"), + name: v.string(), + data: v.any(), + }), v.null()), + handler: async (ctx, args) => { + return await ctx.db + .query("items") + .withIndex("by_name", (q) => q.eq("name", args.name)) + .unique(); + }, +}); +``` + +```typescript +// src/functions/mutations.ts +import { mutation } from "../_generated/server"; +import { v } from "convex/values"; + +export const create = mutation({ + args: { + name: v.string(), + data: v.any(), + }, + returns: v.id("items"), + handler: async (ctx, args) => { + return await ctx.db.insert("items", { + name: args.name, + data: args.data, + createdAt: Date.now(), + }); + }, +}); + +export const update = mutation({ + args: { + id: v.id("items"), + data: v.any(), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.id, { data: args.data }); + return null; + }, +}); + +export const remove = mutation({ + args: { id: v.id("items") }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.delete(args.id); + return null; + }, +}); +``` + +#### 5. Main Exports + +```typescript +// src/index.ts +export { default as component } from "./component"; +export * from "./functions/queries"; +export * from "./functions/mutations"; + +// Export types for consumers +export type { Id } from "./_generated/dataModel"; +``` + +### Using a Component + +```typescript +// In the consuming app's convex/convex.config.ts +import { defineApp } from "convex/server"; +import myComponent from "my-convex-component"; + +const app = defineApp(); + +app.use(myComponent, { name: "myComponent" }); + +export default app; +``` + +```typescript +// In the consuming app's code +import { useQuery, useMutation } from "convex/react"; +import { api } from "../convex/_generated/api"; + +function MyApp() { + // Access component functions through the app's API + const items = useQuery(api.myComponent.list, { limit: 10 }); + const createItem = useMutation(api.myComponent.create); + + return ( +
+ {items?.map((item) => ( +
{item.name}
+ ))} + +
+ ); +} +``` + +### Component Configuration Options + +```typescript +// convex/convex.config.ts +import { defineApp } from "convex/server"; +import myComponent from "my-convex-component"; + +const app = defineApp(); + +// Basic usage +app.use(myComponent); + +// With custom name +app.use(myComponent, { name: "customName" }); + +// Multiple instances +app.use(myComponent, { name: "instance1" }); +app.use(myComponent, { name: "instance2" }); + +export default app; +``` + +### Providing Component Hooks + +```typescript +// src/hooks.ts +import { useQuery, useMutation } from "convex/react"; +import { FunctionReference } from "convex/server"; + +// Type-safe hooks for component consumers +export function useMyComponent(api: { + list: FunctionReference<"query">; + create: FunctionReference<"mutation">; +}) { + const items = useQuery(api.list, {}); + const createItem = useMutation(api.create); + + return { + items, + createItem, + isLoading: items === undefined, + }; +} +``` + +### Publishing a Component + +#### package.json + +```json +{ + "name": "my-convex-component", + "version": "1.0.0", + "description": "A reusable Convex component", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "convex.config.ts" + ], + "scripts": { + "build": "tsc", + "prepublishOnly": "npm run build" + }, + "peerDependencies": { + "convex": "^1.0.0" + }, + "devDependencies": { + "convex": "^1.17.0", + "typescript": "^5.0.0" + }, + "keywords": [ + "convex", + "component" + ] +} +``` + +#### tsconfig.json + +```json +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + +## Examples + +### Rate Limiter Component + +```typescript +// rate-limiter/src/schema.ts +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export default defineSchema({ + requests: defineTable({ + key: v.string(), + timestamp: v.number(), + }) + .index("by_key", ["key"]) + .index("by_key_and_time", ["key", "timestamp"]), +}); +``` + +```typescript +// rate-limiter/src/functions/mutations.ts +import { mutation } from "../_generated/server"; +import { v } from "convex/values"; + +export const checkLimit = mutation({ + args: { + key: v.string(), + limit: v.number(), + windowMs: v.number(), + }, + returns: v.object({ + allowed: v.boolean(), + remaining: v.number(), + resetAt: v.number(), + }), + handler: async (ctx, args) => { + const now = Date.now(); + const windowStart = now - args.windowMs; + + // Clean old entries + const oldEntries = await ctx.db + .query("requests") + .withIndex("by_key_and_time", (q) => + q.eq("key", args.key).lt("timestamp", windowStart) + ) + .collect(); + + for (const entry of oldEntries) { + await ctx.db.delete(entry._id); + } + + // Count current window + const currentRequests = await ctx.db + .query("requests") + .withIndex("by_key", (q) => q.eq("key", args.key)) + .collect(); + + const remaining = Math.max(0, args.limit - currentRequests.length); + const allowed = remaining > 0; + + if (allowed) { + await ctx.db.insert("requests", { + key: args.key, + timestamp: now, + }); + } + + const oldestRequest = currentRequests[0]; + const resetAt = oldestRequest + ? oldestRequest.timestamp + args.windowMs + : now + args.windowMs; + + return { allowed, remaining: remaining - (allowed ? 1 : 0), resetAt }; + }, +}); +``` + +```typescript +// Usage in consuming app +import { useMutation } from "convex/react"; +import { api } from "../convex/_generated/api"; + +function useRateLimitedAction() { + const checkLimit = useMutation(api.rateLimiter.checkLimit); + + return async (action: () => Promise) => { + const result = await checkLimit({ + key: "user-action", + limit: 10, + windowMs: 60000, + }); + + if (!result.allowed) { + throw new Error(`Rate limited. Try again at ${new Date(result.resetAt)}`); + } + + await action(); + }; +} +``` + +## Best Practices + +- Never run `npx convex deploy` unless explicitly instructed +- Never run any git commands unless explicitly instructed +- Keep component tables isolated (don't reference main app tables) +- Export clear TypeScript types for consumers +- Document all public functions and their arguments +- Use semantic versioning for component releases +- Include comprehensive README with examples +- Test components in isolation before publishing + +## Common Pitfalls + +1. **Cross-referencing tables** - Component tables should be self-contained +2. **Missing type exports** - Export all necessary types +3. **Hardcoded configuration** - Use component options for customization +4. **No versioning** - Follow semantic versioning +5. **Poor documentation** - Document all public APIs + +## References + +- Convex Documentation: https://docs.convex.dev/ +- Convex LLMs.txt: https://docs.convex.dev/llms.txt +- Components: https://docs.convex.dev/components +- Component Authoring: https://docs.convex.dev/components/authoring diff --git a/.claude/skills/convex-cron-jobs/SKILL.md b/.claude/skills/convex-cron-jobs/SKILL.md new file mode 100644 index 0000000..66bd974 --- /dev/null +++ b/.claude/skills/convex-cron-jobs/SKILL.md @@ -0,0 +1,604 @@ +--- +name: convex-cron-jobs +displayName: Convex Cron Jobs +description: Scheduled function patterns for background tasks including interval scheduling, cron expressions, job monitoring, retry strategies, and best practices for long-running tasks +version: 1.0.0 +author: Convex +tags: [convex, cron, scheduling, background-jobs, automation] +--- + +# Convex Cron Jobs + +Schedule recurring functions for background tasks, cleanup jobs, data syncing, and automated workflows in Convex applications. + +## Documentation Sources + +Before implementing, do not assume; fetch the latest documentation: + +- Primary: https://docs.convex.dev/scheduling/cron-jobs +- Scheduling Overview: https://docs.convex.dev/scheduling +- Scheduled Functions: https://docs.convex.dev/scheduling/scheduled-functions +- For broader context: https://docs.convex.dev/llms.txt + +## Instructions + +### Cron Jobs Overview + +Convex cron jobs allow you to schedule functions to run at regular intervals or specific times. Key features: + +- Run functions on a fixed schedule +- Support for interval-based and cron expression scheduling +- Automatic retries on failure +- Monitoring via the Convex dashboard + +### Basic Cron Setup + +```typescript +// convex/crons.ts +import { cronJobs } from "convex/server"; +import { internal } from "./_generated/api"; + +const crons = cronJobs(); + +// Run every hour +crons.interval( + "cleanup expired sessions", + { hours: 1 }, + internal.tasks.cleanupExpiredSessions, + {} +); + +// Run every day at midnight UTC +crons.cron( + "daily report", + "0 0 * * *", + internal.reports.generateDailyReport, + {} +); + +export default crons; +``` + +### Interval-Based Scheduling + +Use `crons.interval` for simple recurring tasks: + +```typescript +// convex/crons.ts +import { cronJobs } from "convex/server"; +import { internal } from "./_generated/api"; + +const crons = cronJobs(); + +// Every 5 minutes +crons.interval( + "sync external data", + { minutes: 5 }, + internal.sync.fetchExternalData, + {} +); + +// Every 2 hours +crons.interval( + "cleanup temp files", + { hours: 2 }, + internal.files.cleanupTempFiles, + {} +); + +// Every 30 seconds (minimum interval) +crons.interval( + "health check", + { seconds: 30 }, + internal.monitoring.healthCheck, + {} +); + +export default crons; +``` + +### Cron Expression Scheduling + +Use `crons.cron` for precise scheduling with cron expressions: + +```typescript +// convex/crons.ts +import { cronJobs } from "convex/server"; +import { internal } from "./_generated/api"; + +const crons = cronJobs(); + +// Every day at 9 AM UTC +crons.cron( + "morning notifications", + "0 9 * * *", + internal.notifications.sendMorningDigest, + {} +); + +// Every Monday at 8 AM UTC +crons.cron( + "weekly summary", + "0 8 * * 1", + internal.reports.generateWeeklySummary, + {} +); + +// First day of every month at midnight +crons.cron( + "monthly billing", + "0 0 1 * *", + internal.billing.processMonthlyBilling, + {} +); + +// Every 15 minutes +crons.cron( + "frequent sync", + "*/15 * * * *", + internal.sync.syncData, + {} +); + +export default crons; +``` + +### Cron Expression Reference + +``` +┌───────────── minute (0-59) +│ ┌───────────── hour (0-23) +│ │ ┌───────────── day of month (1-31) +│ │ │ ┌───────────── month (1-12) +│ │ │ │ ┌───────────── day of week (0-6, Sunday=0) +│ │ │ │ │ +* * * * * +``` + +Common patterns: +- `* * * * *` - Every minute +- `0 * * * *` - Every hour +- `0 0 * * *` - Every day at midnight +- `0 0 * * 0` - Every Sunday at midnight +- `0 0 1 * *` - First day of every month +- `*/5 * * * *` - Every 5 minutes +- `0 9-17 * * 1-5` - Every hour from 9 AM to 5 PM, Monday through Friday + +### Internal Functions for Crons + +Cron jobs should call internal functions for security: + +```typescript +// convex/tasks.ts +import { internalMutation, internalQuery } from "./_generated/server"; +import { v } from "convex/values"; + +// Cleanup expired sessions +export const cleanupExpiredSessions = internalMutation({ + args: {}, + returns: v.number(), + handler: async (ctx) => { + const oneHourAgo = Date.now() - 60 * 60 * 1000; + + const expiredSessions = await ctx.db + .query("sessions") + .withIndex("by_lastActive") + .filter((q) => q.lt(q.field("lastActive"), oneHourAgo)) + .collect(); + + for (const session of expiredSessions) { + await ctx.db.delete(session._id); + } + + return expiredSessions.length; + }, +}); + +// Process pending tasks +export const processPendingTasks = internalMutation({ + args: {}, + returns: v.null(), + handler: async (ctx) => { + const pendingTasks = await ctx.db + .query("tasks") + .withIndex("by_status", (q) => q.eq("status", "pending")) + .take(100); + + for (const task of pendingTasks) { + await ctx.db.patch(task._id, { + status: "processing", + startedAt: Date.now(), + }); + + // Schedule the actual processing + await ctx.scheduler.runAfter(0, internal.tasks.processTask, { + taskId: task._id, + }); + } + + return null; + }, +}); +``` + +### Cron Jobs with Arguments + +Pass static arguments to cron jobs: + +```typescript +// convex/crons.ts +import { cronJobs } from "convex/server"; +import { internal } from "./_generated/api"; + +const crons = cronJobs(); + +// Different cleanup intervals for different types +crons.interval( + "cleanup temp files", + { hours: 1 }, + internal.cleanup.cleanupByType, + { fileType: "temp", maxAge: 3600000 } +); + +crons.interval( + "cleanup cache files", + { hours: 24 }, + internal.cleanup.cleanupByType, + { fileType: "cache", maxAge: 86400000 } +); + +export default crons; +``` + +```typescript +// convex/cleanup.ts +import { internalMutation } from "./_generated/server"; +import { v } from "convex/values"; + +export const cleanupByType = internalMutation({ + args: { + fileType: v.string(), + maxAge: v.number(), + }, + returns: v.number(), + handler: async (ctx, args) => { + const cutoff = Date.now() - args.maxAge; + + const oldFiles = await ctx.db + .query("files") + .withIndex("by_type_and_created", (q) => + q.eq("type", args.fileType).lt("createdAt", cutoff) + ) + .collect(); + + for (const file of oldFiles) { + await ctx.storage.delete(file.storageId); + await ctx.db.delete(file._id); + } + + return oldFiles.length; + }, +}); +``` + +### Monitoring and Logging + +Add logging to track cron job execution: + +```typescript +// convex/tasks.ts +import { internalMutation } from "./_generated/server"; +import { v } from "convex/values"; + +export const cleanupWithLogging = internalMutation({ + args: {}, + returns: v.null(), + handler: async (ctx) => { + const startTime = Date.now(); + let processedCount = 0; + let errorCount = 0; + + try { + const expiredItems = await ctx.db + .query("items") + .withIndex("by_expiresAt") + .filter((q) => q.lt(q.field("expiresAt"), Date.now())) + .collect(); + + for (const item of expiredItems) { + try { + await ctx.db.delete(item._id); + processedCount++; + } catch (error) { + errorCount++; + console.error(`Failed to delete item ${item._id}:`, error); + } + } + + // Log job completion + await ctx.db.insert("cronLogs", { + jobName: "cleanup", + startTime, + endTime: Date.now(), + duration: Date.now() - startTime, + processedCount, + errorCount, + status: errorCount === 0 ? "success" : "partial", + }); + } catch (error) { + // Log job failure + await ctx.db.insert("cronLogs", { + jobName: "cleanup", + startTime, + endTime: Date.now(), + duration: Date.now() - startTime, + processedCount, + errorCount, + status: "failed", + error: String(error), + }); + throw error; + } + + return null; + }, +}); +``` + +### Batching for Large Datasets + +Handle large datasets in batches to avoid timeouts: + +```typescript +// convex/tasks.ts +import { internalMutation } from "./_generated/server"; +import { internal } from "./_generated/api"; +import { v } from "convex/values"; + +const BATCH_SIZE = 100; + +export const processBatch = internalMutation({ + args: { + cursor: v.optional(v.string()), + }, + returns: v.null(), + handler: async (ctx, args) => { + const result = await ctx.db + .query("items") + .withIndex("by_status", (q) => q.eq("status", "pending")) + .paginate({ numItems: BATCH_SIZE, cursor: args.cursor ?? null }); + + for (const item of result.page) { + await ctx.db.patch(item._id, { + status: "processed", + processedAt: Date.now(), + }); + } + + // Schedule next batch if there are more items + if (!result.isDone) { + await ctx.scheduler.runAfter(0, internal.tasks.processBatch, { + cursor: result.continueCursor, + }); + } + + return null; + }, +}); +``` + +### External API Calls in Crons + +Use actions for external API calls: + +```typescript +// convex/sync.ts +"use node"; + +import { internalAction } from "./_generated/server"; +import { internal } from "./_generated/api"; +import { v } from "convex/values"; + +export const syncExternalData = internalAction({ + args: {}, + returns: v.null(), + handler: async (ctx) => { + // Fetch from external API + const response = await fetch("https://api.example.com/data", { + headers: { + Authorization: `Bearer ${process.env.API_KEY}`, + }, + }); + + if (!response.ok) { + throw new Error(`API request failed: ${response.status}`); + } + + const data = await response.json(); + + // Store the data using a mutation + await ctx.runMutation(internal.sync.storeExternalData, { + data, + syncedAt: Date.now(), + }); + + return null; + }, +}); + +export const storeExternalData = internalMutation({ + args: { + data: v.any(), + syncedAt: v.number(), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.insert("externalData", { + data: args.data, + syncedAt: args.syncedAt, + }); + return null; + }, +}); +``` + +```typescript +// convex/crons.ts +import { cronJobs } from "convex/server"; +import { internal } from "./_generated/api"; + +const crons = cronJobs(); + +crons.interval( + "sync external data", + { minutes: 15 }, + internal.sync.syncExternalData, + {} +); + +export default crons; +``` + +## Examples + +### Schema for Cron Job Logging + +```typescript +// convex/schema.ts +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export default defineSchema({ + cronLogs: defineTable({ + jobName: v.string(), + startTime: v.number(), + endTime: v.number(), + duration: v.number(), + processedCount: v.number(), + errorCount: v.number(), + status: v.union( + v.literal("success"), + v.literal("partial"), + v.literal("failed") + ), + error: v.optional(v.string()), + }) + .index("by_job", ["jobName"]) + .index("by_status", ["status"]) + .index("by_startTime", ["startTime"]), + + sessions: defineTable({ + userId: v.id("users"), + token: v.string(), + lastActive: v.number(), + expiresAt: v.number(), + }) + .index("by_user", ["userId"]) + .index("by_lastActive", ["lastActive"]) + .index("by_expiresAt", ["expiresAt"]), + + tasks: defineTable({ + type: v.string(), + status: v.union( + v.literal("pending"), + v.literal("processing"), + v.literal("completed"), + v.literal("failed") + ), + data: v.any(), + createdAt: v.number(), + startedAt: v.optional(v.number()), + completedAt: v.optional(v.number()), + }) + .index("by_status", ["status"]) + .index("by_type_and_status", ["type", "status"]), +}); +``` + +### Complete Cron Configuration Example + +```typescript +// convex/crons.ts +import { cronJobs } from "convex/server"; +import { internal } from "./_generated/api"; + +const crons = cronJobs(); + +// Cleanup jobs +crons.interval( + "cleanup expired sessions", + { hours: 1 }, + internal.cleanup.expiredSessions, + {} +); + +crons.interval( + "cleanup old logs", + { hours: 24 }, + internal.cleanup.oldLogs, + { maxAgeDays: 30 } +); + +// Sync jobs +crons.interval( + "sync user data", + { minutes: 15 }, + internal.sync.userData, + {} +); + +// Report jobs +crons.cron( + "daily analytics", + "0 1 * * *", + internal.reports.dailyAnalytics, + {} +); + +crons.cron( + "weekly summary", + "0 9 * * 1", + internal.reports.weeklySummary, + {} +); + +// Health checks +crons.interval( + "service health check", + { minutes: 5 }, + internal.monitoring.healthCheck, + {} +); + +export default crons; +``` + +## Best Practices + +- Never run `npx convex deploy` unless explicitly instructed +- Never run any git commands unless explicitly instructed +- Only use `crons.interval` or `crons.cron` methods, not deprecated helpers +- Always call internal functions from cron jobs for security +- Import `internal` from `_generated/api` even for functions in the same file +- Add logging and monitoring for production cron jobs +- Use batching for operations that process large datasets +- Handle errors gracefully to prevent job failures +- Use meaningful job names for dashboard visibility +- Consider timezone when using cron expressions (Convex uses UTC) + +## Common Pitfalls + +1. **Using public functions** - Cron jobs should call internal functions only +2. **Long-running mutations** - Break large operations into batches +3. **Missing error handling** - Unhandled errors will fail the entire job +4. **Forgetting timezone** - All cron expressions use UTC +5. **Using deprecated helpers** - Avoid `crons.hourly`, `crons.daily`, etc. +6. **Not logging execution** - Makes debugging production issues difficult + +## References + +- Convex Documentation: https://docs.convex.dev/ +- Convex LLMs.txt: https://docs.convex.dev/llms.txt +- Cron Jobs: https://docs.convex.dev/scheduling/cron-jobs +- Scheduling Overview: https://docs.convex.dev/scheduling +- Scheduled Functions: https://docs.convex.dev/scheduling/scheduled-functions diff --git a/.claude/skills/convex-file-storage/SKILL.md b/.claude/skills/convex-file-storage/SKILL.md new file mode 100644 index 0000000..ffa39c9 --- /dev/null +++ b/.claude/skills/convex-file-storage/SKILL.md @@ -0,0 +1,467 @@ +--- +name: convex-file-storage +displayName: Convex File Storage +description: Complete file handling including upload flows, serving files via URL, storing generated files from actions, deletion, and accessing file metadata from system tables +version: 1.0.0 +author: Convex +tags: [convex, file-storage, uploads, images, files] +--- + +# Convex File Storage + +Handle file uploads, storage, serving, and management in Convex applications with proper patterns for images, documents, and generated files. + +## Documentation Sources + +Before implementing, do not assume; fetch the latest documentation: + +- Primary: https://docs.convex.dev/file-storage +- Upload Files: https://docs.convex.dev/file-storage/upload-files +- Serve Files: https://docs.convex.dev/file-storage/serve-files +- For broader context: https://docs.convex.dev/llms.txt + +## Instructions + +### File Storage Overview + +Convex provides built-in file storage with: +- Automatic URL generation for serving files +- Support for any file type (images, PDFs, videos, etc.) +- File metadata via the `_storage` system table +- Integration with mutations and actions + +### Generating Upload URLs + +```typescript +// convex/files.ts +import { mutation } from "./_generated/server"; +import { v } from "convex/values"; + +export const generateUploadUrl = mutation({ + args: {}, + returns: v.string(), + handler: async (ctx) => { + return await ctx.storage.generateUploadUrl(); + }, +}); +``` + +### Client-Side Upload + +```typescript +// React component +import { useMutation } from "convex/react"; +import { api } from "../convex/_generated/api"; +import { useState } from "react"; + +function FileUploader() { + const generateUploadUrl = useMutation(api.files.generateUploadUrl); + const saveFile = useMutation(api.files.saveFile); + const [uploading, setUploading] = useState(false); + + const handleUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setUploading(true); + try { + // Step 1: Get upload URL + const uploadUrl = await generateUploadUrl(); + + // Step 2: Upload file to storage + const result = await fetch(uploadUrl, { + method: "POST", + headers: { "Content-Type": file.type }, + body: file, + }); + + const { storageId } = await result.json(); + + // Step 3: Save file reference to database + await saveFile({ + storageId, + fileName: file.name, + fileType: file.type, + fileSize: file.size, + }); + } finally { + setUploading(false); + } + }; + + return ( +
+ + {uploading &&

Uploading...

} +
+ ); +} +``` + +### Saving File References + +```typescript +// convex/files.ts +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; + +export const saveFile = mutation({ + args: { + storageId: v.id("_storage"), + fileName: v.string(), + fileType: v.string(), + fileSize: v.number(), + }, + returns: v.id("files"), + handler: async (ctx, args) => { + return await ctx.db.insert("files", { + storageId: args.storageId, + fileName: args.fileName, + fileType: args.fileType, + fileSize: args.fileSize, + uploadedAt: Date.now(), + }); + }, +}); +``` + +### Serving Files via URL + +```typescript +// convex/files.ts +export const getFileUrl = query({ + args: { storageId: v.id("_storage") }, + returns: v.union(v.string(), v.null()), + handler: async (ctx, args) => { + return await ctx.storage.getUrl(args.storageId); + }, +}); + +// Get file with URL +export const getFile = query({ + args: { fileId: v.id("files") }, + returns: v.union( + v.object({ + _id: v.id("files"), + fileName: v.string(), + fileType: v.string(), + fileSize: v.number(), + url: v.union(v.string(), v.null()), + }), + v.null() + ), + handler: async (ctx, args) => { + const file = await ctx.db.get(args.fileId); + if (!file) return null; + + const url = await ctx.storage.getUrl(file.storageId); + + return { + _id: file._id, + fileName: file.fileName, + fileType: file.fileType, + fileSize: file.fileSize, + url, + }; + }, +}); +``` + +### Displaying Files in React + +```typescript +import { useQuery } from "convex/react"; +import { api } from "../convex/_generated/api"; + +function FileDisplay({ fileId }: { fileId: Id<"files"> }) { + const file = useQuery(api.files.getFile, { fileId }); + + if (!file) return
Loading...
; + if (!file.url) return
File not found
; + + // Handle different file types + if (file.fileType.startsWith("image/")) { + return {file.fileName}; + } + + if (file.fileType === "application/pdf") { + return ( +