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.
Frontend SDK:
gencow codegenemitsapi.*defs from `@gencow/client`. Use `useMutation` from@gencow/reactin components.
TanStack Query apps: Use TanStack
useMutationwithtanstackApi.*.mutationOptions()from `@gencow/tanstack-query`. Do not mix both mutation stacks for the same procedures.
Recommended:
createCrud(table, { prefix: "tasks" })auto-generatescreate,update, andremoveprocedures with auth, realtime push,userIdauto-injection, andupdatedAtauto-set. Register the definition withdefineApi({ crud: { tasks: tasksCrud } }). 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
.input() and .output() are optional on procedure.mutation. Add .input() when the mutation takes arguments (omit for zero-arg handlers such as some FormData uploads). .output() is strongly recommended when the handler returns data so responses are validated and gencow codegen can emit typed useMutation results in gencow/api.ts. Use any library that implements Standard Schema v1 — the code samples use Zod via drizzle-orm/zod. Gencow's built-in v validator is optional for simple shapes.
Derive table-shaped schemas from your Drizzle tables with createInsertSchema, createUpdateSchema, and createSelectSchema — Drizzle provides integrations for Zod, Valibot, ArkType, and other validators. The examples below use drizzle-orm/zod; use the import path that matches your library. Do not duplicate column definitions by hand. Hand-written schemas are fine for procedure-only payloads (scheduler args, status envelopes) that are not a single table row.
import { z } from "zod";
import { createInsertSchema, createSelectSchema } from "drizzle-orm/zod";
import { procedure } from "./runtime";
import { posts } from "./schema";
const postSchema = createSelectSchema(posts);
const createInput = createInsertSchema(posts).pick({ title: true, content: true });
export const create = procedure.mutation
.name("posts.create")
.input(createInput)
.output(postSchema)
.handler(async ({ context: ctx, input }) => {
const session = ctx.auth.requireAuth();
const [post] = await ctx.db
.insert(posts)
.values({ ...input, userId: session.user.id })
.returning();
return post;
});Key Parts
| Part | Description |
|---|---|
.name("posts.create") |
Unique name — must match {module}.{export} pattern |
.input(schema) |
Optional Standard Schema v1 validator — add when the mutation takes arguments; omit when it takes none |
.output(schema) |
Optional Standard Schema v1 validator for the return value — strongly recommended when the handler returns data |
.handler({ context, input }) |
Async function that writes via context.db, context.storage, etc. |
context.auth.requireAuth() |
Enforces authentication, returns session |
context.db |
Drizzle ORM instance for database writes |
CRUD Patterns
Create
import { createInsertSchema, createSelectSchema } from "drizzle-orm/zod";
import { procedure } from "./runtime";
import { posts } from "./schema";
const postSchema = createSelectSchema(posts);
const createInput = createInsertSchema(posts).pick({ title: true, content: true });
export const create = procedure.mutation
.name("posts.create")
.input(createInput)
.output(postSchema)
.handler(async ({ context: ctx, input }) => {
const session = ctx.auth.requireAuth();
const [post] = await ctx.db
.insert(posts)
.values({ ...input, userId: session.user.id })
.returning();
return post;
});Update (with Ownership Check)
import { z } from "zod";
import { eq, and } from "drizzle-orm";
import { createSelectSchema, createUpdateSchema } from "drizzle-orm/zod";
import { procedure } from "./runtime";
import { posts } from "./schema";
const postSchema = createSelectSchema(posts);
const updateInput = z.object({ id: z.number() }).merge(
createUpdateSchema(posts).pick({ title: true, content: true, published: true }),
);
export const update = procedure.mutation
.name("posts.update")
.input(updateInput)
.output(postSchema)
.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)
import { z } from "zod";
import { eq, and } from "drizzle-orm";
import { procedure } from "./runtime";
import { posts } from "./schema";
// Use _delete (underscore prefix) because 'delete' is a reserved word
export const _delete = procedure.mutation
.name("posts.delete")
.input(z.object({ id: z.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. Omit.output()when the handler returns nothing.
File Upload with FormData
procedure.mutation accepts FormData for file uploads. Files are available in input. Omit .input() for raw FormData bodies; define .output() for the JSON response.
import { z } from "zod";
import { procedure } from "./runtime";
const uploadOutput = z.object({
storageId: z.string(),
url: z.string(),
urlExpiresAt: z.coerce.date(),
name: z.string(),
size: z.number(),
});
export const upload = procedure.mutation
.name("files.upload")
.output(uploadOutput)
.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 readGrant = await ctx.storage.createReadGrant(storageId, {
ttlSeconds: 300,
disposition: "inline",
});
return {
storageId,
url: readGrant.url,
urlExpiresAt: readGrant.expiresAt,
name: file.name,
size: file.size,
};
});Storage is private by default in hosted runtimes. Use ctx.storage.createReadGrant() when a browser needs to preview or download a private upload. For intentionally public website assets, store with { visibility: "public" } and return ctx.storage.getPublicUrl(storageId).
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);
const readGrant = await context.storage.createReadGrant(storageId, {
ttlSeconds: 300,
disposition: "inline",
});
return {
status: 201,
body: { storageId, url: readGrant.url, urlExpiresAt: readGrant.expiresAt },
};
});
// 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:
Keep the starter mutation authenticated, then move the heavy work into bounded internal steps. Do not put crawlers, backfills, imports, or AI fanout behind .allowAnonymous() public mutations. Every long-running path should define a batch limit, an AI fanout budget, ownership scope, and rate/credit limits before it starts external calls or large writes.
In Gencow Cloud, ctx.scheduler.runAfter() uses the BaaS Platform DB-backed
durable scheduler. The scheduled action is persisted outside the tenant app
process, so app sleep or a crash does not erase the callback. Local development
still uses in-memory timers, so restart durability is best-effort locally.
import { z } from "zod";
import { procedure } from "./runtime";
const startInput = z.object({ url: z.string().url() });
const startOutput = z.object({ status: z.literal("started") });
const processInput = z.object({ url: z.string().url(), userId: z.string() });
export const startProcess = procedure.mutation
.name("pipeline.start")
.input(startInput)
.output(startOutput)
.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" as const };
});
export const processStep = procedure.mutation
.name("pipeline.processStep")
.input(processInput)
.handler(async ({ context: ctx, input }) => {
const result = await crawlAndExtract(input.url, { maxPages: 10 });
await ctx.db.insert(results).values({
data: result,
userId: input.userId,
});
});Pattern: Break long pipelines into short, idempotent procedures connected by
ctx.scheduler.runAfter().Use
procedure.internalfor scheduler-only continuation steps when the step should not be callable from browser RPC. Keep the public starter mutation authenticated, store ownership in your own tables, and pass only compact serializable arguments such as IDs and cursors.
For multi-batch work, schedule the next cursor only after the current batch is committed:
export const importBatch = procedure.internal
.name("contacts.importBatch")
.input(z.object({
importId: z.string(),
cursor: z.string().optional(),
userId: z.string(),
}))
.handler(async ({ context: ctx, input }) => {
const batch = await readCsvBatch(input.importId, input.cursor, {
maxRows: 500,
});
await ctx.db.insert(contacts).values(
batch.rows.map((row) => ({
...row,
userId: input.userId,
}))
);
if (batch.nextCursor) {
await ctx.scheduler.runAfter(0, "contacts.importBatch", {
importId: input.importId,
cursor: batch.nextCursor,
userId: input.userId,
});
}
});Choose the primitive by shape:
| Need | Use |
|---|---|
| One delayed callback or next batch | ctx.scheduler.runAfter() / runAt() |
| Recurring schedule | cron + procedure.internal |
| Persisted multi-step state, signals, approvals | workflow() |
Error Handling
Encode validation rules (length, format, enums) in your .input() schema so invalid requests fail with 400 before the handler runs:
import { createInsertSchema, createSelectSchema } from "drizzle-orm/zod";
import { procedure } from "./runtime";
import { posts } from "./schema";
const postSchema = createSelectSchema(posts);
const createInput = createInsertSchema(posts, {
title: (schema) => schema.min(1).max(200),
}).pick({ title: true });
export const create = procedure.mutation
.name("posts.create")
.input(createInput)
.output(postSchema)
.handler(async ({ context: ctx, input }) => {
const session = ctx.auth.requireAuth();
try {
const [post] = await ctx.db
.insert(posts)
.values({ title: input.title, userId: session.user.id })
.returning();
return post;
} catch (e) {
if (e instanceof Error && 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 { z } from "zod";
import { fetchNews } from "./naverApi";
import { procedure } from "./runtime";
export const processNews = procedure.mutation
.name("pipeline.processNews")
.input(z.object({ keyword: z.string().min(1) }))
.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}</p>}
</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.
Input & Output Validation
.input() and .output() accept any Standard Schema v1 validator — use whichever library fits your project (Zod, Valibot, ArkType, etc.). For table-shaped payloads, derive schemas from your Drizzle table with createInsertSchema, createUpdateSchema, and createSelectSchema via the matching Drizzle integration (Zod, Valibot, ArkType, …). Extend or .pick() / .omit() those schemas for client-visible fields (for example omit server-injected userId on create).
Gencow's built-in v validator is optional for simple shapes. See Core API — procedure for the full builder chain.
Security Rules
When the table has an RLS policy, UPDATE/DELETE row filtering is applied automatically at the database level — see Schema:
ctx.db.update(posts).set(data).where(eq(posts.id, id));
ctx.db.delete(posts).where(eq(posts.id, id));
// INSERT — include userId in values for user-owned tables
ctx.db.insert(posts).values({ ...data, userId: session.user.id });Next Steps
- CRUD API — Auto-generated write procedures and schema requirements
- Authentication — Auth setup
- Storage — File upload & download
- Cron Jobs — Scheduled tasks