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,351 @@
# AI SDK Backend Integration Guidelines
## 1. Overview
This document covers backend integration patterns using the Vercel AI SDK (`ai` package) for AI-powered features.
### Supported Providers
- **OpenAI**: GPT-4o, GPT-4o-mini, GPT-4-turbo
- **Google Gemini**: gemini-1.5-pro, gemini-1.5-flash
- **Anthropic**: Claude 3.5 Sonnet, Claude 3 Opus
### Package Dependencies
```bash
pnpm add ai @ai-sdk/openai @ai-sdk/google @ai-sdk/anthropic
```
## 2. Basic Usage
### generateText
Use `generateText` for simple text generation tasks where you need a complete response.
```typescript
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
const { text } = await generateText({
model: openai("gpt-4o-mini"),
prompt: "Summarize this document...",
});
```
### generateObject (Structured Output with Zod)
Use `generateObject` when you need type-safe structured output. The AI SDK validates the response against your Zod schema automatically.
```typescript
import { generateObject } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
const classificationSchema = z.object({
category: z.enum(["urgent", "normal", "low"]),
confidence: z.number().min(0).max(1),
reasoning: z.string(),
});
const { object } = await generateObject({
model: openai("gpt-4o-mini"),
schema: classificationSchema,
prompt: "Classify the priority of this task...",
});
// object is typed as { category: "urgent" | "normal" | "low", confidence: number, reasoning: string }
```
### streamText (For SSE/Streaming)
Use `streamText` for real-time streaming responses, ideal for chat interfaces and long-form content generation.
```typescript
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";
const result = streamText({
model: openai("gpt-4o"),
messages: conversationHistory,
system: "You are a helpful assistant.",
});
// Return as SSE stream
return result.toDataStreamResponse();
```
## 3. Telemetry Configuration
**IMPORTANT**: Always enable telemetry for token tracking and performance monitoring.
```typescript
import { generateObject } from "ai";
import { openai } from "@ai-sdk/openai";
const { object } = await generateObject({
model: openai("gpt-4o-mini"),
schema: mySchema,
prompt,
experimental_telemetry: {
isEnabled: true,
functionId: "orders.classify", // Module.function naming
metadata: {
orderId,
userId,
},
},
});
```
### Telemetry Naming Convention
Use dot-separated format for `functionId`: `module.function`
| Module | Example functionId |
|--------|-------------------|
| Orders | `orders.classify`, `orders.summarize` |
| Support | `support.generateReply`, `support.categorize` |
| Content | `content.summarize`, `content.translate` |
| Users | `users.analyzePreferences` |
### Auto-recorded Metrics
When telemetry is enabled, these metrics are automatically tracked:
| Metric | Description |
|--------|-------------|
| `ai.model.id` | Model identifier (e.g., gpt-4o-mini) |
| `ai.model.provider` | Provider name (e.g., openai) |
| `ai.usage.prompt_tokens` | Input tokens consumed |
| `ai.usage.completion_tokens` | Output tokens generated |
| `ai.usage.total_tokens` | Total tokens used |
| `ai.response.finish_reason` | Completion reason (stop, length, etc.) |
## 4. Tool Calling
Define tools that the AI model can invoke to perform actions in your system.
```typescript
import { generateText, tool } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
const result = await generateText({
model: openai("gpt-4o"),
prompt: "Create a task for the user...",
tools: {
createTask: tool({
description: "Create a new task in the system",
parameters: z.object({
title: z.string(),
dueDate: z.string().optional(),
priority: z.enum(["high", "medium", "low"]),
}),
execute: async ({ title, dueDate, priority }) => {
const task = await db.insert(tasks).values({
title,
dueDate: dueDate ? new Date(dueDate) : null,
priority,
}).returning();
return { success: true, taskId: task[0].id };
},
}),
searchOrders: tool({
description: "Search for orders by criteria",
parameters: z.object({
query: z.string(),
status: z.enum(["pending", "completed", "cancelled"]).optional(),
limit: z.number().default(10),
}),
execute: async ({ query, status, limit }) => {
const orders = await db.query.orders.findMany({
where: and(
like(orders.title, `%${query}%`),
status ? eq(orders.status, status) : undefined
),
limit,
});
return { orders };
},
}),
},
});
// Access tool results
if (result.toolCalls) {
for (const toolCall of result.toolCalls) {
console.log(`Tool: ${toolCall.toolName}`, toolCall.result);
}
}
```
## 5. Error Handling
Always implement graceful error handling for AI operations.
```typescript
import { generateObject } from "ai";
import { openai } from "@ai-sdk/openai";
import { logger } from "@your-app/logs";
async function classifyOrder(orderData: OrderData) {
try {
const { object } = await generateObject({
model: openai("gpt-4o-mini"),
schema: classificationSchema,
prompt: buildClassificationPrompt(orderData),
experimental_telemetry: {
isEnabled: true,
functionId: "orders.classify",
},
});
return { success: true, data: object };
} catch (error) {
logger.error("AI generation failed", {
error,
orderId: orderData.id,
prompt: buildClassificationPrompt(orderData).slice(0, 100)
});
// Return graceful fallback
return {
success: false,
reason: "AI processing failed",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
```
### Common Error Types
| Error | Cause | Resolution |
|-------|-------|------------|
| Rate limit exceeded | Too many requests | Implement exponential backoff |
| Context length exceeded | Prompt too long | Truncate or summarize input |
| Invalid API key | Missing/wrong credentials | Check environment variables |
| Schema validation failed | AI output doesn't match schema | Adjust schema or prompt |
## 6. Prompt Engineering Best Practices
### Use XML Structure for Complex Prompts
XML tags help the AI model better understand the structure of your request.
```typescript
const prompt = `
<context>
${contextData}
</context>
<task>
Analyze the above context and extract key information.
</task>
<output_format>
Return a JSON object with the following fields:
- summary: A brief summary
- keyPoints: Array of key points
- sentiment: positive, negative, or neutral
</output_format>
`;
```
### System Prompts
Define consistent behavior with system prompts.
```typescript
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
const result = await generateText({
model: openai("gpt-4o"),
system: `You are a professional assistant.
Always respond in a structured format.
Be concise and accurate.
Never make up information - if unsure, say so.`,
messages: userMessages,
});
```
### Multi-step Prompts
For complex tasks, break down into multiple AI calls.
```typescript
// Step 1: Extract entities
const { object: entities } = await generateObject({
model: openai("gpt-4o-mini"),
schema: entitiesSchema,
prompt: `Extract entities from: ${document}`,
});
// Step 2: Classify based on entities
const { object: classification } = await generateObject({
model: openai("gpt-4o-mini"),
schema: classificationSchema,
prompt: `
<entities>
${JSON.stringify(entities, null, 2)}
</entities>
<task>
Based on these entities, classify the document category.
</task>
`,
});
```
## 7. Provider-Specific Configuration
### OpenAI
```typescript
import { openai } from "@ai-sdk/openai";
const model = openai("gpt-4o-mini", {
// Optional: custom configuration
});
```
### Google Gemini
```typescript
import { google } from "@ai-sdk/google";
const model = google("gemini-1.5-flash");
```
### Anthropic
```typescript
import { anthropic } from "@ai-sdk/anthropic";
const model = anthropic("claude-3-5-sonnet-20241022");
```
## 8. Best Practices Summary
| Rule | Description |
|------|-------------|
| Always enable telemetry | Track token usage and performance for cost monitoring |
| Use generateObject for structured output | Leverage Zod schemas for type safety and validation |
| Use XML prompts for complex tasks | Better structure improves AI understanding |
| Handle errors gracefully | Return fallback responses, never crash |
| Log AI failures | Include context (truncated prompt, IDs) for debugging |
| Use appropriate model sizes | Use mini models for simple tasks, larger for complex |
| Implement rate limiting | Protect against API quota exhaustion |
| Cache responses when appropriate | Reduce costs for repeated queries |
## 9. Environment Variables
Required environment variables for AI providers:
```bash
# OpenAI
OPENAI_API_KEY=sk-...
# Google Gemini
GOOGLE_GENERATIVE_AI_API_KEY=...
# Anthropic
ANTHROPIC_API_KEY=sk-ant-...
```

View File

@@ -0,0 +1,723 @@
# Authentication Guidelines
This document covers backend authentication integration using better-auth, including session management, protected procedures, and OAuth configuration.
## 1. Overview
### What is better-auth
better-auth is a modern authentication library for TypeScript applications that provides:
- Session-based authentication with secure cookie management
- Multiple authentication methods (email/password, OAuth, magic links, passkeys)
- Built-in support for organizations and multi-tenancy
- Database adapter integration (Drizzle ORM)
- Two-factor authentication (2FA)
- Admin functionality
### Session-based Authentication
The authentication system uses secure, HTTP-only cookies to manage user sessions:
- Sessions are stored in the database and cached in Redis for performance
- Session tokens are automatically validated on each request
- Cookie names are prefixed with `__Secure-` in production (HTTPS)
### Supported Providers
| Provider | Type | Description |
|----------|------|-------------|
| Email/Password | Credential | Traditional email and password authentication |
| Google | OAuth | Social login with Google account |
| GitHub | OAuth | Social login with GitHub account |
| Magic Link | Passwordless | Email-based one-time login links |
| Passkey | Passwordless | WebAuthn/FIDO2 biometric authentication |
## 2. Auth Configuration
### Server-side Auth Setup
The auth configuration is defined in the auth package:
```typescript
// packages/auth/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@your-app/database";
import {
admin,
magicLink,
organization,
passkey,
twoFactor,
username,
} from "better-auth/plugins";
export const auth = betterAuth({
baseURL: process.env.APP_URL,
appName: "Your App Name",
// Database adapter
database: drizzleAdapter(db, {
provider: "pg",
}),
// Session configuration
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days in seconds
freshAge: 0,
},
// Account linking for OAuth providers
account: {
accountLinking: {
enabled: true,
trustedProviders: ["google", "github"],
},
},
// Plugins
plugins: [
username(),
admin(),
passkey(),
magicLink({
sendMagicLink: async ({ email, url }, request) => {
// Send magic link email
await sendEmail({
to: email,
templateId: "magicLink",
context: { url },
});
},
}),
organization({
sendInvitationEmail: async ({ email, id, organization }, request) => {
// Send organization invitation email
},
}),
twoFactor(),
],
});
// Export session type
export type Session = typeof auth.$Infer.Session;
```
### Database Adapter (Drizzle)
better-auth uses Drizzle ORM for database operations. The required tables are automatically created:
- `user` - User accounts
- `session` - Authentication sessions
- `account` - OAuth provider accounts (Google, GitHub, etc.)
- `verification` - Email verification tokens
### Session Configuration
```typescript
session: {
// Session lifetime (default: 7 days)
expiresIn: 60 * 60 * 24 * 7,
// Fresh session age for sensitive operations (0 = always require re-auth)
freshAge: 0,
}
```
## 3. Protected Procedures
### Procedure Types
The API layer provides three procedure types with different authentication levels:
```typescript
// packages/api/orpc/procedures.ts
import { ORPCError, os } from "@orpc/server";
// Public procedure - no authentication required
export const publicProcedure = os
.$context<{ headers: Headers }>()
.use(logIdMiddleware);
// Protected procedure - requires authenticated user
export const protectedProcedure = publicProcedure.use(
async ({ context, next }) => {
const { session } = await getSessionWithCache(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();
},
);
```
### Using Protected Procedures
**Basic protected endpoint:**
```typescript
// procedures/get-profile.ts
import { protectedProcedure } from "../../../orpc/procedures";
export const getProfile = protectedProcedure
.route({
method: "GET",
path: "/users/profile",
tags: ["Users"],
summary: "Get current user profile",
})
.handler(async ({ context }) => {
// Access authenticated user from context
const { user, session } = context;
return {
success: true,
reason: "Profile retrieved",
user: {
id: user.id,
email: user.email,
name: user.name,
},
};
});
```
**Admin-only endpoint:**
```typescript
// procedures/list-users.ts
import { adminProcedure } from "../../../orpc/procedures";
import { z } from "zod";
export const listUsers = adminProcedure
.route({
method: "GET",
path: "/admin/users",
tags: ["Administration"],
summary: "List all users",
})
.input(
z.object({
limit: z.number().min(1).max(100).default(10),
offset: z.number().min(0).default(0),
}),
)
.handler(async ({ input: { limit, offset } }) => {
const users = await getUsers({ limit, offset });
return { users };
});
```
### Accessing User Session in Context
The protected procedure middleware injects session data into the context:
```typescript
interface ProtectedContext {
session: {
id: string;
userId: string;
expiresAt: Date;
// ... other session fields
};
user: {
id: string;
email: string;
name: string;
role: "user" | "admin";
// ... other user fields
};
}
```
**Accessing context in handlers:**
```typescript
.handler(async ({ context, input }) => {
const { user, session } = context;
// Use user.id for database queries
const userOrders = await getOrdersByUserId(user.id);
// Check user role
if (user.role === "admin") {
// Admin-specific logic
}
return { success: true, reason: "Success", orders: userOrders };
});
```
### Role-based Access Control
**Custom role middleware:**
```typescript
// Create a middleware for specific roles
const organizationAdminProcedure = protectedProcedure.use(
async ({ context, input, next }) => {
const { organizationId } = input as { organizationId: string };
const membership = await getOrganizationMembership(
organizationId,
context.user.id
);
if (!membership || membership.role !== "owner") {
throw new ORPCError("FORBIDDEN", {
message: "Organization admin access required",
});
}
return await next({
context: {
...context,
organization: membership.organization,
},
});
},
);
```
**Verifying organization membership:**
```typescript
// lib/membership.ts
import { getOrganizationMembership } from "@your-app/database";
export async function verifyOrganizationMembership(
organizationId: string,
userId: string,
) {
const membership = await getOrganizationMembership(organizationId, userId);
if (!membership) {
return null;
}
return {
organization: membership.organization,
role: membership.role,
};
}
```
## 4. Session Management
### Session Caching
Sessions are cached in Redis to reduce database load:
```typescript
// lib/session-cache.ts
import { auth } from "@your-app/auth";
import { redis } from "./redis";
const SESSION_CACHE_PREFIX = "session";
const SESSION_TTL = 60 * 60 * 24 * 7; // 7 days
export async function getSessionWithCache(
headers: Headers,
): Promise<{ session: Session | null; fromCache: boolean }> {
const sessionToken = getSessionTokenFromHeaders(headers);
if (!sessionToken) {
const fresh = await fetchSession(headers);
return { session: fresh, fromCache: false };
}
// Try cache first
const cached = await redis.get(`${SESSION_CACHE_PREFIX}:${sessionToken}`);
if (cached) {
return { session: JSON.parse(cached), fromCache: true };
}
// Fetch from database
const fresh = await auth.api.getSession({ headers });
if (fresh) {
// Cache the session
await redis.set(
`${SESSION_CACHE_PREFIX}:${sessionToken}`,
JSON.stringify(fresh),
{ ex: SESSION_TTL }
);
}
return { session: fresh, fromCache: false };
}
```
### Getting Session Token from Headers
```typescript
export function getSessionTokenFromHeaders(headers: Headers): string | null {
// Check Authorization header first
const authHeader = headers.get("Authorization");
if (authHeader?.startsWith("Bearer ")) {
return authHeader.slice("Bearer ".length);
}
// Fall back to cookie
const cookieHeader = headers.get("cookie");
if (!cookieHeader) {
return null;
}
const cookies = parseCookie(cookieHeader);
const cookieName = process.env.NODE_ENV === "production"
? "__Secure-better-auth.session_token"
: "better-auth.session_token";
return cookies[cookieName] ?? null;
}
```
### Session Invalidation
```typescript
// Delete session cache on logout or session change
export async function deleteSessionCache(sessionToken: string): Promise<void> {
await redis.del(`${SESSION_CACHE_PREFIX}:${sessionToken}`);
}
```
## 5. OAuth Integration
### Google OAuth Setup
**Configuration:**
```typescript
// auth.ts
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
scope: [
"email",
"profile",
"openid",
// Add additional scopes as needed
// "https://www.googleapis.com/auth/calendar",
],
// Get refresh token for offline access
accessType: "offline",
prompt: "consent",
},
},
```
**Environment variables:**
```bash
# .env
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
```
### GitHub OAuth Setup
```typescript
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID as string,
clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
scope: ["user:email"],
},
},
```
### Accessing OAuth Tokens
To access stored OAuth tokens for API calls:
```typescript
import { db } from "@your-app/database";
import { account } from "@your-app/database/drizzle/schema";
import { eq, and } from "drizzle-orm";
export async function getOAuthToken(userId: string, provider: string) {
const accountRecord = await db.query.account.findFirst({
where: and(
eq(account.userId, userId),
eq(account.providerId, provider)
),
});
if (!accountRecord) {
return null;
}
return {
accessToken: accountRecord.accessToken,
refreshToken: accountRecord.refreshToken,
expiresAt: accountRecord.accessTokenExpiresAt,
};
}
```
### Token Refresh
better-auth handles token refresh automatically. For manual refresh:
```typescript
import { auth } from "@your-app/auth";
export async function refreshOAuthToken(userId: string, provider: string) {
// Use auth API to refresh token
const result = await auth.api.refreshAccessToken({
userId,
providerId: provider,
});
return result;
}
```
## 6. Error Handling
### Standard Auth Errors
Use oRPC error codes for authentication failures:
```typescript
import { ORPCError } from "@orpc/server";
// User not authenticated
throw new ORPCError("UNAUTHORIZED");
// User authenticated but lacks permission
throw new ORPCError("FORBIDDEN", {
message: "Admin access required",
});
// Session expired
throw new ORPCError("UNAUTHORIZED", {
message: "Session expired, please login again",
});
```
### Error Response Pattern
```typescript
// Consistent error response structure
export const authErrorSchema = z.object({
success: z.literal(false),
reason: z.string(),
code: z.enum(["UNAUTHORIZED", "FORBIDDEN", "SESSION_EXPIRED"]).optional(),
});
// In handler
if (!hasPermission) {
return {
success: false,
reason: "You do not have permission to perform this action",
code: "FORBIDDEN",
};
}
```
### Handling Session Expiration
```typescript
// Graceful session expiration handling
export async function handleSessionExpiration(sessionToken: string) {
// Clear cache
await deleteSessionCache(sessionToken);
// Log the event
logger.info("Session expired", { sessionToken: sessionToken.slice(0, 10) });
throw new ORPCError("UNAUTHORIZED", {
message: "Your session has expired. Please login again.",
});
}
```
## 7. Best Practices
### Always Validate Session in Protected Routes
```typescript
// GOOD - Use protectedProcedure for authenticated endpoints
export const updateProfile = protectedProcedure
.route({ method: "PATCH", path: "/users/profile" })
.handler(async ({ context }) => {
// context.user is guaranteed to exist
});
// BAD - Manual session check in public procedure
export const updateProfile = publicProcedure
.handler(async ({ context }) => {
const session = await getSession(context.headers);
if (!session) throw new ORPCError("UNAUTHORIZED");
// Error-prone and inconsistent
});
```
### Use Middleware for Reusable Auth Checks
```typescript
// Create reusable middleware for common patterns
const withOrganization = async ({ context, input, next }) => {
const { organizationId } = input;
const membership = await verifyOrganizationMembership(
organizationId,
context.user.id
);
if (!membership) {
throw new ORPCError("FORBIDDEN", {
message: "Not a member of this organization",
});
}
return next({
context: { ...context, organization: membership.organization },
});
};
// Use in procedures
export const getOrganizationData = protectedProcedure
.use(withOrganization)
.handler(async ({ context }) => {
// context.organization is now available
});
```
### Proper Error Responses
```typescript
// Always return meaningful error messages
.handler(async ({ context, input }) => {
try {
const result = await performAction(input);
return { success: true, reason: "Action completed", data: result };
} catch (error) {
if (error instanceof ORPCError) {
throw error; // Re-throw oRPC errors
}
logger.error("Action failed", { error, userId: context.user.id });
return {
success: false,
reason: "An unexpected error occurred",
};
}
});
```
### Secure Session Token Handling
```typescript
// Never log full session tokens
logger.info("Session validated", {
sessionToken: `${token.substring(0, 10)}...`,
userId: session.user.id,
});
// Clear sensitive data from responses
const sanitizedUser = {
id: user.id,
email: user.email,
name: user.name,
// Don't include: passwordHash, sessionTokens, etc.
};
```
### Cache Invalidation on Auth Events
```typescript
// In auth hooks
hooks: {
after: createAuthMiddleware(async (ctx) => {
if (ctx.path.startsWith("/sign-out")) {
const sessionToken = getSessionTokenFromHeaders(ctx.headers);
if (sessionToken) {
await deleteSessionCache(sessionToken);
}
}
}),
}
```
## Client-side Auth Usage
For client-side authentication, use the auth client:
```typescript
// packages/auth/client.ts
import { createAuthClient } from "better-auth/react";
import {
adminClient,
magicLinkClient,
organizationClient,
passkeyClient,
twoFactorClient,
} from "better-auth/client/plugins";
export const authClient = createAuthClient({
plugins: [
magicLinkClient(),
organizationClient(),
adminClient(),
passkeyClient(),
twoFactorClient(),
],
});
```
**Usage in React components:**
```typescript
import { authClient } from "@your-app/auth/client";
// Sign in
await authClient.signIn.email({
email: "user@example.com",
password: "password",
});
// Sign out
await authClient.signOut();
// Get current session
const session = await authClient.getSession();
// Use hooks
const { data: session, isPending } = authClient.useSession();
```
## Quick Reference
| Task | Solution |
|------|----------|
| Require authentication | Use `protectedProcedure` |
| Require admin role | Use `adminProcedure` |
| Get current user | Access `context.user` in handler |
| Get session data | Access `context.session` in handler |
| Check organization membership | Use `verifyOrganizationMembership` helper |
| Throw auth error | `throw new ORPCError("UNAUTHORIZED")` |
| Throw permission error | `throw new ORPCError("FORBIDDEN")` |

View File

@@ -0,0 +1,419 @@
# Database Operations
This document covers database best practices using Drizzle ORM with PostgreSQL.
## Critical Rules
### 1. NO `await` in Loops (N+1 Problem)
Never use `await` inside a loop. This creates the N+1 query problem, causing severe performance degradation.
```typescript
// BAD - N+1 queries (1 query per iteration)
const orders = await db.select().from(orderTable).where(eq(orderTable.userId, userId));
for (const order of orders) {
const items = await db.select().from(orderItemTable).where(eq(orderItemTable.orderId, order.id));
order.items = items;
}
// GOOD - 2 queries total using inArray
const orders = await db.select().from(orderTable).where(eq(orderTable.userId, userId));
const orderIds = orders.map(o => o.id);
// Single query for all items
const allItems = await db
.select()
.from(orderItemTable)
.where(inArray(orderItemTable.orderId, orderIds));
// Group items by orderId in memory
const itemsByOrder = new Map<string, typeof allItems>();
for (const item of allItems) {
const existing = itemsByOrder.get(item.orderId) || [];
existing.push(item);
itemsByOrder.set(item.orderId, existing);
}
// Attach to orders
const ordersWithItems = orders.map(order => ({
...order,
items: itemsByOrder.get(order.id) || [],
}));
```
### 2. Batch Insert Pattern
Use batch inserts for multiple records instead of individual inserts.
```typescript
// BAD - Multiple insert statements
for (const item of items) {
await db.insert(orderItemTable).values(item);
}
// GOOD - Single batch insert
await db.insert(orderItemTable).values(items);
// With returning clause
const insertedItems = await db
.insert(orderItemTable)
.values(items)
.returning();
```
### 3. Conflict Handling with `onConflictDoUpdate`
Handle upserts efficiently with conflict resolution.
```typescript
// Upsert single record
await db
.insert(userSettingsTable)
.values({
userId,
theme: "dark",
notifications: true,
})
.onConflictDoUpdate({
target: userSettingsTable.userId,
set: {
theme: sql`excluded.theme`,
notifications: sql`excluded.notifications`,
updatedAt: sql`NOW()`,
},
});
// Batch upsert with composite key
const upsertedRecords = await db
.insert(inventoryTable)
.values(inventoryData)
.onConflictDoUpdate({
target: [inventoryTable.warehouseId, inventoryTable.productId],
set: {
quantity: sql`excluded.quantity`,
updatedAt: sql`NOW()`,
},
})
.returning({
id: inventoryTable.id,
productId: inventoryTable.productId,
});
```
## Query Organization
Database queries should be organized in `packages/database/drizzle/queries/`.
```
packages/database/drizzle/queries/
├── index.ts # Re-exports all query modules
├── types.ts # Shared query types
├── users.ts # User-related queries
├── orders.ts # Order-related queries
└── products.ts # Product-related queries
```
**Example: `queries/orders.ts`**
```typescript
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { db } from "../client";
import { order as orderTable, orderItem as orderItemTable } from "../schema/postgres";
/**
* Bulk upsert orders with conflict handling
*/
export async function bulkUpsertOrders(
ordersData: Array<{
externalId: string;
customerId: string;
status: string;
total: number;
}>,
) {
if (ordersData.length === 0) {
return [];
}
const upserted = await db
.insert(orderTable)
.values(ordersData)
.onConflictDoUpdate({
target: [orderTable.externalId],
set: {
status: sql`excluded.status`,
total: sql`excluded.total`,
updatedAt: sql`NOW()`,
},
})
.returning({
id: orderTable.id,
externalId: orderTable.externalId,
});
return upserted;
}
/**
* Get orders with items for a user
*/
export async function getOrdersWithItems(params: {
userId: string;
limit?: number;
}) {
const { userId, limit = 20 } = params;
const orders = await db
.select()
.from(orderTable)
.where(eq(orderTable.userId, userId))
.orderBy(desc(orderTable.createdAt))
.limit(limit);
if (orders.length === 0) {
return [];
}
const orderIds = orders.map(o => o.id);
const items = await db
.select()
.from(orderItemTable)
.where(inArray(orderItemTable.orderId, orderIds));
const itemsByOrder = groupBy(items, "orderId");
return orders.map(order => ({
...order,
items: itemsByOrder.get(order.id) || [],
}));
}
```
## Advanced SQL Patterns
### JSON Column Operations
When using PostgreSQL JSON/JSONB columns, proper casting is required for JSON functions.
```typescript
// BAD - Missing cast for jsonb functions
const result = await db
.select()
.from(productTable)
.where(sql`${productTable.metadata}->>'category' = 'electronics'`);
// GOOD - Explicit cast for jsonb operations
const result = await db
.select()
.from(productTable)
.where(sql`${productTable.metadata}::jsonb->>'category' = 'electronics'`);
// JSON array contains check
const withTag = await db
.select()
.from(productTable)
.where(sql`${productTable.tags}::jsonb ? 'featured'`);
// JSON array length
const withMultipleTags = await db
.select()
.from(productTable)
.where(sql`jsonb_array_length(${productTable.tags}::jsonb) > 3`);
```
### Raw SQL Column Names (camelCase)
When using raw SQL with Drizzle, column names must use double quotes for camelCase names.
```typescript
// BAD - PostgreSQL will lowercase unquoted identifiers
await db.execute(sql`
UPDATE order
SET lastUpdatedAt = NOW()
WHERE userId = ${userId}
`);
// GOOD - Double quotes preserve camelCase
await db.execute(sql`
UPDATE "order"
SET "lastUpdatedAt" = NOW()
WHERE "userId" = ${userId}
`);
// Complex raw SQL example
await db.execute(sql`
UPDATE "order" AS o
SET
"totalAmount" = sub."calculatedTotal",
"updatedAt" = NOW()
FROM (
SELECT
"orderId",
SUM("price" * "quantity") AS "calculatedTotal"
FROM "orderItem"
WHERE "orderId" = ANY(${sql.raw(arrayLiteral)})
GROUP BY "orderId"
) AS sub
WHERE o.id = sub."orderId"
`);
```
### Enum Comparison
When comparing enum columns in raw SQL, cast the column to text.
```typescript
// BAD - Direct enum comparison may fail
await db.execute(sql`
SELECT * FROM "order"
WHERE status != 'DRAFT'
`);
// GOOD - Cast enum column to text
await db.execute(sql`
SELECT * FROM "order"
WHERE status::text != 'DRAFT'
`);
// In Drizzle query builder (works correctly)
const orders = await db
.select()
.from(orderTable)
.where(ne(orderTable.status, "DRAFT"));
```
### Aggregation with Filtering
Use FILTER clause for conditional aggregation.
```typescript
await db.execute(sql`
UPDATE "category" AS c
SET
"productCount" = sub."count",
"activeProductCount" = sub."activeCount",
"updatedAt" = NOW()
FROM (
SELECT
"categoryId",
COUNT(*)::int AS "count",
COUNT(*) FILTER (WHERE "status" = 'ACTIVE')::int AS "activeCount"
FROM "product"
WHERE "categoryId" = ANY(${sql.raw(categoryIds)})
GROUP BY "categoryId"
) AS sub
WHERE c.id = sub."categoryId"
`);
```
## Transaction Patterns
### Basic Transaction
```typescript
import { db } from "@your-app/database";
const result = await db.transaction(async (tx) => {
// All operations use tx instead of db
const [order] = await tx
.insert(orderTable)
.values({ userId, total: 0 })
.returning();
await tx.insert(orderItemTable).values(
items.map(item => ({
orderId: order.id,
...item,
}))
);
// Update order total
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
await tx
.update(orderTable)
.set({ total })
.where(eq(orderTable.id, order.id));
return order;
});
```
### Transaction with Rollback
```typescript
try {
await db.transaction(async (tx) => {
await tx.insert(orderTable).values(orderData);
// This will cause rollback if payment fails
const paymentResult = await processPayment(orderData.total);
if (!paymentResult.success) {
throw new Error("Payment failed");
}
await tx.update(orderTable)
.set({ paymentId: paymentResult.id })
.where(eq(orderTable.id, orderData.id));
});
} catch (error) {
// Transaction automatically rolled back
logger.error("Order creation failed", { error });
}
```
## Query Performance Tips
### Use Indexes
Ensure your queries use appropriate indexes:
```typescript
// Good for index on (userId, createdAt DESC)
const recentOrders = await db
.select()
.from(orderTable)
.where(eq(orderTable.userId, userId))
.orderBy(desc(orderTable.createdAt))
.limit(10);
```
### Select Only Needed Columns
```typescript
// BAD - Selects all columns including large text fields
const orders = await db.select().from(orderTable);
// GOOD - Select only needed columns
const orders = await db
.select({
id: orderTable.id,
status: orderTable.status,
total: orderTable.total,
})
.from(orderTable);
```
### Use Relations for Complex Queries
```typescript
// Using Drizzle relations for nested data
const ordersWithDetails = await db.query.order.findMany({
where: eq(orderTable.userId, userId),
with: {
items: {
with: {
product: true,
},
},
customer: {
columns: {
id: true,
name: true,
email: true,
},
},
},
orderBy: (orders, { desc }) => desc(orders.createdAt),
limit: 20,
});
```

View File

@@ -0,0 +1,252 @@
# Directory Structure
This document describes the module organization pattern for backend API development.
## Module Structure
Each API module follows a consistent directory structure:
```
packages/api/modules/[module]/
├── types.ts # Zod schemas and TypeScript types
├── router.ts # Hono router with route definitions
├── lib/ # Core business logic (shared across procedures)
│ ├── client.ts # External service clients
│ └── helpers.ts # Helper functions
├── procedures/ # HTTP endpoint handlers
│ ├── create.ts
│ ├── update.ts
│ ├── delete.ts
│ └── list.ts
└── api/ # API documentation (optional)
├── create.md
└── list.md
```
## File Responsibilities
### `types.ts` - Schemas and Types
Define all Zod schemas and TypeScript types for the module.
```typescript
// types.ts
import { z } from "zod";
// Input Schemas
export const createOrderInputSchema = z.object({
customerId: z.string(),
items: z.array(z.object({
productId: z.string(),
quantity: z.number().min(1),
})).min(1),
});
// Output Schemas
export const orderResponseSchema = z.object({
success: z.boolean(),
reason: z.string(),
order: z.object({
id: z.string(),
status: z.string(),
total: z.number(),
}).optional(),
});
// Type exports
export type CreateOrderInput = z.infer<typeof createOrderInputSchema>;
export type OrderResponse = z.infer<typeof orderResponseSchema>;
```
### `router.ts` - Route Definitions
The router aggregates all procedures and defines the API routes.
```typescript
// router.ts
import { Hono } from "hono";
import { createOrder } from "./procedures/create";
import { listOrders } from "./procedures/list";
import { updateOrderStatus } from "./procedures/update";
export const ordersRouter = new Hono()
.basePath("/orders")
.post("/", createOrder)
.get("/", listOrders)
.patch("/:id/status", updateOrderStatus);
```
### `lib/` - Business Logic
Contains reusable business logic shared across procedures.
```
lib/
├── client.ts # External API clients (payment gateway, etc.)
├── helpers.ts # Pure helper functions
├── validators.ts # Business rule validators
└── transformers.ts # Data transformation utilities
```
**Example: `lib/helpers.ts`**
```typescript
// lib/helpers.ts
import type { Order } from "../types";
/**
* Calculate order total with tax
*/
export function calculateOrderTotal(
items: Array<{ price: number; quantity: number }>,
taxRate: number = 0.1,
): number {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return subtotal * (1 + taxRate);
}
/**
* Generate order reference number
*/
export function generateOrderReference(timestamp: Date): string {
const year = timestamp.getFullYear();
const month = String(timestamp.getMonth() + 1).padStart(2, "0");
const random = Math.random().toString(36).substring(2, 8).toUpperCase();
return `ORD-${year}${month}-${random}`;
}
```
### `procedures/` - Endpoint Handlers
Each procedure handles a single API endpoint with clear responsibilities.
```typescript
// procedures/create.ts
import { db } from "@your-app/database";
import { order as orderTable } from "@your-app/database/drizzle/schema/postgres";
import { logger } from "@your-app/logs";
import { protectedProcedure } from "../../../orpc/procedures";
import { calculateOrderTotal, generateOrderReference } from "../lib/helpers";
import { createOrderInputSchema, orderResponseSchema } from "../types";
export const createOrder = protectedProcedure
.route({
method: "POST",
path: "/orders",
tags: ["Orders"],
summary: "Create a new order",
})
.input(createOrderInputSchema)
.output(orderResponseSchema)
.handler(async ({ input, context: { user } }) => {
const { customerId, items } = input;
// Business logic
const total = calculateOrderTotal(items);
const reference = generateOrderReference(new Date());
// Database operation
const [newOrder] = await db
.insert(orderTable)
.values({
userId: user.id,
customerId,
reference,
total,
status: "PENDING",
})
.returning();
logger.info("Order created", {
orderId: newOrder.id,
userId: user.id,
total,
});
return {
success: true,
reason: "Order created successfully",
order: {
id: newOrder.id,
status: newOrder.status,
total: newOrder.total,
},
};
});
```
### `api/` - Documentation (Optional)
Markdown documentation for each endpoint, useful for complex APIs.
```markdown
<!-- api/create.md -->
# Create Order
## Schema
### Input
- `customerId`: string - Customer identifier
- `items`: array - Order items
- `productId`: string - Product identifier
- `quantity`: number - Quantity (min: 1)
### Output
- `success`: boolean
- `reason`: string
- `order`: object (optional)
## Logic
1. Validate user has permission to create orders for the customer
2. Verify all products exist and are in stock
3. Calculate total with applicable discounts
4. Create order record
5. Reserve inventory
6. Return order details
## Usage Example
```typescript
const result = await api.orders.create({
customerId: "cust_123",
items: [
{ productId: "prod_456", quantity: 2 },
],
});
```
```
## Naming Conventions
### Files
| Type | Convention | Example |
|------|------------|---------|
| Procedures | Verb-based | `create.ts`, `list.ts`, `update-status.ts` |
| Lib files | Noun-based | `helpers.ts`, `validators.ts`, `client.ts` |
| Types | Always `types.ts` | `types.ts` |
| Router | Always `router.ts` | `router.ts` |
### Exports
| Type | Convention | Example |
|------|------------|---------|
| Schemas | `{name}Schema` suffix | `createOrderInputSchema` |
| Types | PascalCase | `CreateOrderInput` |
| Procedures | camelCase verb | `createOrder`, `listOrders` |
| Helpers | camelCase verb | `calculateTotal`, `generateReference` |
## When to Create New Modules
Create a new module when:
1. The feature represents a distinct domain entity (users, orders, products)
2. The feature has multiple related operations (CRUD + custom actions)
3. The feature will be reused across multiple routes
Avoid creating modules for:
1. Single-use utility functions (place in existing `lib/`)
2. Simple helpers (place in `@your-app/utils`)
3. Database queries only (place in `packages/database/drizzle/queries/`)

View File

@@ -0,0 +1,154 @@
# Backend Development Guidelines Index
> **Tech Stack**: Next.js 15 API Routes + oRPC + Drizzle ORM + PostgreSQL
## Related Guidelines
| Guideline | Location | When to Read |
| ------------------------- | ------------ | ---------------------------- |
| **Shared Code Standards** | `../shared/` | Always - applies to all code |
---
## Documentation Files
| File | Description | When to Read |
| ---------------------------------------------------- | -------------------------------------------------- | ---------------------------------- |
| [directory-structure.md](./directory-structure.md) | Module organization and directory layout | Starting a new feature |
| [orpc-usage.md](./orpc-usage.md) | oRPC router, procedures, middleware patterns | Creating/modifying API endpoints |
| [type-safety.md](./type-safety.md) | Zod schemas, type narrowing, response patterns | Type-related decisions |
| [database.md](./database.md) | Drizzle ORM, queries, transactions, SQL patterns | Database operations |
| [authentication.md](./authentication.md) | better-auth, sessions, OAuth, protected procedures | Auth-related features |
| [logging.md](./logging.md) | Structured logging, Sentry tracing, telemetry | Debugging, observability |
| [local-json-mvp.md](./local-json-mvp.md) | Local JSON persistence/session/API contracts before database adoption | Single-repo MVP features without DB/oRPC deps |
| [performance.md](./performance.md) | Concurrency, caching, batch processing, streaming | Performance optimization |
| [ai-sdk-integration.md](./ai-sdk-integration.md) | Vercel AI SDK, tool calling, prompt patterns | AI-powered features |
| [quality.md](./quality.md) | Pre-commit checklist for backend code | Before committing |
---
## Quick Navigation
### Service Module Structure
| Task | File |
| ----------------------------- | -------------------------------------------------- |
| Project structure | [directory-structure.md](./directory-structure.md) |
| Domain module pattern | [directory-structure.md](./directory-structure.md) |
| Write types.ts | [directory-structure.md](./directory-structure.md) |
| Write procedure | [directory-structure.md](./directory-structure.md) |
| Write lib/ helpers | [directory-structure.md](./directory-structure.md) |
| Router setup | [orpc-usage.md](./orpc-usage.md) |
| Middleware composition | [orpc-usage.md](./orpc-usage.md) |
| Naming conventions | [directory-structure.md](./directory-structure.md) |
### Type Safety
| Task | File |
| -------------------- | ---------------------------------- |
| Type safety patterns | [type-safety.md](./type-safety.md) |
| Discriminated unions | [type-safety.md](./type-safety.md) |
| Zod-first types | [type-safety.md](./type-safety.md) |
| Zod error handling | [type-safety.md](./type-safety.md) |
| Standard response | [type-safety.md](./type-safety.md) |
### Database (Drizzle + PostgreSQL)
| Task | File |
| ----------------------- | ---------------------------- |
| Query organization | [database.md](./database.md) |
| Batch queries (inArray) | [database.md](./database.md) |
| Conflict handling | [database.md](./database.md) |
| Transactions | [database.md](./database.md) |
| JSON column operations | [database.md](./database.md) |
| Raw SQL camelCase | [database.md](./database.md) |
| Enum comparison | [database.md](./database.md) |
### Error Handling / Logging
| Task | File |
| --------------------------- | ------------------------------ |
| Structured logging | [logging.md](./logging.md) |
| Sentry span tracing | [logging.md](./logging.md) |
| Error capture | [logging.md](./logging.md) |
| oRPC error codes | [orpc-usage.md](./orpc-usage.md) |
| Batch operation logging | [logging.md](./logging.md) |
### Performance
| Task | File |
| ----------------------------- | ---------------------------------- |
| Parallel execution | [performance.md](./performance.md) |
| Concurrency control (p-limit) | [performance.md](./performance.md) |
| Exponential backoff retry | [performance.md](./performance.md) |
| Redis caching | [performance.md](./performance.md) |
| Distributed locks | [performance.md](./performance.md) |
| Chunked batch processing | [performance.md](./performance.md) |
| Streaming large datasets | [performance.md](./performance.md) |
### Authentication
| Task | File |
| --------------------------- | -------------------------------------- |
| Protected procedures | [authentication.md](./authentication.md) |
| Admin procedures | [authentication.md](./authentication.md) |
| Session caching (Redis) | [authentication.md](./authentication.md) |
| OAuth integration | [authentication.md](./authentication.md) |
| Role-based access control | [authentication.md](./authentication.md) |
| Client-side auth | [authentication.md](./authentication.md) |
### AI Integration
| Task | File |
| --------------------------- | ---------------------------------------------- |
| generateText / generateObject | [ai-sdk-integration.md](./ai-sdk-integration.md) |
| Streaming (streamText) | [ai-sdk-integration.md](./ai-sdk-integration.md) |
| Tool calling | [ai-sdk-integration.md](./ai-sdk-integration.md) |
| Telemetry configuration | [ai-sdk-integration.md](./ai-sdk-integration.md) |
| Prompt engineering | [ai-sdk-integration.md](./ai-sdk-integration.md) |
| AI error handling | [ai-sdk-integration.md](./ai-sdk-integration.md) |
---
## Core Rules Summary
| Rule | Reference |
| ---------------------------------------------------------------- | -------------------------------------------------- |
| **No `await` in loops** - use `inArray` for batch queries | [database.md](./database.md) |
| **No `console.log`** - use structured logger | [logging.md](./logging.md) |
| **No non-null assertions `!`** - use type narrowing | [type-safety.md](./type-safety.md) |
| **All API inputs/outputs have Zod schemas** | [type-safety.md](./type-safety.md) |
| **Import enums from utils** - not from database package | [type-safety.md](./type-safety.md) |
| **Standard response format** - always include `success`/`reason` | [type-safety.md](./type-safety.md) |
| **Use `protectedProcedure`** for authenticated endpoints | [authentication.md](./authentication.md) |
| **One procedure per file** - keep procedures focused | [orpc-usage.md](./orpc-usage.md) |
| **Service modules follow domain layout** | [directory-structure.md](./directory-structure.md) |
| **Use `Promise.all`** for independent parallel operations | [performance.md](./performance.md) |
| **Use `p-limit`** for external API concurrency control | [performance.md](./performance.md) |
| **Always enable AI telemetry** for token tracking | [ai-sdk-integration.md](./ai-sdk-integration.md) |
| **Cast `::jsonb`** for PostgreSQL JSON operations | [database.md](./database.md) |
| **Double-quote camelCase** column names in raw SQL | [database.md](./database.md) |
| **Use structured context** in logs - no string interpolation | [logging.md](./logging.md) |
| **Run pre-commit checklist** before committing | [quality.md](./quality.md) |
---
## Reference Files
| Feature | Typical Location |
| -------------------- | --------------------------------------- |
| Drizzle Client | `packages/database/drizzle/client.ts` |
| Schema Definition | `packages/database/drizzle/schema/` |
| Database Queries | `packages/database/drizzle/queries/` |
| oRPC Router | `packages/api/orpc/router.ts` |
| Base Procedures | `packages/api/orpc/procedures.ts` |
| Middleware | `packages/api/orpc/middleware/` |
| Service Module | `packages/api/modules/{domain}/` |
| Module Types (Zod) | `packages/api/modules/{domain}/types.ts`|
| Auth Configuration | `packages/auth/auth.ts` |
| Auth Client | `packages/auth/client.ts` |
| Shared Utils/Enums | `packages/utils/` |
---
**Language**: All documentation must be written in **English**.

View File

@@ -0,0 +1,86 @@
# Local JSON MVP Persistence and API Contracts
## Scenario: Single-Repo MVP Before Database Adoption
### 1. Scope / Trigger
- Trigger: implementing full-stack CRUD, auth, RBAC, or audit logging before Drizzle/Postgres/oRPC/better-auth are installed.
- Applies to this single-package Next.js app when the feature must be real and durable in local/runtime execution, but production database infrastructure is not yet available.
- Store boundary: `modules/core/server/store.ts`.
### 2. Signatures
- `readData(): Promise<AppData>`
- `writeData(data: AppData): Promise<void>`
- `updateData<T>(mutator: (data: AppData) => T): Promise<T>`
- `getCurrentAuthContext(): Promise<AuthContext | null>`
- `requirePermission(permission: Permission, auditContext: DeniedAuditContext): Promise<PermissionCheckSuccess | PermissionCheckFailure>`
- Route Handlers use standard `GET`, `POST`, `PATCH`, and `DELETE` exports and return `Response`.
### 3. Contracts
- Default data file: `.data/teatea.json`.
- Override key: `TEATEA_DATA_DIR` points to the directory containing `teatea.json`.
- `.data/` must stay ignored; it may contain account hashes and session IDs.
- API response shape:
```ts
type ApiResult<T extends Record<string, unknown>> =
| ({ success: true; reason: string } & T)
| { success: false; reason: string };
```
- Session cookie:
- Name: `teatea_session`
- Flags: `httpOnly`, `sameSite: "lax"`, `path: "/"`
- Lifetime: 7 days
- Passwords:
- Never persist plaintext.
- Use Node crypto salt + scrypt hash until a dedicated auth library is introduced.
### 4. Validation & Error Matrix
- Missing/expired session -> `401` with `{ success: false, reason: "未登录或会话已过期" }`.
- Missing permission -> `403` with `{ success: false, reason: "权限不足" }` and a denied audit log.
- Invalid JSON body -> `400` with a Chinese user-facing `reason`.
- Missing record by ID -> `404` with `{ success: false, reason: "<entity>不存在" }`.
- Duplicate account email -> `409` with `{ success: false, reason: "账号已存在" }`.
### 5. Good/Base/Bad Cases
- Good: UI submits to Route Handler, Route Handler validates input, calls `requirePermission`, mutates data through `updateData`, writes audit log, returns `ApiResult`.
- Base: Server Component reads data directly via `readData` after session/permission checks.
- Bad: UI or route handler reads `.data/teatea.json` directly, bypasses `requirePermission`, or stores auth state in localStorage.
### 6. Tests Required
- `pnpm lint`
- `pnpm type-check`
- `pnpm build`
- Manual or automated integration assertions:
- first setup creates admin and cookie session
- protected app route redirects without cookie
- CRUD mutation persists across reload/API list
- denied permission returns 403 and writes an audit log
### 7. Wrong vs Correct
#### Wrong
```ts
window.localStorage.setItem("teatea.session", JSON.stringify(session));
```
#### Correct
```ts
const cookieStore = await cookies();
cookieStore.set({
name: "teatea_session",
value: sessionId,
httpOnly: true,
sameSite: "lax",
path: "/",
});
```

View File

@@ -0,0 +1,340 @@
# Logging and Monitoring
This document covers structured logging, error tracking with Sentry, and observability patterns.
## Critical Rules
### NO `console.log` - Use Structured Logger
Never use `console.log` in production code. Always use the structured logger from `@your-app/logs`.
```typescript
// BAD - Unstructured console logging
console.log("Order created:", orderId);
console.error("Failed to process:", error);
// GOOD - Structured logging
import { logger } from "@your-app/logs";
logger.info("Order created", {
orderId,
userId,
total: order.total,
});
logger.error("Failed to process order", {
orderId,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
```
## Logger API
```typescript
import { logger } from "@your-app/logs";
// Log levels
logger.debug("Debug message", { context: "value" });
logger.info("Info message", { orderId, status });
logger.warn("Warning message", { userId, reason: "quota exceeded" });
logger.error("Error message", { error: err.message, stack: err.stack });
```
## Sentry Integration
### Span Tracing
Use the tracing system to monitor performance and track operations.
```typescript
import { SpanPrefix, span } from "../../../lib/tracer";
// Database operations
const orders = await span(
`${SpanPrefix.DB}GetUserOrders`,
() => db.select().from(orderTable).where(eq(orderTable.userId, userId)),
{ userId, limit: 20 }
);
// External API calls
const response = await span(
`${SpanPrefix.Http}FetchInventory`,
() => inventoryClient.getStock(productIds),
{ productCount: productIds.length }
);
// Redis cache operations
const cached = await span(
`${SpanPrefix.Redis}GetSession`,
() => redis.get(sessionKey),
{ sessionKey }
);
```
### SpanPrefix Constants
Use standardized prefixes for consistent Sentry categorization:
```typescript
import { SpanPrefix } from "../../../lib/tracer";
const SpanPrefix = {
/** Database operations - maps to Sentry op: db.query */
DB: "DB.",
/** External HTTP API calls - maps to Sentry op: http.client */
Http: "Http.",
/** Redis cache operations - maps to Sentry op: db.redis */
Redis: "Redis.",
/** AI model invocations - maps to Sentry op: ai.run */
AI: "AI.",
/** Generic cache operations - maps to Sentry op: cache */
Cache: "Cache.",
/** Queue/message operations - maps to Sentry op: queue */
Queue: "Queue.",
} as const;
```
### Naming Convention
```typescript
// Pattern: ${SpanPrefix.Type}${Action}${Resource}
// Database
`${SpanPrefix.DB}GetUserOrders`
`${SpanPrefix.DB}BatchUpdateProducts`
`${SpanPrefix.DB}CreateOrder`
// External APIs
`${SpanPrefix.Http}FetchPaymentStatus`
`${SpanPrefix.Http}SendNotification`
// Redis
`${SpanPrefix.Redis}GetSession`
`${SpanPrefix.Redis}SetCache`
// AI
`${SpanPrefix.AI}ClassifyContent`
`${SpanPrefix.AI}GenerateResponse`
```
### Error Capture
```typescript
import { captureError } from "../../../lib/tracer";
try {
await processOrder(orderId);
} catch (error) {
captureError(error, {
tags: {
operation: "processOrder",
orderId,
},
extra: {
userId: context.user.id,
orderStatus: order.status,
},
});
throw error; // Re-throw if needed
}
```
### Trace Context
For complex operations, use trace context to correlate logs:
```typescript
import { runWithTrace, getLogId } from "../../../lib/tracer";
export async function processOrderBatch(orderIds: string[]) {
return runWithTrace(`batch-${Date.now()}`, async () => {
const logId = getLogId();
logger.info("Starting batch processing", {
logId,
orderCount: orderIds.length
});
for (const orderId of orderIds) {
await span(
`${SpanPrefix.DB}ProcessOrder`,
() => processSingleOrder(orderId),
{ orderId }
);
}
logger.info("Batch processing complete", { logId });
});
}
```
## AI SDK Telemetry
When using the Vercel AI SDK, enable telemetry for token tracking:
```typescript
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
const result = await generateText({
model: openai("gpt-4o"),
prompt: userPrompt,
experimental_telemetry: {
isEnabled: true,
functionId: "classify-content",
metadata: {
userId,
contentLength: content.length,
},
},
});
```
### Telemetry Metadata
Include relevant context in telemetry:
```typescript
experimental_telemetry: {
isEnabled: true,
functionId: "generate-response", // Unique identifier for this AI function
metadata: {
// User context
userId: context.user.id,
// Input metrics
promptTokens: estimatedTokens,
// Business context
feature: "auto-reply",
priority: "high",
},
}
```
## Error Handling Patterns
### Structured Error Logging
```typescript
async function processPayment(orderId: string) {
try {
const result = await paymentGateway.charge(orderId);
logger.info("Payment processed", {
orderId,
transactionId: result.transactionId,
amount: result.amount,
});
return result;
} catch (error) {
logger.error("Payment processing failed", {
orderId,
error: error instanceof Error ? error.message : String(error),
errorCode: (error as any).code,
});
// Capture to Sentry with context
captureError(error, {
tags: { service: "payment", operation: "charge" },
extra: { orderId },
});
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Payment processing failed",
});
}
}
```
### Batch Operation Logging
```typescript
async function batchUpdateInventory(updates: InventoryUpdate[]) {
const results: ProcessResult[] = [];
logger.info("Starting batch inventory update", {
updateCount: updates.length,
});
const processed = await Promise.allSettled(
updates.map(update => processUpdate(update))
);
const successful = processed.filter(r => r.status === "fulfilled").length;
const failed = processed.filter(r => r.status === "rejected").length;
logger.info("Batch inventory update complete", {
total: updates.length,
successful,
failed,
});
if (failed > 0) {
logger.warn("Some inventory updates failed", {
failedCount: failed,
errors: processed
.filter((r): r is PromiseRejectedResult => r.status === "rejected")
.map(r => r.reason?.message || "Unknown error"),
});
}
return { successful, failed };
}
```
## Logging Best Practices
### What to Log
**Always log:**
- Request/response for external API calls
- Database write operations (create, update, delete)
- Authentication events
- Business-critical operations
- Errors and exceptions
**Log with care (avoid sensitive data):**
- User inputs (sanitize PII)
- Request payloads (redact secrets)
**Never log:**
- Passwords or tokens
- Credit card numbers
- Personal identification numbers
- API keys or secrets
### Log Levels Guide
| Level | Use Case | Example |
|-------|----------|---------|
| `debug` | Development diagnostics | Variable values, flow tracing |
| `info` | Normal operations | Order created, user logged in |
| `warn` | Recoverable issues | Rate limit approaching, retry attempted |
| `error` | Failures requiring attention | API call failed, database error |
### Structured Context
Always include relevant context as structured data:
```typescript
// BAD - String interpolation
logger.info(`User ${userId} created order ${orderId} for $${total}`);
// GOOD - Structured context
logger.info("Order created", {
userId,
orderId,
total,
currency: "USD",
itemCount: items.length,
});
```

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();
});
});
```

View File

@@ -0,0 +1,498 @@
# Performance Patterns
This document covers performance optimization patterns for backend development.
## Parallel Execution with Promise.all
When operations are independent, execute them in parallel.
```typescript
// BAD - Sequential execution (slow)
const user = await getUser(userId);
const orders = await getOrders(userId);
const preferences = await getPreferences(userId);
// GOOD - Parallel execution
const [user, orders, preferences] = await Promise.all([
getUser(userId),
getOrders(userId),
getPreferences(userId),
]);
```
### Promise.allSettled for Partial Failures
When some operations can fail without blocking others:
```typescript
const results = await Promise.allSettled([
processOrderA(),
processOrderB(),
processOrderC(),
]);
const successful = results
.filter((r): r is PromiseFulfilledResult<Order> => r.status === "fulfilled")
.map(r => r.value);
const failed = results
.filter((r): r is PromiseRejectedResult => r.status === "rejected")
.map(r => r.reason);
logger.info("Batch processing complete", {
successful: successful.length,
failed: failed.length,
});
```
## Concurrency Control with p-limit
When calling external APIs, limit concurrent requests to avoid rate limiting.
```typescript
import pLimit from "p-limit";
// Create limiter with max 20 concurrent requests
const limit = pLimit(20);
const orderIds = ["order1", "order2", /* ... hundreds more */];
// Process all with controlled concurrency
const results = await Promise.all(
orderIds.map(orderId =>
limit(() => fetchOrderDetails(orderId))
)
);
```
### Shared Limiter Pattern
For module-wide concurrency control:
```typescript
// lib/api-client.ts
import pLimit from "p-limit";
// External API concurrency limit
const API_CONCURRENCY = 20;
export function createApiLimiter(): ReturnType<typeof pLimit> {
return pLimit(API_CONCURRENCY);
}
// Usage in procedure
const limiter = createApiLimiter();
const results = await Promise.allSettled(
items.map(item =>
limiter(async () => {
try {
const result = await externalApi.process(item);
return { itemId: item.id, success: true, result };
} catch (error) {
return {
itemId: item.id,
success: false,
error: error instanceof Error ? error.message : "Unknown error"
};
}
})
)
);
```
## Rate Limit Retry with Exponential Backoff
Handle rate limits gracefully with automatic retry.
```typescript
const MAX_RETRIES = 3;
async function fetchWithRetry<T>(
fn: () => Promise<T>,
context: { operation: string; itemId: string }
): Promise<T> {
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
return await fn();
} catch (error: any) {
const isRateLimited = error?.code === 429 || error?.status === 429;
if (isRateLimited && attempt < MAX_RETRIES) {
// Exponential backoff: 2^attempt seconds + random jitter
const delay = 2 ** attempt * 1000 + Math.random() * 1000;
logger.warn("Rate limited, retrying", {
operation: context.operation,
itemId: context.itemId,
attempt,
delay: Math.round(delay),
});
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
throw new Error(`Failed after ${MAX_RETRIES} attempts`);
}
// Usage
const result = await fetchWithRetry(
() => externalApi.getResource(resourceId),
{ operation: "getResource", itemId: resourceId }
);
```
### Backoff Configuration
```typescript
interface RetryConfig {
maxRetries: number;
baseDelay: number; // Base delay in ms
maxDelay: number; // Maximum delay cap
jitterFactor: number; // Random jitter (0-1)
}
const defaultConfig: RetryConfig = {
maxRetries: 3,
baseDelay: 1000,
maxDelay: 30000,
jitterFactor: 0.5,
};
function calculateDelay(attempt: number, config: RetryConfig): number {
const exponentialDelay = config.baseDelay * 2 ** (attempt - 1);
const cappedDelay = Math.min(exponentialDelay, config.maxDelay);
const jitter = cappedDelay * config.jitterFactor * Math.random();
return cappedDelay + jitter;
}
```
## Redis Caching (Cache-Aside Pattern)
Implement caching for expensive operations.
```typescript
import { redis } from "../../../lib/redis";
import { SpanPrefix, span } from "../../../lib/tracer";
const CACHE_TTL = 3600; // 1 hour in seconds
interface CachedUserProfile {
id: string;
name: string;
preferences: Record<string, unknown>;
}
async function getUserProfile(userId: string): Promise<CachedUserProfile> {
const cacheKey = `user:profile:${userId}`;
// 1. Try cache first
const cached = await span(
`${SpanPrefix.Redis}GetUserProfile`,
async () => {
const data = await redis.get<string>(cacheKey);
return data ? JSON.parse(data) as CachedUserProfile : null;
},
{ userId }
);
if (cached) {
return cached;
}
// 2. Cache miss - fetch from database
const profile = await span(
`${SpanPrefix.DB}FetchUserProfile`,
() => db.query.user.findFirst({
where: eq(userTable.id, userId),
with: { preferences: true },
}),
{ userId }
);
if (!profile) {
throw new ORPCError("NOT_FOUND", { message: "User not found" });
}
const cacheValue: CachedUserProfile = {
id: profile.id,
name: profile.name,
preferences: profile.preferences,
};
// 3. Store in cache
await span(
`${SpanPrefix.Redis}SetUserProfile`,
() => redis.set(cacheKey, JSON.stringify(cacheValue), { ex: CACHE_TTL }),
{ userId }
);
return cacheValue;
}
```
### Cache Invalidation
```typescript
async function updateUserProfile(
userId: string,
updates: Partial<UserProfile>
): Promise<void> {
// 1. Update database
await db.update(userTable)
.set(updates)
.where(eq(userTable.id, userId));
// 2. Invalidate cache
const cacheKey = `user:profile:${userId}`;
await redis.del(cacheKey);
logger.info("User profile updated and cache invalidated", { userId });
}
```
### Cache Key Patterns
```typescript
// User-specific data
`user:profile:${userId}`
`user:settings:${userId}`
`user:orders:${userId}:page:${page}`
// Resource-specific data
`product:${productId}`
`inventory:${warehouseId}:${productId}`
// Aggregated data
`stats:daily:${date}`
`leaderboard:${category}`
```
## Background Tasks with Distributed Locks
Prevent duplicate processing in distributed environments.
```typescript
const LOCK_KEY = "task:process-orders";
const LOCK_TTL = 300; // 5 minutes
async function processScheduledOrders(): Promise<void> {
// 1. Try to acquire lock
const lockResult = await redis.set(LOCK_KEY, Date.now(), {
ex: LOCK_TTL,
nx: true, // Only set if not exists
});
if (!lockResult) {
logger.info("Another instance is processing orders, skipping");
return;
}
try {
// 2. Process with lock held
logger.info("Acquired lock, processing scheduled orders");
const pendingOrders = await db
.select()
.from(orderTable)
.where(and(
eq(orderTable.status, "SCHEDULED"),
lte(orderTable.scheduledAt, new Date())
))
.limit(100);
for (const order of pendingOrders) {
await processOrder(order);
}
logger.info("Scheduled orders processed", {
count: pendingOrders.length
});
} finally {
// 3. Release lock
await redis.del(LOCK_KEY);
}
}
```
### Lock with Heartbeat
For long-running tasks, extend the lock periodically:
```typescript
async function processLongRunningTask(): Promise<void> {
const LOCK_KEY = "task:long-running";
const LOCK_TTL = 30;
const HEARTBEAT_INTERVAL = 10000; // 10 seconds
const lockResult = await redis.set(LOCK_KEY, Date.now(), {
ex: LOCK_TTL,
nx: true,
});
if (!lockResult) {
return;
}
// Heartbeat to extend lock
const heartbeat = setInterval(async () => {
await redis.expire(LOCK_KEY, LOCK_TTL);
}, HEARTBEAT_INTERVAL);
try {
await doExpensiveWork();
} finally {
clearInterval(heartbeat);
await redis.del(LOCK_KEY);
}
}
```
## Batch Processing Patterns
### Chunked Processing
For large datasets, process in chunks:
```typescript
const CHUNK_SIZE = 100;
async function processAllOrders(orderIds: string[]): Promise<void> {
// Split into chunks
const chunks: string[][] = [];
for (let i = 0; i < orderIds.length; i += CHUNK_SIZE) {
chunks.push(orderIds.slice(i, i + CHUNK_SIZE));
}
logger.info("Processing orders in chunks", {
totalOrders: orderIds.length,
chunkCount: chunks.length,
chunkSize: CHUNK_SIZE,
});
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
if (!chunk) continue;
await processOrderChunk(chunk);
logger.info("Chunk processed", {
chunkIndex: i + 1,
totalChunks: chunks.length,
});
}
}
async function processOrderChunk(orderIds: string[]): Promise<void> {
// Batch database query
const orders = await db
.select()
.from(orderTable)
.where(inArray(orderTable.id, orderIds));
// Parallel processing with concurrency limit
const limiter = pLimit(10);
await Promise.all(
orders.map(order => limiter(() => processOrder(order)))
);
}
```
### Progress Reporting
Track and report progress for long operations:
```typescript
interface ProgressTracker {
total: number;
processed: number;
failed: number;
startTime: number;
}
async function batchProcessWithProgress(
items: string[],
progressCallback?: (progress: ProgressTracker) => void
): Promise<void> {
const progress: ProgressTracker = {
total: items.length,
processed: 0,
failed: 0,
startTime: Date.now(),
};
const UPDATE_INTERVAL = 20; // Report every 20 items
for (const item of items) {
try {
await processItem(item);
progress.processed++;
} catch {
progress.failed++;
}
// Report progress periodically
if ((progress.processed + progress.failed) % UPDATE_INTERVAL === 0) {
progressCallback?.(progress);
logger.info("Batch progress", {
processed: progress.processed,
failed: progress.failed,
total: progress.total,
elapsedMs: Date.now() - progress.startTime,
});
}
}
}
```
## Memory Optimization
### Streaming Large Datasets
For very large datasets, use streaming:
```typescript
async function* streamOrders(userId: string): AsyncGenerator<Order> {
let cursor: string | undefined;
const PAGE_SIZE = 100;
while (true) {
const orders = await db
.select()
.from(orderTable)
.where(and(
eq(orderTable.userId, userId),
cursor ? gt(orderTable.id, cursor) : undefined
))
.orderBy(asc(orderTable.id))
.limit(PAGE_SIZE);
if (orders.length === 0) {
break;
}
for (const order of orders) {
yield order;
}
const lastOrder = orders[orders.length - 1];
cursor = lastOrder?.id;
if (orders.length < PAGE_SIZE) {
break;
}
}
}
// Usage
for await (const order of streamOrders(userId)) {
await processOrder(order);
}
```

View File

@@ -0,0 +1,81 @@
# Pre-commit Checklist
Run through this checklist before committing backend code.
## Type Safety
- [ ] **No non-null assertions (`!`)** - Use local variables and conditionals for type narrowing
- [ ] **All API inputs have Zod schemas** - Defined in `types.ts`
- [ ] **All API outputs have Zod schemas** - Including `success` and `reason` fields
- [ ] **Enums imported from `@your-app/utils`** - Not from database package
## Database Operations
- [ ] **No `await` in loops** - Use `inArray` for batch queries
- [ ] **Batch inserts used** - Not individual inserts in loops
- [ ] **Conflict handling considered** - Use `onConflictDoUpdate` when appropriate
- [ ] **JSON columns cast properly** - `::jsonb` for jsonb functions
- [ ] **Raw SQL column names quoted** - Double quotes for camelCase columns
## Logging
- [ ] **No `console.log`** - Use `logger` from `@your-app/logs`
- [ ] **Structured logging used** - Pass objects, not string interpolation
- [ ] **Errors logged with context** - Include relevant IDs and stack traces
- [ ] **Sensitive data excluded** - No passwords, tokens, or PII in logs
## Performance
- [ ] **Parallel execution where possible** - Use `Promise.all` for independent operations
- [ ] **Concurrency control for external APIs** - Use `p-limit` for rate-limited APIs
- [ ] **Retry logic for rate limits** - Exponential backoff implemented
- [ ] **Caching considered** - For expensive or frequently accessed data
## Error Handling
- [ ] **Errors properly caught and logged** - With Sentry context when applicable
- [ ] **Appropriate error codes returned** - `NOT_FOUND`, `FORBIDDEN`, `BAD_REQUEST`, etc.
- [ ] **Batch operations handle partial failures** - Return detailed error information
## Code Organization
- [ ] **Code in correct location** - Procedures, lib, types in right directories
- [ ] **Reusable logic extracted** - Shared code in `lib/` directory
- [ ] **Naming conventions followed** - Schemas, types, functions named correctly
## Quick Reference
### Response Format
```typescript
return {
success: true,
reason: "Operation completed successfully",
// additional fields
};
```
### Batch Query Pattern
```typescript
const items = await db
.select()
.from(itemTable)
.where(inArray(itemTable.parentId, parentIds));
const itemsByParent = groupBy(items, "parentId");
```
### Logging Pattern
```typescript
logger.info("Operation completed", {
operationId,
userId,
itemCount: items.length,
});
```
### Error Pattern
```typescript
if (!resource) {
throw new ORPCError("NOT_FOUND", { message: "Resource not found" });
}
```

View File

@@ -0,0 +1,316 @@
# Type Safety Guidelines
This document covers TypeScript best practices and type safety patterns for backend development.
## Critical Rules
### 1. NO Non-null Assertions (`!`)
Never use the non-null assertion operator (`!`). It bypasses TypeScript's null checking and can lead to runtime errors.
```typescript
// BAD - Non-null assertion
const user = users.find(u => u.id === id);
await processUser(user!); // Dangerous!
// GOOD - Use local variable for type narrowing
const user = users.find(u => u.id === id);
if (!user) {
return { success: false, reason: "User not found" };
}
// TypeScript now knows user is defined
await processUser(user);
```
**Why this matters:**
- Non-null assertions (`!`) tell TypeScript to trust you, but runtime doesn't care
- If the value is actually `null` or `undefined`, you get a runtime crash
- Local variable narrowing is verifiable at both compile-time and runtime
### 2. All Inputs/Outputs Must Have Zod Schemas
Every API endpoint must define explicit input and output schemas using Zod.
```typescript
// types.ts
import { z } from "zod";
// Input Schema
export const updateUserInputSchema = z.object({
userId: z.string(),
name: z.string().min(1).max(100).optional(),
email: z.string().email().optional(),
settings: z.object({
notifications: z.boolean(),
theme: z.enum(["light", "dark"]),
}).optional(),
});
// Output Schema
export const updateUserOutputSchema = z.object({
success: z.boolean(),
reason: z.string(),
user: z.object({
id: z.string(),
name: z.string(),
email: z.string(),
}).optional(),
});
// Type exports (inferred from schemas)
export type UpdateUserInput = z.infer<typeof updateUserInputSchema>;
export type UpdateUserOutput = z.infer<typeof updateUserOutputSchema>;
```
**Using schemas in procedures:**
```typescript
// procedures/update.ts
export const updateUser = protectedProcedure
.route({
method: "PATCH",
path: "/users/:userId",
tags: ["Users"],
summary: "Update user profile",
})
.input(updateUserInputSchema)
.output(updateUserOutputSchema)
.handler(async ({ input, context }) => {
// input is fully typed as UpdateUserInput
const { userId, name, email, settings } = input;
// ... implementation
return {
success: true,
reason: "User updated successfully",
user: { id: userId, name: updatedName, email: updatedEmail },
};
});
```
### 3. Import Enums from Shared Utils Package
**Never import enums directly from the database package.** The database package includes the PostgreSQL client, which can cause issues in certain environments (edge runtime, client-side code).
```typescript
// BAD - Imports database client as side effect
import { messageCategoryEnum } from "@your-app/database/drizzle/schema/postgres";
// GOOD - Import from utils (no database client dependency)
import {
messageCategoryZodSchema,
type MessageCategory,
MESSAGE_CATEGORY_VALUES,
} from "@your-app/utils";
```
**How enums are organized in the utils package:**
```typescript
// packages/utils/lib/enum-types.ts
import { z } from "zod";
// Import enum values from schema (not the full database package)
import {
statusEnum
} from "@your-app/database/drizzle/schema";
// Export as Zod schema and TypeScript type
export const ORDER_STATUS_VALUES = statusEnum.enumValues;
export const orderStatusZodSchema = z.enum(ORDER_STATUS_VALUES);
export type OrderStatus = z.infer<typeof orderStatusZodSchema>;
```
### 4. Standard Response Format
All API responses must include `success` and `reason` fields for consistent error handling.
```typescript
// Output schema pattern
export const operationResultSchema = z.object({
success: z.boolean(),
reason: z.string(),
// Additional fields as needed
data: z.unknown().optional(),
});
// Success response
return {
success: true,
reason: "Operation completed successfully",
data: result,
};
// Error response
return {
success: false,
reason: "Insufficient permissions to perform this action",
};
```
**Batch operation response pattern:**
```typescript
export const batchOperationResultSchema = z.object({
success: z.boolean(),
total: z.number(),
processed: z.number(),
failed: z.number(),
errors: z.array(z.object({
itemId: z.string(),
error: z.string(),
})).optional(),
});
```
## Type Narrowing Patterns
### Array Operations
```typescript
// BAD - Assumes array has elements
const firstOrder = orders[0];
await processOrder(firstOrder!);
// GOOD - Check first
const firstOrder = orders[0];
if (!firstOrder) {
return { success: false, reason: "No orders found" };
}
await processOrder(firstOrder);
```
### Optional Chaining with Fallback
```typescript
// BAD - Non-null assertion on optional property
const userName = user.profile!.name!;
// GOOD - Safe access with fallback
const userName = user.profile?.name ?? "Unknown";
// GOOD - When value is required, validate first
const profile = user.profile;
if (!profile?.name) {
throw new ORPCError("BAD_REQUEST", { message: "Profile name is required" });
}
const userName = profile.name;
```
### Map/Find Operations
```typescript
// BAD - Assuming find always succeeds
const account = accounts.find(a => a.id === accountId)!;
// GOOD - Handle the undefined case
const account = accounts.find(a => a.id === accountId);
if (!account) {
throw new ORPCError("NOT_FOUND", { message: "Account not found" });
}
// account is now guaranteed to be defined
```
## Zod Schema Best Practices
### Reusable Base Schemas
```typescript
// Define reusable schemas
const paginationSchema = z.object({
page: z.number().min(1).default(1),
limit: z.number().min(1).max(100).default(20),
});
const timestampSchema = z.object({
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
// Compose into larger schemas
export const listOrdersInputSchema = paginationSchema.extend({
status: orderStatusZodSchema.optional(),
customerId: z.string().optional(),
});
export const orderSchema = z.object({
id: z.string(),
status: orderStatusZodSchema,
total: z.number(),
}).merge(timestampSchema);
```
### Discriminated Unions
```typescript
// For polymorphic responses
export const notificationSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("email"),
recipient: z.string().email(),
subject: z.string(),
}),
z.object({
type: z.literal("sms"),
phoneNumber: z.string(),
message: z.string(),
}),
z.object({
type: z.literal("push"),
deviceToken: z.string(),
title: z.string(),
body: z.string(),
}),
]);
```
### Transform and Refine
```typescript
// Transform input data
export const createProductInputSchema = z.object({
name: z.string().transform(s => s.trim()),
price: z.string().transform(s => parseFloat(s)),
tags: z.string().transform(s => s.split(",").map(t => t.trim())),
});
// Add custom validation
export const dateRangeSchema = z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
}).refine(
data => new Date(data.endDate) > new Date(data.startDate),
{ message: "End date must be after start date" }
);
```
## Error Handling Types
Use typed errors with oRPC:
```typescript
import { ORPCError } from "@orpc/server";
// Standard error codes
throw new ORPCError("NOT_FOUND", { message: "Resource not found" });
throw new ORPCError("FORBIDDEN", { message: "Access denied" });
throw new ORPCError("BAD_REQUEST", { message: "Invalid input" });
throw new ORPCError("UNAUTHORIZED", { message: "Authentication required" });
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Unexpected error" });
```
## Type Inference Helpers
```typescript
// Infer types from Drizzle tables
type User = typeof userTable.$inferSelect;
type NewUser = typeof userTable.$inferInsert;
// Infer from Zod schemas
type CreateOrderInput = z.infer<typeof createOrderInputSchema>;
// Utility types for partial updates
type UpdateOrderInput = Partial<Omit<CreateOrderInput, "id">>;
```