oneie
Version:
Build apps, websites, and AI agents in English. Zero-interaction setup for AI agents (Claude Code, Cursor, Windsurf). Download to your computer, run in the cloud, deploy to the edge. Open source and free forever.
250 lines (212 loc) • 7.19 kB
Markdown
---
title: Mutation Template
dimension: knowledge
category: patterns
tags: ai, auth, backend, events
related_dimensions: events, groups, people, things
scope: global
created: 2025-11-03
updated: 2025-11-03
version: 1.0.0
ai_context: |
This document is part of the knowledge dimension in the patterns category.
Location: one/knowledge/patterns/backend/mutation-template.md
Purpose: Documents pattern: convex mutation template
Related dimensions: events, groups, people, things
For AI agents: Read this to understand mutation template.
---
# Pattern: Convex Mutation Template
**Category:** Backend
**Context:** When creating Convex mutations that modify data (create, update, delete)
**Problem:** Need consistent mutation structure that uses services, handles auth, and logs events
## Solution
Keep mutations thin - just validate auth, call service, handle errors. All business logic lives in services.
## Template
```typescript
// backend/convex/mutations/{entities}.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { {EntityName}Service, {EntityName}ServiceLive } from "./services/{EntityName}Service";
import { Effect } from "effect";
export const create = mutation({
args: {
name: v.string(),
properties: v.any(),
},
handler: async (ctx, args) => {
// 1. Check authentication
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Unauthorized");
}
// 2. Get group ID (multi-tenant)
const groupId = identity.groupId;
if (!groupId) {
throw new Error("No group");
}
// 3. Call service
const program = {EntityName}Service.create({
name: args.name,
properties: args.properties,
groupId: groupId,
});
// 4. Run Effect program
const result = await Effect.runPromise(
program.pipe(
Effect.provideService({EntityName}Service, {EntityName}ServiceLive),
Effect.catchAll((error) => {
// Map service errors to user-friendly messages
switch (error._tag) {
case "{EntityName}ValidationError":
return Effect.fail(new Error(error.message));
case "{EntityName}AlreadyExists":
return Effect.fail(new Error(`{EntityName} "${error.name}" already exists`));
case "DatabaseError":
return Effect.fail(new Error("Database error: " + error.message));
default:
return Effect.fail(new Error("Unknown error"));
}
})
)
);
return result;
},
});
export const update = mutation({
args: {
id: v.id("{entities}"),
name: v.optional(v.string()),
properties: v.optional(v.any()),
status: v.optional(v.union(v.literal("draft"), v.literal("active"), v.literal("archived"))),
},
handler: async (ctx, args) => {
// 1. Check authentication
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Unauthorized");
}
// 2. Check ownership (entity belongs to user's group)
const entity = await ctx.db.get(args.id);
if (!entity || entity.groupId !== identity.groupId) {
throw new Error("Not found or unauthorized");
}
// 3. Call service
const { id, ...updates } = args;
const program = {EntityName}Service.update(id, updates);
// 4. Run Effect program
await Effect.runPromise(
program.pipe(
Effect.provideService({EntityName}Service, {EntityName}ServiceLive),
Effect.catchAll((error) => {
switch (error._tag) {
case "{EntityName}NotFound":
return Effect.fail(new Error("{EntityName} not found"));
case "DatabaseError":
return Effect.fail(new Error("Database error: " + error.message));
default:
return Effect.fail(new Error("Unknown error"));
}
})
)
);
return { success: true };
},
});
export const remove = mutation({
args: {
id: v.id("{entities}"),
},
handler: async (ctx, args) => {
// 1. Check authentication
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Unauthorized");
}
// 2. Check ownership
const entity = await ctx.db.get(args.id);
if (!entity || entity.organizationId !== identity.orgId) {
throw new Error("Not found or unauthorized");
}
// 3. Call service (soft delete)
const program = {EntityName}Service.delete(args.id);
// 4. Run Effect program
await Effect.runPromise(
program.pipe(
Effect.provideService({EntityName}Service, {EntityName}ServiceLive),
Effect.catchAll((error) => {
return Effect.fail(new Error("Failed to delete: " + error._tag));
})
)
);
return { success: true };
},
});
```
## Variables
- `{EntityName}` - PascalCase entity name (e.g., `Course`, `Lesson`)
- `{entities}` - Table name (always "entities" in our ontology)
## Usage
1. Copy template to `backend/convex/mutations/{entities}.ts`
2. Replace all `{EntityName}` with your entity name
3. Import corresponding service
4. Add additional mutations as needed
5. Export from `backend/convex/_generated/api.ts`
## Example (Course Mutations)
```typescript
// backend/convex/mutations/entities.ts
import { CourseService, CourseServiceLive } from "./services/CourseService";
export const createCourse = mutation({
args: {
name: v.string(),
description: v.string(),
price: v.number(),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthorized");
const program = CourseService.create({
name: args.name,
properties: {
description: args.description,
price: args.price,
},
groupId: identity.groupId,
});
return await Effect.runPromise(
program.pipe(
Effect.provideService(CourseService, CourseServiceLive),
)
);
},
});
export const publishCourse = mutation({
args: {
id: v.id("entities"),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthorized");
const program = CourseService.publish(args.id);
return await Effect.runPromise(
program.pipe(
Effect.provideService(CourseService, CourseServiceLive),
)
);
},
});
```
## Common Mistakes
- **Mistake:** Business logic in mutation handler
- **Fix:** Move logic to service, keep mutation thin
- **Mistake:** Not checking authentication
- **Fix:** Always verify `ctx.auth.getUserIdentity()` first
- **Mistake:** Not checking group ownership
- **Fix:** Verify entity belongs to user's group (multi-tenancy)
- **Mistake:** Throwing raw service errors
- **Fix:** Map service errors to user-friendly messages
- **Mistake:** Not using Effect.catchAll
- **Fix:** Always handle errors explicitly
## Related Patterns
- **service-template.md** - Service implementation that mutations call
- **query-template.md** - Read-only operations
- **event-logging.md** - How to log events from mutations