Files
wiki/.opencode/skill/convex.md

193 lines
4.5 KiB
Markdown
Raw Normal View History

---
description: Convex patterns and conventions
---
# Convex Patterns Reference
Convex patterns and conventions for this markdown publishing framework.
## Function structure
Every Convex function needs argument and return validators:
```typescript
import { query } from "./_generated/server";
import { v } from "convex/values";
export const myQuery = query({
args: { slug: v.string() },
returns: v.union(v.object({...}), v.null()),
handler: async (ctx, args) => {
// implementation
},
});
```
If a function returns nothing, use `returns: v.null()` and `return null;`.
## Always use indexes
Never use `.filter()` on queries. Define indexes in schema and use `.withIndex()`:
```typescript
// Good
const post = await ctx.db
.query("posts")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.first();
// Bad - causes table scans
const post = await ctx.db
.query("posts")
.filter((q) => q.eq(q.field("slug"), args.slug))
.first();
```
## Make mutations idempotent
Mutations should be safe to call multiple times:
```typescript
export const heartbeat = mutation({
args: { sessionId: v.string(), currentPath: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
const now = Date.now();
const existing = await ctx.db
.query("activeSessions")
.withIndex("by_sessionId", (q) => q.eq("sessionId", args.sessionId))
.first();
if (existing) {
// Early return if recently updated with same data
if (existing.currentPath === args.currentPath &&
now - existing.lastSeen < 10000) {
return null;
}
await ctx.db.patch(existing._id, {
currentPath: args.currentPath,
lastSeen: now
});
return null;
}
await ctx.db.insert("activeSessions", { ...args, lastSeen: now });
return null;
},
});
```
## Patch directly without reading
When you only need to update fields, patch directly:
```typescript
// Good - patch directly
await ctx.db.patch(args.id, { content: args.content });
// Bad - unnecessary read creates conflict window
const doc = await ctx.db.get(args.id);
if (!doc) throw new Error("Not found");
await ctx.db.patch(args.id, { content: args.content });
```
## Use event records for counters
Never increment counters on documents. Use separate event records:
```typescript
// Good - insert event record
await ctx.db.insert("pageViews", {
path,
sessionId,
timestamp: Date.now()
});
// Bad - counter updates cause write conflicts
await ctx.db.patch(pageId, { views: page.views + 1 });
```
## Schema indexes in this project
Key indexes defined in `convex/schema.ts`:
```typescript
posts: defineTable({...})
.index("by_slug", ["slug"])
.index("by_published", ["published"])
.index("by_featured", ["featured"])
.searchIndex("search_title", { searchField: "title" })
.searchIndex("search_content", { searchField: "content" })
pages: defineTable({...})
.index("by_slug", ["slug"])
.index("by_published", ["published"])
.index("by_featured", ["featured"])
```
## Common query patterns
### Get post by slug
```typescript
const post = await ctx.db
.query("posts")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.first();
```
### Get all published posts
```typescript
const posts = await ctx.db
.query("posts")
.withIndex("by_published", (q) => q.eq("published", true))
.order("desc")
.collect();
```
### Get featured items
```typescript
const featured = await ctx.db
.query("posts")
.withIndex("by_featured", (q) => q.eq("featured", true))
.collect();
```
### Full text search
```typescript
const results = await ctx.db
.query("posts")
.withSearchIndex("search_content", (q) => q.search("content", searchTerm))
.take(10);
```
## File locations
| File | Purpose |
|------|---------|
| `convex/schema.ts` | Database schema and indexes |
| `convex/posts.ts` | Post queries/mutations |
| `convex/pages.ts` | Page queries/mutations |
| `convex/stats.ts` | Analytics (heartbeat, pageViews) |
| `convex/search.ts` | Full text search |
| `convex/http.ts` | HTTP endpoints |
| `convex/rss.ts` | RSS feed generation |
| `convex/crons.ts` | Scheduled jobs |
## Write conflict prevention
This project uses specific patterns to avoid write conflicts:
**Backend (convex/stats.ts):**
- 10-second dedup window for heartbeats
- Early return when session was recently updated
- Indexed queries for efficient lookups
**Frontend (src/hooks/usePageTracking.ts):**
- 5-second debounce window using refs
- Pending state tracking prevents overlapping calls
- Path tracking skips redundant heartbeats