diff --git a/skills/trpc-router/SKILL.md b/skills/trpc-router/SKILL.md new file mode 100644 index 0000000..f2e6344 --- /dev/null +++ b/skills/trpc-router/SKILL.md @@ -0,0 +1,224 @@ +--- +name: trpc-router +description: "Scaffold a complete tRPC v11 router for a named entity in this monorepo. Use when creating a new tRPC router, adding API endpoint procedures, or scaffolding CRUD routes. Trigger on phrases like 'create a router for X', 'scaffold X routes', 'I need CRUD for X', 'add X endpoints', or any mention of 'router', 'route', 'CRUD', 'procedure', or 'endpoint' alongside an entity name. Do NOT trigger on edits to existing routers, general API design discussions, config changes, or architecture debates." +--- + +# tRPC Router Scaffolder + +Scaffold a complete new tRPC router for a named entity. Invoked via `/trpc-router [options]` or automatically when the user wants a new API route or endpoint for a named entity. + +## Step -1 — Help flag and interactive mode + +**Handle these before anything else.** + +### --help + +If `--help` or `-h` appears in the arguments, output the following and **stop** — do not scaffold: + +``` +Usage: /trpc-router [options] + +Scaffolds a complete tRPC v11 router for a named entity. + +Arguments: + entity The entity name (any casing — e.g. post, ProductVariant, order-item) + +Options: + --procedures Which procedures to generate (default: all) + all → all, byId, create, update, delete + query → all, byId + mutation → create, update, delete + Or pick individually: byId, create, update, delete + + --auth Auth mode for procedures (default: mixed) + mixed → queries use publicProcedure, mutations use protectedProcedure + public → all procedures use publicProcedure + protected → all procedures use protectedProcedure + + --with-schema Also append a Drizzle table + Zod schemas to packages/db/src/schema.ts + (You'll be asked for column definitions before anything is written) + + --no-tests Skip generating the test file + + --guided Step through all options interactively before scaffolding + + --help, -h Show this help text + +Examples: + /trpc-router post + /trpc-router comment --procedures query --auth public + /trpc-router orderItem --procedures mutation --with-schema --no-tests + /trpc-router user --auth protected --guided +``` + +### Interactive guided mode + +Trigger guided mode when **either** of these is true: +- No entity name was provided (e.g. bare `/trpc-router` or "scaffold a router") +- `--guided` is in the arguments + +**Guided flow:** + +1. **Entity name** *(skip if already provided)* + Ask: `What entity would you like to scaffold a router for?` + Wait for a response. + +2. **Procedures** + Ask: + ``` + Which procedures should I generate? (default: all) + all → all, byId, create, update, delete + query → all, byId only + mutation → create, update, delete only + Or list specific ones: byId, create, delete + ``` + Accept a blank reply or "default" to mean `all`. + +3. **Auth mode** + Ask: + ``` + Auth mode? (default: mixed) + mixed → queries public, mutations protected + public → all procedures public + protected → all procedures protected + ``` + Accept a blank reply or "default" to mean `mixed`. + +4. **Schema** + Ask: `Generate a Drizzle table + Zod schema in schema.ts? (y/n, default: n)` + Accept y/yes to set `--with-schema`. + +5. **Tests** + Ask: `Generate a test file? (y/n, default: y)` + Accept n/no to set `--no-tests`. + +6. **Confirm** — summarise the resolved options before scaffolding: + ``` + Ready to scaffold: + Entity: + Procedures: + Auth: + Schema: + Tests: + + Proceed? (y/n) + ``` + If the user says no or asks to change something, go back to the relevant question. + +Once confirmed, continue to Step 0 with the collected options. + +--- + +## Step 0 — Pre-flight reads + +Before generating any files, read these four files (always): + +- `packages/api/src/root.ts` — router registration pattern, existing routers, collision check +- `packages/api/src/test-helpers.ts` — `makeTestCaller` signature and context shape +- `packages/api/src/trpc.ts` — available procedure types and context fields +- `packages/api/src/index.ts` — package exports (check if the new router needs type re-exports) + +If `--with-schema` was requested, also read: + +- `packages/db/src/schema.ts` — find the exact insertion point, verify imports are not duplicated + +Do **not** read any files under `packages/api/src/router/` — they are template placeholders and may not exist. + +## Step 1 — Parse and validate the entity name + +Accept any casing and normalise: + +| Input | Normalised | +|-------|-----------| +| `ProductVariant` | `productVariant` | +| `product-variant` | `productVariant` | +| `comment` | `comment` | + +Derive these identifiers from the normalised name: + +- **Router key** (in `root.ts`): camelCase — e.g. `productVariant` +- **Router file**: kebab-case — e.g. `product-variant.ts` +- **Router export**: camelCase + `Router` — e.g. `productVariantRouter` +- **Table/class name**: PascalCase — e.g. `ProductVariant` +- **Zod schema names**: `CreateSchema`, `UpdateSchema` — e.g. `CreateProductVariantSchema`, `UpdateProductVariantSchema` + +Check `root.ts` for a collision — if a router with the same key already exists, warn the user and abort. Confirm the result is a valid TypeScript identifier (alphanumeric, starts with a letter). + +## Step 2 — Resolve options + +| Option | Values | Default | +|--------|--------|---------| +| `--procedures` | `all`, `query`, `mutation`, or comma-separated list of: `all`, `byId`, `create`, `update`, `delete` | `all` (all five procedures) | +| `--auth` | `public`, `protected`, `mixed` | `mixed` | +| `--with-schema` | flag | off | +| `--no-tests` | flag | off | + +**Procedure shortcuts:** +- `all` → `all, byId, create, update, delete` +- `query` → `all, byId` +- `mutation` → `create, update, delete` + +When a subset is specified, omit unspecified procedures entirely — no stubs or comments for them. + +**Auth modes:** +- `public` — all procedures use `publicProcedure` +- `protected` — all procedures use `protectedProcedure` +- `mixed` — queries (`all`, `byId`) use `publicProcedure`; mutations (`create`, `update`, `delete`) use `protectedProcedure` + +## Step 3 — Execution order + +This order is mandatory — the router imports from the schema, so the schema must exist first. + +1. **If `--with-schema`**: append table + Zod schema to `packages/db/src/schema.ts` +2. Create `packages/api/src/router/.ts` +3. Register the router in `packages/api/src/root.ts` +4. **Unless `--no-tests`**: create `packages/api/src/router/.test.ts` + +## Step 4 — Generate files using the templates + +Read the relevant template files from the `templates/` folder adjacent to this skill. Each template is a markdown file — extract only the code block (the content inside the ` ```ts ``` ` fence), then substitute the entity placeholders before writing: + +- **Router** → read `templates/router.md`, write to `packages/api/src/router/.ts` +- **Tests** → read `templates/test.md`, write to `packages/api/src/router/.test.ts` +- **Schema** (only with `--with-schema`) → read `templates/schema.md`, append to `packages/db/src/schema.ts` + +Placeholder substitution: + +| Placeholder | Replace with | +|-------------|-------------| +| `` | PascalCase entity name | +| `` | camelCase entity name | +| `` | kebab-case entity name (file names only) | + +**After substitution, apply these adjustments:** + +- Remove any imports that aren't used (e.g. omit `protectedProcedure` if `--auth public`; omit `CreateSchema` if `create` is not included) +- Remove procedures not in the requested set — omit entirely, no stubs +- Adjust `publicProcedure` / `protectedProcedure` per `--auth` mode +- For `--with-schema`: ask the user for entity-specific column definitions before writing — do not generate columns without input + +**Confirm `root.ts` was updated** before presenting the checklist. + +--- + +## Post-scaffolding checklist + +Present this after all files are generated: + +- [ ] Schema appended to `packages/db/src/schema.ts` *(only if `--with-schema`)* +- [ ] Router created at `packages/api/src/router/.ts` +- [ ] Router registered in `packages/api/src/root.ts` +- [ ] Test file created at `packages/api/src/router/.test.ts` *(unless `--no-tests`)* +- [ ] Run `pnpm typecheck` +- [ ] Run `pnpm db:push` *(only if `--with-schema`)* +- [ ] Run `pnpm test` + +--- + +## Constraints + +- Use Australian/British English in all user-facing text +- Zod imports are **always** from `"zod/v4"` — never from `"zod"` +- TypeScript strict mode — no `any` and no `eslint-disable` comments in generated files; use `makeAuthenticatedCaller()` from `test-helpers` for mutation tests (protected procedures), use `makeTestCaller()` for query tests (public procedures) +- Import ordering: `import type` first, then value imports — match the router template exactly +- When a procedure subset is given, omit the rest entirely — no stubs or placeholder comments diff --git a/skills/trpc-router/templates/router.md b/skills/trpc-router/templates/router.md new file mode 100644 index 0000000..f5cd4dc --- /dev/null +++ b/skills/trpc-router/templates/router.md @@ -0,0 +1,53 @@ +# Router Template + +Use to generate `packages/api/src/router/.ts`. + +Substitute all placeholders: +- `` → PascalCase (e.g. `ProductVariant`) +- `` → camelCase (e.g. `productVariant`) + +After substitution, remove any imports that are unused given the selected `--procedures` and `--auth` options (e.g. omit `protectedProcedure` if `--auth public`; omit `CreateSchema` if `create` is excluded). + +```ts +import type { TRPCRouterRecord } from "@trpc/server"; +import { z } from "zod/v4"; + +import { desc, eq } from "@acme/db"; // desc: only if 'all' is included; eq: always +import { CreateSchema, UpdateSchema, } from "@acme/db/schema"; // Omit CreateSchema if 'create' excluded; omit UpdateSchema if 'update' excluded + +import { protectedProcedure, publicProcedure } from "../trpc"; + +export const Router = { + all: publicProcedure.query(({ ctx }) => { + return ctx.db.query..findMany({ + orderBy: desc(.id), + limit: 10, + }); + }), + + byId: publicProcedure + .input(z.object({ id: z.string() })) + .query(({ ctx, input }) => { + return ctx.db.query..findFirst({ + where: eq(.id, input.id), + }); + }), + + create: protectedProcedure + .input(CreateSchema) + .mutation(({ ctx, input }) => { + return ctx.db.insert().values(input); + }), + + update: protectedProcedure + .input(UpdateSchema) + .mutation(({ ctx, input }) => { + const { id, ...data } = input; + return ctx.db.update().set(data).where(eq(.id, id)).returning(); + }), + + delete: protectedProcedure.input(z.string()).mutation(({ ctx, input }) => { + return ctx.db.delete().where(eq(.id, input)); + }), +} satisfies TRPCRouterRecord; +``` diff --git a/skills/trpc-router/templates/schema.md b/skills/trpc-router/templates/schema.md new file mode 100644 index 0000000..67d2369 --- /dev/null +++ b/skills/trpc-router/templates/schema.md @@ -0,0 +1,39 @@ +# Schema Template + +Append to `packages/db/src/schema.ts` — do **not** create a new file. + +The following imports are already present in `schema.ts` — do **not** duplicate them: +- `import { sql } from "drizzle-orm"` +- `import { pgTable } from "drizzle-orm/pg-core"` +- `import { createInsertSchema } from "drizzle-zod"` +- `import { z } from "zod/v4"` + +Ask the user for entity-specific column definitions (name, type, nullable, validation rules) before writing — do not generate columns without input. + +Substitute all placeholders: +- `` → PascalCase (e.g. `ProductVariant`) +- `` → camelCase (e.g. `productVariant`) + +```ts +export const = pgTable("", (t) => ({ + id: t.uuid().notNull().primaryKey().defaultRandom(), + // entity-specific columns here + createdAt: t.timestamp().defaultNow().notNull(), + updatedAt: t + .timestamp({ mode: "date", withTimezone: true }) + .$onUpdateFn(() => new Date()), +})); + +export const CreateSchema = createInsertSchema(, { + // add per-field Zod overrides here if needed +}).omit({ + id: true, + createdAt: true, + updatedAt: true, +}); + +export const UpdateSchema = createInsertSchema() + .omit({ createdAt: true, updatedAt: true }) + .partial() + .required({ id: true }); +``` diff --git a/skills/trpc-router/templates/test.md b/skills/trpc-router/templates/test.md new file mode 100644 index 0000000..327e105 --- /dev/null +++ b/skills/trpc-router/templates/test.md @@ -0,0 +1,87 @@ +# Test Template + +Use to generate `packages/api/src/router/.test.ts`. + +Substitute all placeholders: +- `` → PascalCase (e.g. `ProductVariant`) +- `` → camelCase (e.g. `productVariant`) + +Fill `/* required fields */` with the actual column values needed to satisfy the schema's non-nullable constraints. Use valid RFC 4122 UUIDs for all UUID fields — never bare strings like `"test-uuid"` or zero-padded strings like `"00000000-0000-4000-8000-000000000001"` (the 3rd group must start with `[1-8]` for version, 4th group with `[89ab]` for variant). Use the pattern `"00000000-0000-4000-8000-00000000000X"` for readable test fixtures. + +Omit tests for procedures that were not included in `--procedures`. + +```ts +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { eq } from "@acme/db"; +import { db } from "@acme/db/client"; +import { } from "@acme/db/schema"; + +import { makeAuthenticatedCaller, makeTestCaller } from "../test-helpers"; + +vi.mock("@acme/db/client", async () => { + const { createMockDb } = await import("@acme/db/mocks"); + return { db: await createMockDb() }; +}); + +describe("Router", () => { + beforeEach(async () => { + // If has FK dependencies, delete child tables first, then parents. + // See like.test.ts for an example (deletes Like then Post). + await db.delete(); + }); + + it("should return all s", async () => { + const caller = makeTestCaller(); + const result = await caller..all(); + expect(result).toEqual([]); + }); + + it("should fetch by id — found", async () => { + const record = { id: "00000000-0000-4000-8000-000000000001", /* required fields */ }; + await db.insert().values(record); + + const caller = makeTestCaller(); + const result = await caller..byId({ id: record.id }); + expect(result?.id).toBe(record.id); + }); + + it("should fetch by id — not found", async () => { + const caller = makeTestCaller(); + const result = await caller..byId({ id: "00000000-0000-4000-8000-000000000099" }); + expect(result).toBeUndefined(); + }); + + it("should create a ", async () => { + const caller = makeAuthenticatedCaller(); + + await caller..create({ /* required fields */ }); + + const records = await db.select().from(); + expect(records).toHaveLength(1); + expect(records[0]?./* field */).toBe(/* expected value */); + }); + + it("should update a ", async () => { + const record = { id: "00000000-0000-4000-8000-000000000001", /* required fields */ }; + await db.insert().values(record); + + const caller = makeAuthenticatedCaller(); + await caller..update({ id: record.id, /* fields to update */ }); + + const [updated] = await db.select().from().where(eq(.id, record.id)); + expect(updated?./* updated field */).toBe(/* expected value */); + }); + + it("should delete a ", async () => { + const record = { id: "00000000-0000-4000-8000-000000000001", /* required fields */ }; + await db.insert().values(record); + + const caller = makeAuthenticatedCaller(); + await caller..delete(record.id); + + const records = await db.select().from(); + expect(records).toHaveLength(0); + }); +}); +```