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 codegen emits api.* defs from `@gencow/client`. Use `useMutation` from @gencow/react in components.

TanStack Query apps: Use TanStack useMutation with tanstackApi.*.mutationOptions() from `@gencow/tanstack-query`. Do not mix both mutation stacks for the same procedures.

Recommended: createCrud(table, { prefix: "tasks" }) auto-generates create, update, and remove procedures with auth, realtime push, userId auto-injection, and updatedAt auto-set. Register the definition with defineApi({ crud: { tasks: tasksCrud } }). 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

.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 _delete maps to API name posts.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.internal for 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, call ctx.realtime.refresh("procedureKey") or ctx.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