Trovella Wiki

Router Patterns

Standard patterns for tRPC routers including queries, mutations, pagination, filtering, and the architecture test.

All feature routers follow consistent patterns. This page documents those patterns with real examples from the codebase.

Adding a New Router

  1. Create packages/api/src/routers/kebab-case.ts
  2. Export const camelCaseRouter = router({ ... })
  3. Register in packages/api/src/routers/index.ts (barrel export)
  4. Register in packages/api/src/router.ts (add to appRouter)

If the router introduces a new domain entity, also add a CASL subject -- see Ability Definitions for the steps.

The Standard Query Pattern

Every feature query follows this structure: check CASL, then query using ctx.db.

// From packages/api/src/routers/organization.ts
export const organizationRouter = router({
  detail: authorizedProcedure.query(async ({ ctx }) => {
    if (ctx.ability.cannot("read", "Organization")) {
      throw new TRPCError({ code: "FORBIDDEN" });
    }

    const org = await ctx.db.query.organization.findFirst({
      where: eq(organization.id, ctx.organizationId),
    });

    if (!org) {
      throw new TRPCError({ code: "NOT_FOUND", message: "Organization not found" });
    }

    return org;
  }),
});

Key points:

  • CASL check comes first, before any database query
  • Queries use ctx.db which is already RLS-scoped -- never filter by organization_id manually
  • Not-found resources throw NOT_FOUND with a descriptive message
  • The return value is inferred by TypeScript and flows to the client type-safely

The Standard Mutation Pattern

Mutations validate input via Zod schemas (typically from @repo/validators), check CASL, then write.

// From packages/api/src/routers/organization.ts
update: authorizedProcedure
  .input(updateOrganizationSchema)
  .mutation(async ({ ctx, input }) => {
    if (ctx.ability.cannot("update", "Organization")) {
      throw new TRPCError({ code: "FORBIDDEN" });
    }

    const [updated] = await ctx.db
      .update(organization)
      .set({
        ...(input.name !== undefined && { name: input.name }),
        ...(input.slug !== undefined && { slug: input.slug }),
      })
      .where(eq(organization.id, ctx.organizationId))
      .returning();

    if (!updated) {
      throw new TRPCError({ code: "NOT_FOUND", message: "Organization not found" });
    }

    return updated;
  }),

Key points:

  • Input schemas come from @repo/validators (shared between API and client forms)
  • tRPC runs the Zod schema automatically -- invalid input returns a structured error before the handler runs
  • Mutations use .returning() to get the updated record and confirm the write succeeded
  • Partial updates use spread with undefined checks to only set changed fields

Paginated List Pattern

Most list endpoints accept limit, offset, and optional filters. They return both the items and the total count for pagination.

// From packages/api/src/routers/research-plan.ts
list: authorizedProcedure
  .input(
    z.object({
      limit: z.number().min(1).max(100).default(20),
      offset: z.number().min(0).default(0),
      status: z.enum(["planning", "executing", "awaiting_review", "stalled", "completed", "failed"])
        .optional(),
    }),
  )
  .query(async ({ ctx, input }) => {
    if (ctx.ability.cannot("read", "ResearchPlan")) {
      throw new TRPCError({ code: "FORBIDDEN" });
    }

    const conditions = input.status ? [eq(researchPlan.status, input.status)] : [];
    const where = conditions.length > 0 ? and(...conditions) : undefined;

    const plans = await ctx.db
      .select()
      .from(researchPlan)
      .where(where)
      .orderBy(desc(researchPlan.updatedAt))
      .limit(input.limit)
      .offset(input.offset);

    const [totalRow] = await ctx.db
      .select({ total: count() })
      .from(researchPlan)
      .where(where);

    return { plans, total: totalRow?.total ?? 0 };
  }),

The pattern for building filter conditions:

  1. Start with an empty array
  2. Push conditions for each non-undefined filter
  3. Combine with and(...conditions) if any exist, otherwise pass undefined (no WHERE clause)

Some routers use Promise.all to run the items query and count query in parallel:

// From packages/api/src/routers/research-artifact.ts
const [rows, totalRows] = await Promise.all([
  ctx.db
    .select(/* ... */)
    .from(researchArtifact)
    .where(where)
    .orderBy(desc(researchArtifact.createdAt))
    .limit(input.limit)
    .offset(input.offset),
  ctx.db.select({ count: count() }).from(researchArtifact).where(where),
]);

ABAC (Attribute-Based) Checks

Some operations require checking attributes of the target resource beyond what CASL provides. These checks are written as explicit if statements after loading the target.

// From packages/api/src/routers/member.ts -- members can remove themselves
remove: authorizedProcedure.input(removeMemberSchema).mutation(async ({ ctx, input }) => {
  const target = await ctx.db.query.member.findFirst({
    where: eq(member.id, input.memberId),
  });

  if (!target) {
    throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" });
  }

  const isSelf = target.userId === ctx.session.user.id;
  if (ctx.ability.cannot("delete", "Member") && !isSelf) {
    throw new TRPCError({ code: "FORBIDDEN" });
  }

  if (target.role === "owner") {
    throw new TRPCError({
      code: "FORBIDDEN",
      message: "Cannot remove the organization owner",
    });
  }

  await ctx.db.delete(member).where(eq(member.id, input.memberId));
  return { success: true };
}),

For a detailed discussion of when RBAC vs ABAC checks are appropriate, see Router Enforcement.

User-Scoped Routers (protectedProcedure)

The PAT router is the only router that uses protectedProcedure instead of authorizedProcedure. PATs are user-level resources, not organization-scoped, so they do not need tenant context or CASL authorization.

// From packages/api/src/routers/pat.ts
export const patRouter = router({
  list: protectedProcedure.query(async ({ ctx }) => {
    return listUserPATs(ctx.session.user.id);
  }),

  create: protectedProcedure.input(createPatSchema).mutation(async ({ ctx, input }) => {
    // ... generates token, hashes it, stores in DB
    return { id, token: rawToken };
  }),
});

Using a lower-privilege procedure requires an entry in the architecture test allowlist (see below).

Architecture Test Enforcement

A Vitest test in packages/api/src/__tests__/arch.test.ts scans every file in src/routers/ and verifies it uses authorizedProcedure. Files that intentionally use a different procedure must be listed in AUTHORIZED_PROCEDURE_ALLOWLIST with a justification:

const AUTHORIZED_PROCEDURE_ALLOWLIST: Record<string, string> = {
  "pat.ts": "PAT management is user-level, not org-scoped — uses protectedProcedure",
  "index.ts": "Barrel re-export file",
  "ai-logs-helpers.ts": "Pure helper functions, no procedure definitions",
};

The test fails if any non-allowlisted file uses publicProcedure., protectedProcedure., or tenantProcedure. at a call site. This runs in CI alongside unit tests.

Adding entries to the allowlist is flagged for manual review. See Router Enforcement for the full details on how this works and the review expectations.

Banned Imports

ESLint enforces that routers use context-provided services instead of importing directly:

Banned ImportUse Instead
import { db } from "@repo/db"ctx.db
import { ... } from "@repo/logger"ctx.logger
import { createAIHelper } from "@repo/ai"ctx.ai

These rules ensure that database queries go through the RLS-scoped transaction, logs include request context, and AI calls are tracked per-tenant.

On this page