mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
193 lines
4.5 KiB
Markdown
193 lines
4.5 KiB
Markdown
---
|
|
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
|