tRPC Conventions
SkillDev toolsEsposter tRPC conventions — return-type generics on the method, async only when there is an await, where input schemas, server event emitters and server/shared/@esposter-db helpers live, useQuery/useMutation for every client read and write, calling conventions for all-optional and UUID inputs, router structure mirroring the file path, Function.prototype router-key collisions, procedure and result naming, base*Router composition with mergeRouters, the three room RBAC procedure builders and a read taking the builder its data deserves rather than the one its caller's UI implies, ownedBy ownership guards, one router and store per DB table, BAD_REQUEST messages, plus deep dives on router tests, subscription procedures, read/pagination endpoints, and mutations that write blobs. Apply when writing tRPC routers, procedures, or router tests.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the tRPC Conventions skill
What this skill tells your AI
The instructions your AI receives, as published by esposter/esposter in .agents/skills/trpc/SKILL.md and read by ahel’s review.
Deep dives
references/router-tests.md— when writing or reviewing a test that drives a tRPC caller.references/subscriptions.md— when adding a subscription procedure, or deciding whether the caller of a mutation also updates its own store.references/read-endpoints.md— when writing aread*procedure, its pagination input schema, or theuseRead*composable that calls it.references/blob-mutations.md— when a mutation deletes or replaces a blob.
Procedures
- Return type generic on the method, not as a callback return annotation —
readFoos: standardAuthedProcedure.query<Foo[]>(async ({ ctx }) => { ... }). Same for.mutation<T>(...).- A procedure that returns nothing still writes
<void>. The generic pins a public API surface, so a handler that later grows areturnis a compile error rather than a silently widened response every client can now read.typescript/no-invalid-void-typeis off for exactly this: a generic type argument is a position upstream allows by default, oxlint does not implement that option, and the config yields rather than the correct call sites.
- A procedure that returns nothing still writes
- Omit
asyncwhen there is noawait— e.g. a body that onlyreturns a Drizzle query chain.
Where the Pieces Live
- Input schemas →
shared/models/db/<feature>/— one file per input type, named after the type (FooBarIdInput.ts), exporting both the schema (...Schema) and the inferred type. Never re-export types from router files; each type lives in exactly one place. One file per procedure, even where two procedures take the same shape —assignRoleandrevokeRoletake an identical room/user/role triple, and the three room-list reads take an identical non-emptyroomIds; collapsing them onto one shared input couples procedures that are free to diverge, and a review that reads the identical files as duplication is reading the pattern rather than a fault. - Server-only utility functions →
server/services/<feature>/— one function per file, named after the function. - Also needed by a Pinia store →
shared/services/<feature>/— importable on both server and client without duplication. - Also needed by
apps/functions→packages/db/src/services/— Postgres/Drizzle helpers called from both the Nuxt app server and Azure Functions, withDatabase(from@esposter/db-schema) as thedbparameter, exported frompackages/db/src/index.ts. Callers import from@esposter/dbdirectly — a shared function never gets a local re-export file, which only adds a second name to grep for. Error-throwing wrappers (assertCanCreateFooetc.) stay in their own packages because they throw package-specific types (TRPCErrorin the app,InvalidOperationErrorin azure-functions) — only the underlying DB query helpers move. - Server event emitters →
server/services/<feature>/events/<name>EventEmitter.ts— one emitter per file under the feature that owns the events it carries, never a sharedserver/services/events/bucket. The emitter is the feature's own surface: the subscription procedure and every mutation that fires it already live in that feature, so a central bucket would be the only file in the graph that imports all of them.
Client-Side Calling Conventions
-
Every user-facing client read/write goes through
useQuery/useMutation(composables/shared/). Before hand-rolling agetResultAsync(...)around a$trpccall, confirm it matches a documented exception — the raw call sites are deliberate, not omissions. Primitive semantics, "Optimistic by default" and the full exception list:apps/web/content/docs/architecture/client-data.md. -
Never call
.query({})/.mutate({})with a bare empty object — all-optional inputs chain.prefault({}), which makes the input itself optional:$trpc.foo.readFoos.query(). Same for test callers:caller.readFoos(). -
Omit optional UUID fields instead of passing
undefined— when the value comes from a ref defaulting to"", use a conditional spread, not|| undefined:// key absent when empty — not { barId: currentBarId.value || undefined } $trpc.foo.readFoos.query(currentBarId.value ? { barId: currentBarId.value } : {}); -
Guard required UUID fields with an early return —
if (!currentBarId.value) return;before the call, rather than letting an empty string reach the UUID validator.
Router Structure
Routers nested by domain. Root merger: server/trpc/routers/index.ts. The client path mirrors the file path segment for segment — trpc.<feature>.* is routers/<feature>/index.ts and trpc.<feature>.<sub>.* is routers/<feature>/<sub>.ts — so a nested key is never flattened, and the file for any path is derivable rather than looked up. The two diverge only where a key was renamed to dodge a Function.prototype collision.
-
Sub-routers compose in the feature's own
index.ts— export abase*Routerwith the feature's own procedures, thenmergeRoutersit with the sub-routers.routers/index.tsimports only the composed router, never a sub-router directly.// routers/foo/index.ts — the composition root export const baseFooRouter = router({ createFoo: ..., updateFoo: ... }); export const fooRouter = mergeRouters(baseFooRouter, router({ bar: barRouter })); -
Exception:
achievementis merged separately (viamergeRouters) to avoid a circular dep with the router that fires achievement events. -
Never use
call,apply,bind,then,catchas router keys — they areFunction.prototypemethods, and tRPC clients use aProxy, so.callreturnsFunction.prototype.callinstead of descending the router, silently breaking the namespace. Use a descriptive compound name:callSession,videoCall,roomCall.
Procedure & Result Naming
- Every query names its verb:
read*for a fetch,search*for a ranked query,generate*for a minted credential (a SAS entity, a Web PubSub access url). A bare noun (buildVersion) and aget*procedure are both wrong —get*is for derivation, which is not what a network round trip is. - A query answering with a count is a
read*Count—readResourcesCount,readMembersCount,readResourceViewCount,readSurveyResponsesCount. There is no second spelling: whether the caller drove the tally with filters or asked for a number belonging to one subject makes no difference to the name, because a reader cannot tell those apart and neither can the next author. - A grouping answers with rows rather than a number, so it is plural and named for what it returns —
readResourceTagCounts,readMemberCountsByTopRole, matching theResourceTagCount[]/MemberCountByTopRole[]it hands back. No procedure is namedcount*; that prefix is a pure in-memory tally (naming), which is not a network round trip. - A named type for what a procedure answers with ends in
Result—ReadInviteResult,JoinCallResult— neverOutput, which is the same idea under a second name and leaves the tree with two spellings of one convention. The type is named for the procedure, so it renames when the procedure does. upsert*for procedures that doinsert().onConflictDoUpdate()— neverupdate*(update implies the record already exists). Domain operation names (subscribe,connect) are exempt.- Subscription naming:
on+ exact mutation name (camelCase):createFoo→onCreateFoo. - DB result variables named after the entity:
newFoo,updatedFoo,existingFoo— nevercreated,updated,existing.
Procedure Helpers (Room RBAC)
Three builders in server/trpc/procedure/room/:
getMemberProcedure(schema, roomIdKey)— verifies caller is a room member; standard message/room operations.getPermissionsProcedure(permission, schema, roomIdKey, rateLimiterType?)— verifies caller has a specificRoomPermission; most common for moderation/admin.getOwnerProcedure(schema, roomIdKey, rateLimiterType?)— verifies caller owns the room; destructive room operations.
rateLimiterType defaults to RateLimiterType.Standard; pass another only to opt into a different limiter.
A read takes the builder its data deserves, never the one its caller's UI implies. Hiding a control or a settings panel from a caller who lacks a permission is presentation — the procedure behind it stays callable by anyone the client reaches. So a read whose data is only shown inside a permission-gated surface takes getPermissionsProcedure with that same permission, and getMemberProcedure is correct only where the data is genuinely the room's to see. Deciding it from the surface is how a getMemberProcedure ends up behind a ManageRoom panel; the exception, where a management panel reads data members already see elsewhere, is stated at the procedure (/docs/esbabbler/rbac).
Ownership Guards in Mutations
ownedBy(table, id, userId)(server/services/db/ownedBy.ts) — the where-predicate for "this row must belong to the caller":.where(ownedBy(foos, input, ctx.getSessionPayload.user.id)). Compose extra clauses withand(ownedBy(...), isNull(...)). Never hand-writeand(eq(table.id, id), eq(table.userId, userId)).- Repeated multi-clause where-fragments within one router (e.g. "not cancelled and not completed") get a named module-level
const/helper in that router file. A parameterised one is a function, so it is namedget*Where—getRoomMembershipWhere(roomId, userId), matchinggetCursorWhereandgetNotBlockedWhereinserver/services/; only a fragment that takes no arguments is a bare*Whereconst, because that name is then a value rather than a call.
Router and Store Structure
- One router + one Pinia store per DB table — never bundle multiple tables into one router or store.
- Naming derived from the table name, not semantics —
foo_bars→fooBarsstore ref andreadFooBarsprocedure, never a semantic rename of the same rows (the table implies the state). - Nuxt does NOT auto-import store functions — always
import { useXxxStore } from "@/store/..."when calling other stores. Avoid circular imports with a one-way dependency direction:blockmay importfriend+friendRequest;friendRequestmay importfriend;friendimports neither.
Error Handling
BAD_REQUESTalways includes amessage— never a barenew TRPCError({ code: "BAD_REQUEST" }). Usemessage: new InvalidOperationError(Operation.X, EntityType, name).message, picking theOperationmatching the procedure (Operation.Readfor a query;Create/Update/Deletefor mutations), the entity type, and anameidentifying the invalid value (JSON.stringify(input), the relevant ID).- The
typescriptskill'sif/else ifchain rule applies inside procedure bodies: an early-exitifthat throws is followed byelse if, even when the conditions are logically independent.
Signals
- GitHub stars
- 23
- Forks
- 3
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
trpc-esposter- Source
- github.com/esposter/esposter