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-generates create, update, and remove procedures with auth, realtime push, userId auto-injection, and updatedAt auto-set. Use procedure.mutation only 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 _delete maps to API name posts.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, call ctx.realtime.refresh("procedureKey") or ctx.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