Loading...
Loading...
ALWAYS use this skill when working with convex or kitcn. Covers both setup and e2e feature paths using cRPC + ORM + auth + React.
npx skill4agent add udecode/kitcn kitcnreferences/setup/index.mdquerymutationactionhttpActionuseCRPC()ctx.ormCRPCErrorcreate<Module>Handler(ctx)create<Module>Caller(ctx)caller.actions.*caller.schedule.*./generated/<module>.runtimectx.runQueryctx.runMutationctx.runActionfindMany/findFirstinsert/update/deletez.object(...)z.void().output(...).input(...).paginated({ limit, item }).query()input.cursorinput.limit{ page, continueCursor, isDone }@convex/apiapi.namespace.fn.meta.meta(...).meta(...)defaultMetaauth: "optional"auth: "required"ctx.ormctx.dbfindMany()limitdefaultLimitallowFullScanwhere.withIndex(...)orderBymaxScanallowFullScancolumnsorderBywhereallowFullScan()count()aggregate()groupBy()aggregateIndexgroupBy({ by, _count, _sum }).count()findManybyeqinisNullwherereferences/features/aggregates.mdsubscribe: truequeryClient.invalidateQueriesprefetchcallerpreloadQueryconvexBetterAuth(...)createCallerFactory(...)createAuthMutations(authClient)authClientctx.runQueryctx.runMutationctx.runActioncreate<Module>Handler(ctx)create<Module>Caller(ctx)convex/functions/generated/<module>.runtimecreate<Module>Handler(ctx)./generated/<module>.runtimecreate<Module>Caller(ctx)ActionCtxcaller.actions.*caller.schedule.now|after|atcaller.schedule.cancel(id)requireActionCtx(ctx)ActionCtxcaller.actions.*MutationCtx | ActionCtxrequireSchedulerCtx(ctx)caller.schedule.*./generated/<module>.runtimeApiApiInputsApiOutputsSelectInsertTableName@convex/apiinferApiInputs<typeof api>httpRouterappRouterconvex/functions/generated/getAuthdefineAuthgenerated/authinitCRPCQueryCtxMutationCtxOrmCtxgenerated/servercreate<Module>Callercreate<Module>Handlergenerated/<module>.runtimeconvex/lib/orm.tsdefineAuth(() => ({ ...options, triggers }))getAuthOptionsauthTriggersbeforeCreate(data)onCreate(doc)onUpdate(newDoc, oldDoc)ctxinternal.generated.*internal.auth.*execute({ batchSize, delayMs })execute({ mode: 'sync' })defineSchema(..., { defaults: { mutationExecutionMode: 'sync' } })mutationBatchSizemutationLeafBatchSizemutationMaxRowsmutationScheduleCallCapactionType: discriminator({ variants, as? })convexTable(...)polymorphicdetailswithVariants: trueone()references/setup/setup/index.mdsetup/server.mdsetup/auth.mdsetup/react.mdsetup/next.mdsetup/start.mdsetup/index.mdpublicoptionalAuthauthprivateconvex/functions/schema.tsconvex/functions/<feature>.tsconvex/lib/crpc.tssrc/lib/convex/crpc.tsxsrc/**convex/functions/http.tsconvex/routers/**convex/functions/crons.tsimport {
convexTable,
defineSchema,
id,
integer,
index,
text,
timestamp,
} from "kitcn/orm";
export const project = convexTable(
"project",
{
name: text().notNull(),
ownerId: id("user").notNull(),
updatedAt: timestamp()
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
},
(t) => [index("ownerId_updatedAt").on(t.ownerId, t.updatedAt)]
);
export const task = convexTable(
"task",
{
projectId: id("project").notNull(),
title: text().notNull(),
status: text().notNull().default("open"),
updatedAt: timestamp()
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
},
(t) => [index("projectId_updatedAt").on(t.projectId, t.updatedAt)]
);
export default defineSchema({ project, task })
.relations((r) => ({
project: {
tasks: r.many.task(),
},
task: {
project: r.one.project({ from: r.task.projectId, to: r.project.id }),
},
}))
.triggers({
task: {
change: async (change, ctx) => {
const projectId = change.newDoc?.projectId ?? change.oldDoc?.projectId;
if (!projectId) return;
const open = await ctx.orm.query.task.findMany({
where: { projectId, status: "open" },
columns: { id: true },
limit: 500,
});
await ctx.orm.update(project).set({ openTaskCount: open.length });
},
},
});many()references/features/orm.mdimport { getHeaders } from "kitcn/auth";
import { CRPCError } from "kitcn/server";
import { getAuth } from "../functions/generated/auth";
import { initCRPC } from "../functions/generated/server";
const c = initCRPC
.meta<{
auth?: "optional" | "required";
role?: "admin";
ratelimit?: string;
}>()
.create();
function requireAuth<T>(user: T | null): T {
if (!user) {
throw new CRPCError({ code: "UNAUTHORIZED", message: "Not authenticated" });
}
return user;
}
export const publicQuery = c.query.meta({ auth: "optional" });
export const authQuery = c.query
.meta({ auth: "required" })
.use(async ({ ctx, next }) => {
const auth = getAuth(ctx);
const session = await auth.api.getSession({
headers: await getHeaders(ctx),
});
const user = requireAuth(session?.user ?? null);
return next({ ctx: { ...ctx, user, userId: user.id } });
});
export const authMutation = c.mutation
.meta({ auth: "optional" })
.use(async ({ ctx, next }) => {
const auth = getAuth(ctx);
const session = await auth.api.getSession({
headers: await getHeaders(ctx),
});
return next({
ctx: {
...ctx,
user: session?.user ?? null,
userId: session?.user?.id ?? null,
},
});
});publicoptionalauthprivateconvex/lib/crpc.ts.meta(...)proceduregenerated/serverexport constmodule:function.name("module:function")c.middleware()c.middleware<MutationCtx>(...)references/setup/server.mdreferences/features/auth*.mdimport { z } from "zod";
import { eq } from "kitcn/orm";
import { CRPCError } from "kitcn/server";
import { authMutation, authQuery } from "../lib/crpc";
import { project } from "./schema";
export const listProjects = authQuery
.paginated({ limit: z.number().min(1).max(50).default(20), item: project })
.query(async ({ ctx, input }) =>
ctx.orm.query.project.findMany({
where: { ownerId: ctx.userId },
orderBy: { updatedAt: "desc" },
cursor: input.cursor,
limit: input.limit,
})
);
export const renameProject = authMutation
.input(z.object({ id: z.string(), name: z.string().min(1).max(120) }))
.mutation(async ({ ctx, input }) => {
const current = await ctx.orm.query.project.findFirst({
where: { id: input.id, ownerId: ctx.userId },
columns: { id: true },
});
if (!current) {
throw new CRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
await ctx.orm
.update(project)
.set({ name: input.name })
.where(eq(project.id, current.id));
return null;
});z.object(...).input(...).output(...).output(...).meta({ ratelimit: ... })CRPCErrorlimit.paginated(...)references/features/orm.mdcreate<Module>Handler(ctx)create<Module>Caller(ctx)caller.actions.*caller.schedule.*ctx.runQueryctx.runMutationctx.runActionwherewhere.withIndex(...)limitmaxScansearch: { index, query, filters }orderByorderBypageByKeyreferences/features/orm.md.returning(...)where(...)unsetToken.execute({ mode: "sync" })references/features/orm.mdBAD_REQUESTUNAUTHORIZEDFORBIDDENNOT_FOUNDCONFLICTTOO_MANY_REQUESTSINTERNAL_SERVER_ERRORdataCRPCErrorerror.datauseCRPC()@convex/apiCRPCProviderreferences/setup/.convex/~/.convexuseCRPC()const crpc = useCRPC(); const projects = useQuery(crpc.project.listProjects.queryOptions({ cursor: null, limit: 20 })); const createProject = useMutation(crpc.project.createProject.mutationOptions());subscribe: truequeryClient.invalidateQueries{ subscribe: false }refetchfetchQueryskipUnauth: trueuseInfiniteQuerykitcn/reactqueryKey(...)createAuthMutations(...)error.data?.messageerror.messagedata.messageCRPCErrorQueryClientonErrormutation.meta.errorMessageskipErrorToastonErrorreferences/features/react.mdprefetch(...)caller.*preloadQuery(...)preloadQueryHydrateClientreferences/setup/next.mdreferences/features/react.mdimport { createTaskCaller } from "../functions/generated/task.runtime";
export const createTaskRoute = authRoute
.post("/api/projects/:projectId/tasks")
.params(z.object({ projectId: z.string() }))
.input(z.object({ title: z.string().min(1) }))
.output(z.object({ id: z.string() }))
.mutation(async ({ ctx, params, input }) => {
const caller = createTaskCaller(ctx);
const id = await caller.createFromHttp({
projectId: params.projectId,
title: input.title,
userId: ctx.userId,
});
return { id };
});z.coerce.*publicRouteauthRouteoptionalAuthRouteconvex/lib/crpc.tsrouter(...){ params, searchParams }references/features/http.mdconst caller = createTaskCaller(ctx); await caller.schedule.now.sendTaskCreated({ taskId: created.id, userId: ctx.userId }); await caller.schedule.at(input.sendAt).sendReminder({ taskId: input.taskId, userId: ctx.userId });ctx.scheduler.*internal.*references/features/scheduling.mdUNAUTHORIZEDFORBIDDENNOT_FOUNDreferences/features/testing.mdlimitwherectx.db.paginated(...).withIndex(...)limitmaxScan@ts-nocheck| Mistake | Correct pattern |
|---|---|
| Raw Convex handler for new feature procedures | cRPC builders ( |
| Write-time side effects duplicated across mutations | Schema trigger, or one centralized mutation-side sync helper when trigger path is unsafe |
| Missing bounds on list/search | Add |
| Use object form: |
Using | Use |
Throwing generic | Throw |
| Infinite list with TanStack native hook directly | Use |
Primitive root input ( | Use root |
Returning nothing with | Omit explicit output |
| Manual pagination wrappers for infinite endpoints | Use |
Synthetic Convex IDs in tests ( | Use inserted IDs or semantic lookup keys |
| Aggregates disabled but helper/config still present | Remove aggregate helper + |
Putting secrets in | Keep metadata non-sensitive (client-visible) |
Using | Use |
Using | Use |
Adding | NEVER do this; fix the underlying types using canonical patterns in |
| Relaxing lint rules to pass checks | Keep baseline lint config; fix code-level warnings/errors instead |
references/setup/index.mdreferences/setup/server.mdreferences/setup/auth.mdreferences/setup/react.mdreferences/setup/next.mdreferences/setup/start.mdreferences/setup/doc-guidelines.mdreferences/features/orm.mdreferences/features/react.mdreferences/features/http.mdreferences/features/scheduling.mdreferences/features/testing.mdreferences/features/aggregates.mdreferences/features/migrations.mdkitcn migratereferences/features/create-plugins.mdreferences/features/auth.mdreferences/features/auth-admin.mdreferences/features/auth-organizations.md