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