Mutations
Write data with type-safe procedures — createCrud() auto-generation and custom procedure.mutation
Write procedures create, update, or delete data. Import procedure and createCrud from gencow/runtime.ts, not from @gencow/core directly.
Recommended:
createCrud(table)auto-generatescreate,update, andremoveprocedures with auth, realtime push,userIdauto-injection, andupdatedAtauto-set. Useprocedure.mutationonly for custom business logic.
createCrud() auto-generates these write procedures — no code needed:
| Procedure | What it does |
|---|---|
create |
Insert row, auto-inject userId, emit realtime { data, total } |
update |
Update row by id, auto-set updatedAt, emit realtime |
remove |
Delete row by id (or soft-delete), emit realtime |
When to write manual procedure.mutation
| Use Case | createCrud() | procedure.mutation |
|---|---|---|
| Simple insert/update/delete | ✅ | — |
| Custom validation logic | — | ✅ |
| External API calls | — | ✅ |
| Multi-table transactions | — | ✅ |
| File upload processing | — | ✅ |
| AI/LLM calls | — | ✅ |
Defining Mutations
import { v } from "@gencow/core";
import { eq, and } from "drizzle-orm";
import { procedure } from "./runtime";
import { posts } from "./schema";
export const create = procedure.mutation
.name("posts.create")
.input(v.object({
title: v.string(),
content: v.optional(v.string()),
}))
.handler(async ({ context: ctx, input }) => {
const session = ctx.auth.requireAuth();
const [post] = await ctx.db
.insert(posts)
.values({ title: input.title, content: input.content, userId: session.user.id })
.returning();
return post;
});CRUD Patterns
Create
export const create = procedure.mutation
.name("posts.create")
.input(v.object({ title: v.string(), content: v.optional(v.string()) }))
.handler(async ({ context: ctx, input }) => {
const session = ctx.auth.requireAuth();
const [post] = await ctx.db
.insert(posts)
.values({ title: input.title, content: input.content, userId: session.user.id })
.returning();
return post;
});Update (with Ownership Check)
export const update = procedure.mutation
.name("posts.update")
.input(v.object({
id: v.number(),
title: v.optional(v.string()),
content: v.optional(v.string()),
published: v.optional(v.boolean()),
}))
.handler(async ({ context: ctx, input }) => {
const session = ctx.auth.requireAuth();
const { id, ...updates } = input;
const data = Object.fromEntries(
Object.entries(updates).filter(([_, v]) => v !== undefined)
);
const [updated] = await ctx.db
.update(posts)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(posts.id, id), eq(posts.userId, session.user.id)))
.returning();
if (!updated) throw new Error("Post not found");
return updated;
});Delete (with Ownership Check)
// Use _delete (underscore prefix) because 'delete' is a reserved word
export const _delete = procedure.mutation
.name("posts.delete")
.input(v.object({ id: v.number() }))
.handler(async ({ context: ctx, input }) => {
const session = ctx.auth.requireAuth();
await ctx.db
.delete(posts)
.where(and(
eq(posts.id, input.id),
eq(posts.userId, session.user.id)
));
});Important: Export name
_deletemaps to API nameposts.delete. The underscore prefix is automatically handled.
File Upload with FormData
procedure.mutation accepts FormData for file uploads. Files are available in input:
import { procedure } from "./runtime";
export const upload = procedure.mutation
.name("files.upload")
.handler(async ({ context: ctx, input }) => {
ctx.auth.requireAuth();
const file = input?.["file"] as File;
if (!file || typeof file === "string") {
throw new Error("No file provided");
}
const storageId = await ctx.storage.store(file);
const url = ctx.storage.getUrl(storageId);
return { storageId, url, name: file.name, size: file.size };
});Alternative: httpRoute for File Upload
For more control over the HTTP request, use httpRoute from ./runtime. Register it via defineApi({ httpRoutes }):
// gencow/files.ts
import { httpRoute } from "./runtime";
import { defineApi } from "@gencow/core";
export const upload = httpRoute.post
.path("/upload")
.handler(async ({ context, request }) => {
context.auth.requireAuth();
const formData = await request.formData();
const file = formData.get("file") as File;
if (!file) return { status: 400, body: { error: "No file provided" } };
const storageId = await context.storage.store(file);
return {
status: 201,
body: { storageId, url: context.storage.getUrl(storageId) },
};
});
// gencow/index.ts
import { defineApi } from "@gencow/core";
import { upload } from "./files";
export default defineApi({
httpRoutes: { upload },
});
httpRoute.post.path(...)returns an authenticated route by default. Add.allowAnonymous()only when the endpoint is meant to be open.
React Upload Example
const { mutate: upload, isPending } = useMutation(api.files.upload);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append("file", file);
await upload(formData);
};Scheduling Long-Running Tasks
Procedures have a 30-second timeout. For tasks that take longer (AI calls, external APIs, crawling), split them into steps:
import { procedure } from "./runtime";
import { v } from "@gencow/core";
export const startProcess = procedure.mutation
.name("pipeline.start")
.input(v.object({ url: v.string() }))
.handler(async ({ context: ctx, input }) => {
const session = ctx.auth.requireAuth();
const isValid = await validateUrl(input.url);
if (!isValid) throw new Error("Invalid URL");
await ctx.scheduler.runAfter(0, "pipeline.processStep", {
url: input.url,
userId: session.user.id,
});
return { status: "started" };
});
export const processStep = procedure.mutation
.name("pipeline.processStep")
.input(v.object({ url: v.string(), userId: v.string() }))
.handler(async ({ context: ctx, input }) => {
const result = await crawlAndExtract(input.url);
await ctx.db.insert(results).values({
data: result,
userId: input.userId,
});
});Pattern: Break long pipelines into short procedures connected by
ctx.scheduler.runAfter().⚠️ Cloud Warning:
scheduler.runAfter()is sleep-unsafe — if the app is idle and enters sleep mode, scheduled callbacks will be lost.
Error Handling
export const create = procedure.mutation
.name("posts.create")
.input(v.object({ title: v.string() }))
.handler(async ({ context: ctx, input }) => {
const session = ctx.auth.requireAuth();
if (input.title.length > 200) {
throw new Error("Title too long (max 200 characters)");
}
try {
const [post] = await ctx.db
.insert(posts)
.values({ title: input.title, userId: session.user.id })
.returning();
return post;
} catch (e) {
if (e.message.includes("unique")) {
throw new Error("A post with this title already exists");
}
throw e;
}
});React Error Handling
const { mutate: create, isPending, error } = useMutation(api.posts.create);
{error && <div className="error">{error.message}</div>}Calling Other Functions Within Mutations
When you need to call another module's function within a mutation, import it directly — don't use fetch:
import { fetchNews } from "./naverApi";
import { procedure } from "./runtime";
import { v } from "@gencow/core";
export const processNews = procedure.mutation
.name("pipeline.processNews")
.input(v.object({ keyword: v.string() }))
.handler(async ({ context: ctx, input }) => {
const result = await fetchNews.handler(ctx, { keyword: input.keyword });
return result;
});Using from React
import { useMutation } from "@gencow/react";
import { api } from "../gencow/api";
function CreatePost() {
const { mutate: createPost, isPending, error } = useMutation(api.posts.create);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const data = new FormData(e.currentTarget as HTMLFormElement);
await createPost({
title: data.get("title") as string,
content: data.get("content") as string,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Post title..." required />
<textarea name="content" placeholder="Content..." />
<button disabled={isPending}>
{isPending ? "Creating..." : "Create Post"}
</button>
{error && <p className="error">{error.message}</div>}
</form>
);
}Realtime sync:
createCrud()write procedures automatically push updates via WebSocket. For custom mutations, callctx.realtime.refresh("procedureKey")orctx.realtime.emit("procedureKey", data)— see Realtime Guide.
Security Rules
With ownerRls(), UPDATE/DELETE filters are applied automatically by Postgres RLS:
ctx.db.update(posts).set(data).where(eq(posts.id, id));
ctx.db.delete(posts).where(eq(posts.id, id));
// ✅ INSERT — always include userId in values
ctx.db.insert(posts).values({ ...data, userId: session.user.id });Next Steps
- Authentication — Auth setup
- Storage — File upload & download
- Cron Jobs — Scheduled tasks