docs: add Trellis planning and project specs

This commit is contained in:
2026-07-01 06:27:55 -07:00
parent 7f227c5f2a
commit aafd9caaac
349 changed files with 55801 additions and 0 deletions

View File

@@ -0,0 +1,805 @@
# oRPC Backend Usage Guidelines
## 1. Overview
### What is oRPC
oRPC (OpenAPI RPC) is a type-safe RPC framework for TypeScript that provides end-to-end type safety from your backend to frontend. It combines the best aspects of REST APIs and RPC frameworks while generating OpenAPI specifications automatically.
### Why oRPC over tRPC or plain REST
| Feature | oRPC | tRPC | REST |
|---------|------|------|------|
| Type Safety | End-to-end | End-to-end | Manual |
| OpenAPI Generation | Built-in | Plugin required | Manual |
| HTTP Method Control | Full control | Limited | Full control |
| Learning Curve | Low | Low | Medium |
| Middleware Support | Native | Native | Framework-dependent |
| Schema Validation | Zod native | Zod native | Manual |
Key advantages of oRPC:
- **OpenAPI-first**: Automatic OpenAPI spec generation for documentation and client generation
- **HTTP semantics**: Full control over HTTP methods, paths, and tags
- **Type inference**: Automatic TypeScript types from Zod schemas
- **Middleware composition**: Chainable middleware for auth, logging, etc.
### Project Structure with oRPC
```
packages/api/
├── orpc/
│ ├── router.ts # Main router composition
│ ├── procedures.ts # Base procedure definitions
│ └── middleware/ # Reusable middleware
│ ├── log-id-middleware.ts
│ └── locale-middleware.ts
├── modules/
│ └── [module]/
│ ├── router.ts # Module router exports
│ ├── types.ts # Zod schemas and TypeScript types
│ └── procedures/ # Individual procedure implementations
│ ├── create-item.ts
│ ├── list-items.ts
│ └── update-item.ts
└── lib/ # Shared utilities
```
## 2. Router Setup
### Main Router Structure
The main router composes all module routers under a common prefix:
```typescript
// orpc/router.ts
import type { RouterClient } from "@orpc/server";
import { usersRouter } from "../modules/users/router";
import { itemsRouter } from "../modules/items/router";
import { publicProcedure } from "./procedures";
export const router = publicProcedure
// Prefix for OpenAPI paths
.prefix("/api")
.router({
users: usersRouter,
items: itemsRouter,
// Add more module routers here
});
// Export type for frontend client
export type ApiRouterClient = RouterClient<typeof router>;
```
### Module Router Composition
Each module exports a router object that groups related procedures:
```typescript
// modules/items/router.ts
import { createItem } from "./procedures/create-item";
import { deleteItem } from "./procedures/delete-item";
import { findItem } from "./procedures/find-item";
import { listItems } from "./procedures/list-items";
import { updateItem } from "./procedures/update-item";
export const itemsRouter = {
list: listItems,
find: findItem,
create: createItem,
update: updateItem,
delete: deleteItem,
// Nested routes are supported
drafts: {
list: listDrafts,
save: saveDraft,
},
};
```
### Base Procedures with Middleware
Define base procedures with common middleware:
```typescript
// orpc/procedures.ts
import { ORPCError, os } from "@orpc/server";
import { logIdMiddleware } from "./middleware/log-id-middleware";
// Public procedure - no authentication required
export const publicProcedure = os
.$context<{
headers: Headers;
}>()
.use(logIdMiddleware);
// Protected procedure - requires authentication
export const protectedProcedure = publicProcedure.use(
async ({ context, next }) => {
const session = await getSession(context.headers);
if (!session) {
throw new ORPCError("UNAUTHORIZED");
}
return await next({
context: {
session: session.session,
user: session.user,
},
});
},
);
// Admin procedure - requires admin role
export const adminProcedure = protectedProcedure.use(
async ({ context, next }) => {
if (context.user.role !== "admin") {
throw new ORPCError("FORBIDDEN");
}
return await next();
},
);
```
## 3. Procedure Definition
### Query Procedures (GET-like)
Use GET method for read operations that don't modify data:
```typescript
// modules/items/procedures/list-items.ts
import { z } from "zod";
import { protectedProcedure } from "../../../orpc/procedures";
// Define input schema
const listItemsInputSchema = z.object({
limit: z.number().min(1).max(100).default(50),
cursor: z.object({
createdAt: z.string(),
id: z.string(),
}).optional(),
filters: z.object({
status: z.enum(["active", "archived"]).optional(),
category: z.string().optional(),
}).optional(),
});
// Define output schema
const listItemsOutputSchema = z.object({
items: z.array(z.object({
id: z.string(),
name: z.string(),
status: z.string(),
createdAt: z.date(),
})),
nextCursor: z.object({
createdAt: z.string(),
id: z.string(),
}).nullable(),
hasMore: z.boolean(),
});
export const listItems = protectedProcedure
.route({
method: "GET",
path: "/items",
tags: ["Items"],
summary: "List items with cursor pagination",
description: "Retrieve a paginated list of items for the current user",
})
.input(listItemsInputSchema)
.output(listItemsOutputSchema)
.handler(async ({ input, context }) => {
const { limit, cursor, filters } = input;
const { user } = context;
// Query implementation
const items = await db.query.items.findMany({
where: { userId: user.id, ...filters },
limit: limit + 1, // Fetch one extra to check hasMore
orderBy: [desc(items.createdAt), desc(items.id)],
});
const hasMore = items.length > limit;
const resultItems = hasMore ? items.slice(0, limit) : items;
return {
items: resultItems,
nextCursor: hasMore && resultItems.length > 0
? {
createdAt: resultItems[resultItems.length - 1].createdAt.toISOString(),
id: resultItems[resultItems.length - 1].id,
}
: null,
hasMore,
};
});
```
### Mutation Procedures (POST/PUT/DELETE-like)
Use POST for create operations, PUT/PATCH for updates, DELETE for removals:
```typescript
// modules/items/procedures/create-item.ts
import { ORPCError } from "@orpc/client";
import { z } from "zod";
import { protectedProcedure } from "../../../orpc/procedures";
const createItemInputSchema = z.object({
name: z.string().min(1).max(255),
description: z.string().optional(),
categoryId: z.string().optional(),
});
const createItemOutputSchema = z.object({
item: z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable(),
createdAt: z.date(),
}),
});
export const createItem = protectedProcedure
.route({
method: "POST",
path: "/items",
tags: ["Items"],
summary: "Create a new item",
})
.input(createItemInputSchema)
.output(createItemOutputSchema)
.handler(async ({ input, context }) => {
const { name, description, categoryId } = input;
const { user } = context;
// Validate category if provided
if (categoryId) {
const category = await db.query.categories.findFirst({
where: { id: categoryId, userId: user.id },
});
if (!category) {
throw new ORPCError("NOT_FOUND", {
message: "Category not found",
});
}
}
const item = await db.insert(items).values({
name,
description,
categoryId,
userId: user.id,
}).returning();
return { item: item[0] };
});
```
### Update Procedure Example
```typescript
// modules/items/procedures/update-item.ts
import { ORPCError } from "@orpc/client";
import { z } from "zod";
import { protectedProcedure } from "../../../orpc/procedures";
const updateItemInputSchema = z.object({
itemId: z.string(),
name: z.string().min(1).max(255).optional(),
description: z.string().optional(),
status: z.enum(["active", "archived"]).optional(),
});
export const updateItem = protectedProcedure
.route({
method: "PUT",
path: "/items/{itemId}",
tags: ["Items"],
summary: "Update an item",
})
.input(updateItemInputSchema)
.handler(async ({ input, context }) => {
const { itemId, ...updates } = input;
const { user } = context;
// Verify ownership
const existingItem = await db.query.items.findFirst({
where: { id: itemId },
});
if (!existingItem) {
throw new ORPCError("NOT_FOUND", { message: "Item not found" });
}
if (existingItem.userId !== user.id) {
throw new ORPCError("FORBIDDEN", {
message: "You don't have permission to modify this item",
});
}
const updated = await db.update(items)
.set({ ...updates, updatedAt: new Date() })
.where(eq(items.id, itemId))
.returning();
return { item: updated[0] };
});
```
### Input Validation with Zod
oRPC uses Zod for input validation. Define schemas in a separate `types.ts` file for reusability:
```typescript
// modules/items/types.ts
import { z } from "zod";
// Input Schemas
export const createItemInputSchema = z.object({
name: z.string().min(1).max(255),
description: z.string().max(1000).optional(),
tags: z.array(z.string()).max(10).optional(),
});
export const updateItemInputSchema = z.object({
itemId: z.string(),
name: z.string().min(1).max(255).optional(),
description: z.string().max(1000).optional(),
});
export const listItemsInputSchema = z.object({
limit: z.number().int().min(1).max(100).default(50),
cursor: z.object({
createdAt: z.string(),
id: z.string(),
}).optional(),
});
// Output Schemas
export const itemSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable(),
status: z.enum(["active", "archived"]),
createdAt: z.date(),
updatedAt: z.date(),
});
export const operationResultSchema = z.object({
success: z.boolean(),
});
export const batchOperationResultSchema = z.object({
success: z.boolean(),
successCount: z.number(),
failedCount: z.number(),
failedIds: z.array(z.string()).optional(),
});
// Type exports (inferred from schemas)
export type CreateItemInput = z.infer<typeof createItemInputSchema>;
export type UpdateItemInput = z.infer<typeof updateItemInputSchema>;
export type ListItemsInput = z.infer<typeof listItemsInputSchema>;
export type Item = z.infer<typeof itemSchema>;
export type OperationResult = z.infer<typeof operationResultSchema>;
```
## 4. Middleware
### Authentication Middleware
Built into `protectedProcedure`:
```typescript
// orpc/procedures.ts
export const protectedProcedure = publicProcedure.use(
async ({ context, next }) => {
const session = await getSession(context.headers);
if (!session) {
throw new ORPCError("UNAUTHORIZED");
}
// Add user info to context for downstream handlers
return await next({
context: {
session: session.session,
user: session.user,
},
});
},
);
```
### Logging Middleware
Generate and propagate request IDs for tracing:
```typescript
// orpc/middleware/log-id-middleware.ts
import { os } from "@orpc/server";
function generateLogId(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
}
function getOrGenerateLogId(headers: Headers): string {
// Prefer client-provided x-log-id for distributed tracing
const existingLogId = headers.get("x-log-id");
if (existingLogId) {
return existingLogId;
}
return generateLogId();
}
export const logIdMiddleware = os
.$context<{
headers: Headers;
}>()
.middleware(async ({ context, next }) => {
const logId = getOrGenerateLogId(context.headers);
// Run with tracing context
return await runWithTrace(logId, async () => {
return await next({
context: {
logId,
},
});
});
});
```
### Locale Middleware
Extract locale from cookies for i18n:
```typescript
// orpc/middleware/locale-middleware.ts
import { os } from "@orpc/server";
import { getCookie } from "@orpc/server/helpers";
import { config } from "@your-app/config";
import type { Locale } from "@your-app/i18n";
export const localeMiddleware = os
.$context<{
headers: Headers;
}>()
.middleware(async ({ context, next }) => {
const locale = (getCookie(
context.headers,
config.i18n.localeCookieName,
) as Locale) ?? config.i18n.defaultLocale;
return await next({
context: {
locale,
},
});
});
```
### Using Middleware in Procedures
Apply middleware to specific procedures:
```typescript
// modules/contact/procedures/submit-contact-form.ts
import { localeMiddleware } from "../../../orpc/middleware/locale-middleware";
import { publicProcedure } from "../../../orpc/procedures";
export const submitContactForm = publicProcedure
.route({
method: "POST",
path: "/contact",
tags: ["Contact"],
summary: "Submit contact form",
})
.input(contactFormSchema)
.use(localeMiddleware) // Apply locale middleware
.handler(async ({ input, context: { locale } }) => {
// locale is now available in context
await sendEmail({
to: config.contactForm.to,
locale,
subject: config.contactForm.subject,
text: `Name: ${input.name}\n\nEmail: ${input.email}\n\nMessage: ${input.message}`,
});
});
```
### Error Handling Middleware
Create custom error handling:
```typescript
// orpc/middleware/error-middleware.ts
import { ORPCError, os } from "@orpc/server";
import { logger } from "@your-app/logs";
export const errorMiddleware = os.middleware(async ({ next, path }) => {
try {
return await next();
} catch (error) {
// Log error with context
logger.error("Procedure error", {
path,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
// Re-throw oRPC errors as-is
if (error instanceof ORPCError) {
throw error;
}
// Wrap unknown errors
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "An unexpected error occurred",
});
}
});
```
## 5. Context
### How to Access User Session
The session is available in context after `protectedProcedure`:
```typescript
export const getProfile = protectedProcedure
.route({ method: "GET", path: "/users/profile", tags: ["Users"] })
.handler(async ({ context }) => {
// context.user contains the authenticated user
const { user, session } = context;
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
sessionId: session.id,
};
});
```
### How to Access Logger
Use the logger from the shared logs package:
```typescript
import { logger } from "@your-app/logs";
export const createItem = protectedProcedure
.route({ method: "POST", path: "/items", tags: ["Items"] })
.input(createItemInputSchema)
.handler(async ({ input, context }) => {
logger.info("Creating item", {
userId: context.user.id,
itemName: input.name,
});
try {
const item = await db.insert(items).values({
...input,
userId: context.user.id,
}).returning();
logger.info("Item created successfully", { itemId: item[0].id });
return { item: item[0] };
} catch (error) {
logger.error("Failed to create item", {
userId: context.user.id,
error: error instanceof Error ? error.message : String(error),
});
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Failed to create item",
});
}
});
```
### How to Access Database
Import the database client and use it directly:
```typescript
import { db } from "@your-app/database";
import { items, categories } from "@your-app/database/drizzle/schema";
import { eq, and, desc } from "drizzle-orm";
export const listItems = protectedProcedure
.route({ method: "GET", path: "/items", tags: ["Items"] })
.handler(async ({ context }) => {
// Using Drizzle query builder
const userItems = await db.query.items.findMany({
where: eq(items.userId, context.user.id),
orderBy: desc(items.createdAt),
with: {
category: true, // Include relations
},
});
// Or using raw select
const itemsWithCategory = await db
.select({
id: items.id,
name: items.name,
categoryName: categories.name,
})
.from(items)
.leftJoin(categories, eq(items.categoryId, categories.id))
.where(eq(items.userId, context.user.id));
return { items: userItems };
});
```
## 6. Best Practices
### Input/Output Schema Naming Conventions
Follow consistent naming patterns:
```typescript
// Input schemas: [action][Entity]InputSchema
export const createItemInputSchema = z.object({ ... });
export const updateItemInputSchema = z.object({ ... });
export const listItemsInputSchema = z.object({ ... });
export const deleteItemInputSchema = z.object({ ... });
// Output schemas: [action][Entity]OutputSchema or [entity]Schema
export const itemSchema = z.object({ ... });
export const listItemsOutputSchema = z.object({ ... });
export const operationResultSchema = z.object({ ... });
// Shared/reusable schemas: [entity]Schema or [concept]Schema
export const paginationSchema = z.object({
limit: z.number().int().min(1).max(100).default(50),
cursor: z.object({
createdAt: z.string(),
id: z.string(),
}).optional(),
});
```
### Error Handling Patterns
Use appropriate error codes and messages:
```typescript
import { ORPCError } from "@orpc/client";
// Resource not found
throw new ORPCError("NOT_FOUND", {
message: "Item not found",
});
// Permission denied
throw new ORPCError("FORBIDDEN", {
message: "You don't have permission to access this resource",
});
// Authentication required
throw new ORPCError("UNAUTHORIZED", {
message: "Please sign in to continue",
});
// Validation error (usually handled by Zod, but for custom validation)
throw new ORPCError("BAD_REQUEST", {
message: "Invalid email format",
});
// Conflict (e.g., duplicate entry)
throw new ORPCError("CONFLICT", {
message: "An item with this name already exists",
});
// Server error (wrap internal errors)
try {
await externalService.call();
} catch (error) {
logger.error("External service failed", { error });
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Service temporarily unavailable",
});
}
```
### Procedure Organization
1. **One procedure per file**: Keep procedures focused and testable
2. **Group related procedures**: Use module routers to organize by domain
3. **Reuse schemas**: Define common schemas in `types.ts`
4. **Consistent file naming**: Use kebab-case matching the procedure name
```
modules/items/
├── router.ts # Exports all procedures
├── types.ts # Shared schemas and types
└── procedures/
├── create-item.ts # createItem procedure
├── delete-item.ts # deleteItem procedure
├── find-item.ts # findItem procedure
├── list-items.ts # listItems procedure
└── update-item.ts # updateItem procedure
```
### Performance Tips
1. **Use cursor pagination** instead of offset for large datasets
2. **Batch database queries** to avoid N+1 problems
3. **Add appropriate indexes** for filtered/sorted columns
4. **Use select** to fetch only needed columns
```typescript
// Avoid N+1 queries - fetch related data in batch
const items = await db.query.items.findMany({
where: eq(items.userId, user.id),
limit,
});
// Batch fetch labels for all items
const itemIds = items.map(i => i.id);
const labels = await db.query.itemLabels.findMany({
where: inArray(itemLabels.itemId, itemIds),
});
// Group labels by itemId
const labelsByItemId = new Map();
for (const label of labels) {
const existing = labelsByItemId.get(label.itemId) || [];
existing.push(label);
labelsByItemId.set(label.itemId, existing);
}
// Combine results
const itemsWithLabels = items.map(item => ({
...item,
labels: labelsByItemId.get(item.id) || [],
}));
```
### Testing Procedures
Structure tests to cover various scenarios:
```typescript
import { describe, expect, it } from "vitest";
import { createCaller } from "../test-utils";
describe("createItem", () => {
it("creates an item successfully", async () => {
const caller = createCaller({ user: testUser });
const result = await caller.items.create({
name: "Test Item",
description: "A test item",
});
expect(result.item.name).toBe("Test Item");
expect(result.item.id).toBeDefined();
});
it("throws UNAUTHORIZED for unauthenticated users", async () => {
const caller = createCaller({ user: null });
await expect(
caller.items.create({ name: "Test" })
).rejects.toThrow("UNAUTHORIZED");
});
it("validates input schema", async () => {
const caller = createCaller({ user: testUser });
await expect(
caller.items.create({ name: "" }) // Empty name
).rejects.toThrow();
});
});
```