docs: add Trellis planning and project specs
This commit is contained in:
805
.trellis/spec/backend/orpc-usage.md
Normal file
805
.trellis/spec/backend/orpc-usage.md
Normal 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();
|
||||
});
|
||||
});
|
||||
```
|
||||
Reference in New Issue
Block a user