docs: add Trellis planning and project specs
This commit is contained in:
77
.trellis/spec/README.md
Normal file
77
.trellis/spec/README.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Next.js Full-Stack Development Guidelines
|
||||
|
||||
Universal development guidelines for production Next.js applications with oRPC API layer and PostgreSQL.
|
||||
|
||||
## Structure
|
||||
|
||||
### [Frontend](./frontend/index.md)
|
||||
|
||||
React 19 + Next.js 15 App Router frontend development patterns:
|
||||
|
||||
- [Directory Structure](./frontend/directory-structure.md)
|
||||
- [Components](./frontend/components.md)
|
||||
- [State Management](./frontend/state-management.md)
|
||||
- [Hooks](./frontend/hooks.md)
|
||||
- [API Integration](./frontend/api-integration.md)
|
||||
- [oRPC Usage](./frontend/orpc-usage.md)
|
||||
- [Authentication](./frontend/authentication.md)
|
||||
- [AI SDK Integration](./frontend/ai-sdk-integration.md)
|
||||
- [CSS & Layout](./frontend/css-layout.md)
|
||||
- [Type Safety](./frontend/type-safety.md)
|
||||
- [Quality Checklist](./frontend/quality.md)
|
||||
|
||||
### [Backend](./backend/index.md)
|
||||
|
||||
oRPC + Drizzle ORM backend development patterns:
|
||||
|
||||
- [Directory Structure](./backend/directory-structure.md)
|
||||
- [oRPC Usage](./backend/orpc-usage.md)
|
||||
- [Authentication](./backend/authentication.md)
|
||||
- [Database](./backend/database.md)
|
||||
- [AI SDK Integration](./backend/ai-sdk-integration.md)
|
||||
- [Logging](./backend/logging.md)
|
||||
- [Performance](./backend/performance.md)
|
||||
- [Type Safety](./backend/type-safety.md)
|
||||
- [Quality Checklist](./backend/quality.md)
|
||||
|
||||
### [Shared](./shared/index.md)
|
||||
|
||||
Cross-cutting concerns:
|
||||
|
||||
- [Dependencies](./shared/dependencies.md)
|
||||
- [Code Quality](./shared/code-quality.md)
|
||||
- [TypeScript Conventions](./shared/typescript.md)
|
||||
|
||||
### [Guides](./guides/index.md)
|
||||
|
||||
Development thinking guides:
|
||||
|
||||
- [Pre-Implementation Checklist](./guides/pre-implementation-checklist.md)
|
||||
- [Cross-Layer Thinking Guide](./guides/cross-layer-thinking-guide.md)
|
||||
|
||||
### [Common Issues / Pitfalls](./big-question/index.md)
|
||||
|
||||
Common issues and solutions:
|
||||
|
||||
- [PostgreSQL JSON vs JSONB](./big-question/postgres-json-jsonb.md)
|
||||
- [WebKit Tap Highlight](./big-question/webkit-tap-highlight.md)
|
||||
- [Sentry & next-intl Conflict](./big-question/sentry-nextintl-conflict.md)
|
||||
- [Turbopack vs Webpack Flexbox](./big-question/turbopack-webpack-flexbox.md)
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend**: Next.js 15, React 19, TailwindCSS 4, Radix UI, React Query
|
||||
- **Backend**: oRPC, Drizzle ORM, PostgreSQL, better-auth
|
||||
- **AI**: Vercel AI SDK (multi-provider)
|
||||
- **Real-time**: Ably / WebSocket / SSE
|
||||
- **Build**: Turborepo + pnpm (monorepo)
|
||||
- **Monitoring**: Sentry + OpenTelemetry
|
||||
|
||||
## Usage
|
||||
|
||||
These guidelines can be used as:
|
||||
|
||||
1. **New Project Template** - Copy the entire structure for new Next.js projects
|
||||
2. **Reference Documentation** - Consult specific guides when implementing features
|
||||
3. **Code Review Checklist** - Verify implementations against established patterns
|
||||
4. **Onboarding Material** - Help new developers understand project conventions
|
||||
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">>;
|
||||
```
|
||||
34
.trellis/spec/big-question/index.md
Normal file
34
.trellis/spec/big-question/index.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Common Issues and Solutions
|
||||
|
||||
> Documented pitfalls discovered while building production Next.js fullstack applications.
|
||||
> These issues apply to any project using Next.js with PostgreSQL, Drizzle ORM, Tailwind CSS, and i18n.
|
||||
|
||||
## Severity Levels
|
||||
|
||||
| Level | Description |
|
||||
| -------- | ---------------------------------------------------- |
|
||||
| Critical | Build fails or data corruption |
|
||||
| Warning | Degraded experience, workaround exists |
|
||||
| Info | Minor visual issue, easy to fix once identified |
|
||||
|
||||
---
|
||||
|
||||
## Issue Index
|
||||
|
||||
| Issue | Category | Severity |
|
||||
| ---------------------------------------------------------------- | ---------------- | -------- |
|
||||
| [postgres-json-jsonb.md](./postgres-json-jsonb.md) | Database/ORM | Critical |
|
||||
| [sentry-nextintl-conflict.md](./sentry-nextintl-conflict.md) | Plugin Conflicts | Critical |
|
||||
| [turbopack-webpack-flexbox.md](./turbopack-webpack-flexbox.md) | Build System | Warning |
|
||||
| [webkit-tap-highlight.md](./webkit-tap-highlight.md) | Mobile/CSS | Info |
|
||||
|
||||
---
|
||||
|
||||
## How to Contribute
|
||||
|
||||
Found a new pitfall? Add it to this directory:
|
||||
|
||||
1. Create a new `.md` file with a descriptive kebab-case name
|
||||
2. Follow the existing format: Problem, Root Cause, Solution, Key Takeaways
|
||||
3. Update this index table with the correct category and severity
|
||||
4. Include reproducible code examples whenever possible
|
||||
192
.trellis/spec/big-question/postgres-json-jsonb.md
Normal file
192
.trellis/spec/big-question/postgres-json-jsonb.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# PostgreSQL JSON vs JSONB Type Issues with Drizzle ORM
|
||||
|
||||
## Problem
|
||||
|
||||
Database queries using PostgreSQL's `jsonb_*` functions fail with type errors, even though the column appears to store JSON data correctly.
|
||||
|
||||
**Error message:**
|
||||
```
|
||||
function jsonb_array_elements(json) does not exist
|
||||
HINT: No function matches the given name and argument types.
|
||||
You might need to add explicit type casts.
|
||||
```
|
||||
|
||||
**Additional symptoms:**
|
||||
- Raw SQL queries fail to find columns with camelCase names
|
||||
- JSON aggregation functions return unexpected results
|
||||
|
||||
## Root Cause
|
||||
|
||||
### Issue 1: Drizzle's `json()` Maps to PostgreSQL `json`, Not `jsonb`
|
||||
|
||||
When defining a JSON column in Drizzle ORM:
|
||||
|
||||
```typescript
|
||||
// Drizzle schema definition
|
||||
export const orders = pgTable("orders", {
|
||||
id: text("id").primaryKey(),
|
||||
metadata: json("metadata"), // Creates PostgreSQL 'json' type, NOT 'jsonb'
|
||||
});
|
||||
```
|
||||
|
||||
PostgreSQL has two JSON types with different characteristics:
|
||||
|
||||
| Feature | `json` | `jsonb` |
|
||||
|---------|--------|---------|
|
||||
| Storage | Text (preserves whitespace, key order) | Binary (normalized) |
|
||||
| Functions | `json_*` functions only | `jsonb_*` functions only |
|
||||
| Indexing | Limited | GIN indexes supported |
|
||||
| Performance | Slower for operations | Faster for operations |
|
||||
|
||||
The `jsonb_*` functions (like `jsonb_array_elements`, `jsonb_extract_path`) **only work with `jsonb` type**.
|
||||
|
||||
### Issue 2: Column Name Case Sensitivity
|
||||
|
||||
PostgreSQL treats unquoted identifiers as lowercase. If your column uses camelCase:
|
||||
|
||||
```sql
|
||||
-- This fails (looks for column named 'metadata' in lowercase)
|
||||
SELECT metadata->>'userId' FROM orders;
|
||||
|
||||
-- Column is actually named "metaData" with exact case
|
||||
SELECT "metaData"->>'userId' FROM orders;
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
### Solution 1: Use Type Cast for jsonb Functions
|
||||
|
||||
Add `::jsonb` type cast before using jsonb functions:
|
||||
|
||||
```typescript
|
||||
// Before (fails)
|
||||
const result = await db.execute(sql`
|
||||
SELECT jsonb_array_elements(items) as item
|
||||
FROM orders
|
||||
WHERE id = ${orderId}
|
||||
`);
|
||||
|
||||
// After (works)
|
||||
const result = await db.execute(sql`
|
||||
SELECT jsonb_array_elements(items::jsonb) as item
|
||||
FROM orders
|
||||
WHERE id = ${orderId}
|
||||
`);
|
||||
```
|
||||
|
||||
### Solution 2: Define Column as `jsonb` in Schema
|
||||
|
||||
If you need jsonb functionality frequently, define the column as jsonb:
|
||||
|
||||
```typescript
|
||||
import { pgTable, text, jsonb } from "drizzle-orm/pg-core";
|
||||
|
||||
export const orders = pgTable("orders", {
|
||||
id: text("id").primaryKey(),
|
||||
metadata: jsonb("metadata"), // Now uses PostgreSQL 'jsonb' type
|
||||
});
|
||||
```
|
||||
|
||||
**Note:** This requires a migration if the column already exists.
|
||||
|
||||
### Solution 3: Quote camelCase Column Names in Raw SQL
|
||||
|
||||
Always use double quotes for camelCase column names:
|
||||
|
||||
```typescript
|
||||
// Before (fails - column not found)
|
||||
const result = await db.execute(sql`
|
||||
SELECT "userId", createdAt
|
||||
FROM orders
|
||||
`);
|
||||
|
||||
// After (works)
|
||||
const result = await db.execute(sql`
|
||||
SELECT "userId", "createdAt"
|
||||
FROM orders
|
||||
`);
|
||||
```
|
||||
|
||||
### Complete Example: Querying JSON Array Data
|
||||
|
||||
```typescript
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
// Table with json column storing an array of items
|
||||
// items: [{ "productId": "123", "quantity": 2 }, ...]
|
||||
|
||||
// Query to find orders containing a specific product
|
||||
async function findOrdersWithProduct(productId: string) {
|
||||
const result = await db.execute(sql`
|
||||
SELECT
|
||||
o.id,
|
||||
o."createdAt",
|
||||
item->>'productId' as "productId",
|
||||
(item->>'quantity')::int as quantity
|
||||
FROM orders o,
|
||||
jsonb_array_elements(o.items::jsonb) as item
|
||||
WHERE item->>'productId' = ${productId}
|
||||
`);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// Query to aggregate JSON array data
|
||||
async function getOrderItemStats(orderId: string) {
|
||||
const result = await db.execute(sql`
|
||||
SELECT
|
||||
COUNT(*) as "itemCount",
|
||||
SUM((item->>'quantity')::int) as "totalQuantity"
|
||||
FROM orders o,
|
||||
jsonb_array_elements(o.items::jsonb) as item
|
||||
WHERE o.id = ${orderId}
|
||||
`);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
```
|
||||
|
||||
### Best Practice: Create a Helper for JSON Queries
|
||||
|
||||
```typescript
|
||||
// utils/db-helpers.ts
|
||||
import { sql, SQL } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Wraps a column reference with ::jsonb cast for use with jsonb functions
|
||||
*/
|
||||
export function asJsonb(column: SQL | string): SQL {
|
||||
if (typeof column === "string") {
|
||||
return sql.raw(`"${column}"::jsonb`);
|
||||
}
|
||||
return sql`${column}::jsonb`;
|
||||
}
|
||||
|
||||
// Usage
|
||||
const result = await db.execute(sql`
|
||||
SELECT jsonb_array_elements(${asJsonb("items")}) as item
|
||||
FROM orders
|
||||
`);
|
||||
```
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **Know the difference between `json` and `jsonb`**
|
||||
- `json`: Text storage, use `json_*` functions
|
||||
- `jsonb`: Binary storage, use `jsonb_*` functions, better performance
|
||||
|
||||
2. **Drizzle's `json()` creates PostgreSQL `json` type** - use `jsonb()` if you need jsonb functionality
|
||||
|
||||
3. **Always add `::jsonb` cast** when using jsonb functions with json columns
|
||||
|
||||
4. **Quote camelCase identifiers** in raw SQL queries with double quotes
|
||||
|
||||
5. **Prefer Drizzle's query builder** over raw SQL when possible to avoid these issues
|
||||
|
||||
6. **Test raw SQL queries** directly in a PostgreSQL client before using in code
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [PostgreSQL JSON Types Documentation](https://www.postgresql.org/docs/current/datatype-json.html)
|
||||
- [PostgreSQL JSON Functions](https://www.postgresql.org/docs/current/functions-json.html)
|
||||
- [Drizzle ORM PostgreSQL Column Types](https://orm.drizzle.team/docs/column-types/pg)
|
||||
223
.trellis/spec/big-question/sentry-nextintl-conflict.md
Normal file
223
.trellis/spec/big-question/sentry-nextintl-conflict.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# Sentry and next-intl Build Configuration Conflict
|
||||
|
||||
## Problem
|
||||
|
||||
Production builds fail with the error:
|
||||
|
||||
```
|
||||
Error: Couldn't find next-intl config file
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```
|
||||
Error: Failed to collect page data for /[locale]/page
|
||||
```
|
||||
|
||||
The build works fine in development mode but fails during `next build`.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The `withSentryConfig` wrapper in `next.config.js` interferes with other Next.js plugins, particularly `next-intl`'s plugin (`createNextIntlPlugin`).
|
||||
|
||||
### Technical Details
|
||||
|
||||
When you chain multiple config wrappers:
|
||||
|
||||
```javascript
|
||||
// next.config.js - Problematic configuration
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
const nextConfig = {
|
||||
// your config
|
||||
};
|
||||
|
||||
// This chaining causes conflicts
|
||||
export default withSentryConfig(withNextIntl(nextConfig), {
|
||||
// Sentry options
|
||||
});
|
||||
```
|
||||
|
||||
The issue occurs because:
|
||||
|
||||
1. **Plugin execution order matters** - Sentry's wrapper modifies the webpack configuration in ways that can break other plugins' assumptions
|
||||
2. **Build-time vs runtime** - Some plugins expect to run at specific build phases
|
||||
3. **Config mutation** - Wrappers may mutate the config object in incompatible ways
|
||||
|
||||
Specifically, `withSentryConfig`:
|
||||
- Modifies webpack configuration extensively
|
||||
- Adds custom loaders and plugins
|
||||
- May interfere with `next-intl`'s message loading mechanism
|
||||
|
||||
## Solution
|
||||
|
||||
### Solution 1: Remove withSentryConfig Wrapper (Recommended)
|
||||
|
||||
Sentry's runtime features still work via `instrumentation.ts` without the config wrapper:
|
||||
|
||||
```javascript
|
||||
// next.config.js - Fixed configuration
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
const nextConfig = {
|
||||
// your config
|
||||
};
|
||||
|
||||
// Only use next-intl wrapper, no Sentry wrapper
|
||||
export default withNextIntl(nextConfig);
|
||||
```
|
||||
|
||||
**Why Sentry still works:**
|
||||
|
||||
The `withSentryConfig` wrapper is primarily for:
|
||||
- Source map uploading
|
||||
- Build-time instrumentation
|
||||
- Release management
|
||||
|
||||
However, Sentry's core error tracking works through `instrumentation.ts`:
|
||||
|
||||
```typescript
|
||||
// instrumentation.ts
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
tracesSampleRate: 1.0,
|
||||
// ... other options
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === "edge") {
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
tracesSampleRate: 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Solution 2: Use Sentry Without Source Maps
|
||||
|
||||
If you need some Sentry build features but want to avoid conflicts:
|
||||
|
||||
```javascript
|
||||
// next.config.js
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
const nextConfig = {
|
||||
// your config
|
||||
};
|
||||
|
||||
// Apply next-intl first
|
||||
const configWithIntl = withNextIntl(nextConfig);
|
||||
|
||||
// Conditionally apply Sentry only if not causing issues
|
||||
const finalConfig = process.env.SKIP_SENTRY_BUILD
|
||||
? configWithIntl
|
||||
: withSentryConfig(configWithIntl, {
|
||||
silent: true,
|
||||
disableSourceMapUpload: true, // Disable problematic feature
|
||||
});
|
||||
|
||||
export default finalConfig;
|
||||
```
|
||||
|
||||
### Solution 3: Alternative Plugin Order
|
||||
|
||||
Sometimes reversing the wrapper order helps:
|
||||
|
||||
```javascript
|
||||
// Try applying Sentry first, then next-intl
|
||||
const configWithSentry = withSentryConfig(nextConfig, sentryOptions);
|
||||
export default withNextIntl(configWithSentry);
|
||||
```
|
||||
|
||||
**Note:** This may or may not work depending on your specific versions.
|
||||
|
||||
### Solution 4: Separate Sentry Configuration
|
||||
|
||||
Use Sentry CLI for source map upload instead of the webpack plugin:
|
||||
|
||||
```bash
|
||||
# In your CI/CD pipeline after build
|
||||
npx @sentry/cli sourcemaps upload ./next/static --org your-org --project your-project
|
||||
```
|
||||
|
||||
```javascript
|
||||
// next.config.js - Clean configuration
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
const nextConfig = {
|
||||
productionBrowserSourceMaps: true, // Enable source maps for Sentry CLI
|
||||
};
|
||||
|
||||
export default withNextIntl(nextConfig);
|
||||
```
|
||||
|
||||
## Verification Steps
|
||||
|
||||
After applying the fix:
|
||||
|
||||
1. **Clean build artifacts:**
|
||||
```bash
|
||||
rm -rf .next node_modules/.cache
|
||||
```
|
||||
|
||||
2. **Test production build:**
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
3. **Verify Sentry works in production:**
|
||||
```bash
|
||||
pnpm start
|
||||
# Trigger a test error and check Sentry dashboard
|
||||
```
|
||||
|
||||
4. **Test i18n routing:**
|
||||
```bash
|
||||
# Visit different locale routes
|
||||
curl http://localhost:3000/en/page
|
||||
curl http://localhost:3000/de/page
|
||||
```
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **Plugin wrappers can conflict** - Be cautious when combining multiple Next.js config wrappers
|
||||
|
||||
2. **Sentry works without withSentryConfig** - Core error tracking functions via `instrumentation.ts`
|
||||
|
||||
3. **Order matters** - Try different wrapper orders if you must use multiple plugins
|
||||
|
||||
4. **Source maps are optional** - You can still get stack traces without source map upload
|
||||
|
||||
5. **Test builds locally** - Always run `pnpm build` before deploying
|
||||
|
||||
6. **Keep dependencies updated** - Plugin compatibility issues are often fixed in newer versions
|
||||
|
||||
## Version Information
|
||||
|
||||
This issue was observed with:
|
||||
- Next.js 14.x / 15.x
|
||||
- @sentry/nextjs 7.x / 8.x
|
||||
- next-intl 3.x
|
||||
|
||||
Check the respective changelogs for compatibility updates.
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [Sentry Next.js SDK Documentation](https://docs.sentry.io/platforms/javascript/guides/nextjs/)
|
||||
- [next-intl Plugin Documentation](https://next-intl-docs.vercel.app/docs/getting-started/app-router)
|
||||
- [Next.js Configuration](https://nextjs.org/docs/app/api-reference/next-config-js)
|
||||
141
.trellis/spec/big-question/turbopack-webpack-flexbox.md
Normal file
141
.trellis/spec/big-question/turbopack-webpack-flexbox.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Turbopack vs Webpack Flexbox Layout Differences
|
||||
|
||||
## Problem
|
||||
|
||||
Layout works correctly in development mode (Turbopack) but breaks in production (Webpack). Specifically, flex containers and their children behave differently between the two bundlers.
|
||||
|
||||
**Symptoms:**
|
||||
- Components have correct height in dev mode but collapse or overflow in production
|
||||
- Scrollable areas work in dev but fail in prod
|
||||
- Nested flex layouts display differently between environments
|
||||
|
||||
## Root Cause
|
||||
|
||||
Turbopack (Next.js dev mode default) and Webpack (production build) have subtle differences in how they process CSS, particularly regarding flexbox behavior:
|
||||
|
||||
1. **Turbopack is stricter** about explicit flexbox properties
|
||||
2. **Webpack may auto-infer** certain flex child behaviors that Turbopack does not
|
||||
3. The difference lies in how CSS is compiled and applied, not in the CSS specification itself
|
||||
|
||||
### Technical Details
|
||||
|
||||
When a flex container has `flex-direction: column` and children that need to fill available space, the behavior depends on:
|
||||
|
||||
- The `align-items` property (defaults to `stretch` but may not be consistently applied)
|
||||
- Whether children have explicit `height` or `flex` properties
|
||||
- The interaction between nested flex containers
|
||||
|
||||
**Example of problematic layout:**
|
||||
|
||||
```tsx
|
||||
// Parent component
|
||||
<div className="flex flex-col h-screen">
|
||||
<Header /> {/* Fixed height */}
|
||||
<main className="flex-1 flex"> {/* Should fill remaining space */}
|
||||
<Sidebar />
|
||||
<Content /> {/* Should scroll internally */}
|
||||
</main>
|
||||
</div>
|
||||
```
|
||||
|
||||
In Turbopack, the `main` element might not properly pass its height to children without explicit `items-stretch`.
|
||||
|
||||
## Solution
|
||||
|
||||
### 1. Explicitly Set `items-stretch` on Flex Containers
|
||||
|
||||
Add `items-stretch` to main flex containers that need children to fill available space:
|
||||
|
||||
```tsx
|
||||
// Before (inconsistent between Turbopack/Webpack)
|
||||
<div className="flex flex-col h-screen">
|
||||
<main className="flex-1 flex">
|
||||
{/* children */}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
// After (consistent behavior)
|
||||
<div className="flex flex-col h-screen items-stretch">
|
||||
<main className="flex-1 flex items-stretch">
|
||||
{/* children */}
|
||||
</main>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 2. Apply Parent/Child Responsibility Separation
|
||||
|
||||
Follow a clear pattern for layout responsibilities:
|
||||
|
||||
**Parent's Responsibility:**
|
||||
- Define the flex container (`flex`, `flex-col`, `flex-row`)
|
||||
- Set alignment (`items-stretch`, `justify-between`)
|
||||
- Control overall dimensions (`h-screen`, `w-full`)
|
||||
|
||||
**Child's Responsibility:**
|
||||
- Define its own flex behavior (`flex-1`, `flex-shrink-0`)
|
||||
- Handle internal overflow (`overflow-auto`, `overflow-hidden`)
|
||||
- Set min/max constraints (`min-h-0`, `max-w-full`)
|
||||
|
||||
### 3. Use `min-h-0` for Scrollable Flex Children
|
||||
|
||||
When a flex child needs internal scrolling:
|
||||
|
||||
```tsx
|
||||
<div className="flex flex-col h-full items-stretch">
|
||||
<div className="flex-shrink-0">Fixed Header</div>
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
{/* Scrollable content */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
The `min-h-0` is crucial because flex items default to `min-height: auto`, which can prevent overflow from working correctly.
|
||||
|
||||
### Complete Example
|
||||
|
||||
```tsx
|
||||
// App layout with consistent dev/prod behavior
|
||||
function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col h-screen items-stretch">
|
||||
{/* Fixed navigation */}
|
||||
<nav className="flex-shrink-0 h-16 border-b">
|
||||
<Navigation />
|
||||
</nav>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 flex items-stretch min-h-0">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 flex-shrink-0 border-r overflow-auto">
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
|
||||
{/* Main content with internal scroll */}
|
||||
<main className="flex-1 min-w-0 overflow-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **Always test production builds locally** before deployment using `pnpm build && pnpm start`
|
||||
|
||||
2. **Be explicit with flexbox properties** - Don't rely on browser defaults or bundler behavior
|
||||
|
||||
3. **Use `items-stretch` explicitly** on containers where children need to fill space
|
||||
|
||||
4. **Remember `min-h-0` and `min-w-0`** for scrollable flex children
|
||||
|
||||
5. **Separate layout responsibilities** between parent (container behavior) and child (self behavior)
|
||||
|
||||
6. **Document layout patterns** in your project to ensure consistency across the team
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [CSS Flexbox Guide](https://css-tricks.com/snippets/css/a-guide-to-flexbox/)
|
||||
- [Next.js Turbopack Documentation](https://nextjs.org/docs/architecture/turbopack)
|
||||
- [Tailwind CSS Flexbox Utilities](https://tailwindcss.com/docs/flex)
|
||||
218
.trellis/spec/big-question/webkit-tap-highlight.md
Normal file
218
.trellis/spec/big-question/webkit-tap-highlight.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# WebKit Tap Highlight and Border-Radius Issues on Mobile
|
||||
|
||||
## Problem
|
||||
|
||||
Buttons and interactive elements lose their `border-radius` styling when tapped on mobile devices (iOS Safari, Chrome on iOS). The element briefly shows a rectangular highlight instead of respecting the rounded corners.
|
||||
|
||||
**Symptoms:**
|
||||
- Button appears with sharp corners during tap/touch
|
||||
- A blue or gray rectangular overlay flashes on touch
|
||||
- The visual glitch only occurs on WebKit-based mobile browsers
|
||||
- Desktop browsers and Android Chrome don't show the issue
|
||||
|
||||
## Root Cause
|
||||
|
||||
WebKit browsers apply a default tap highlight effect to interactive elements. This highlight:
|
||||
|
||||
1. **Ignores `border-radius`** - The highlight is applied as a simple rectangular overlay
|
||||
2. **Uses system default color** - Typically a semi-transparent blue or gray
|
||||
3. **Overrides visual styling** - The highlight appears on top of your custom styles
|
||||
|
||||
### Technical Details
|
||||
|
||||
When you tap an element on iOS Safari:
|
||||
|
||||
```css
|
||||
/* WebKit's default behavior (pseudo-representation) */
|
||||
element:active {
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
|
||||
/* This creates a RECTANGULAR overlay, ignoring border-radius */
|
||||
}
|
||||
```
|
||||
|
||||
The tap highlight is rendered as a separate layer that doesn't respect the element's `border-radius`, `clip-path`, or other shape-defining properties.
|
||||
|
||||
## Solution
|
||||
|
||||
### Solution 1: Disable Tap Highlight + Wrapper with Overflow Hidden
|
||||
|
||||
The most reliable solution combines two techniques:
|
||||
|
||||
```tsx
|
||||
// Button component with proper mobile touch handling
|
||||
function Button({ children, className, ...props }: ButtonProps) {
|
||||
return (
|
||||
<div className="rounded-lg overflow-hidden inline-block">
|
||||
<button
|
||||
className={cn("rounded-lg px-4 py-2 bg-blue-500 text-white", className)}
|
||||
style={{ WebkitTapHighlightColor: "transparent" }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Why this works:**
|
||||
1. `WebkitTapHighlightColor: "transparent"` removes the default highlight
|
||||
2. The wrapper `div` with `overflow-hidden` clips any remaining visual artifacts
|
||||
3. Both elements have matching `border-radius` for consistent appearance
|
||||
|
||||
### Solution 2: CSS-Only Approach
|
||||
|
||||
If you can't modify the component structure:
|
||||
|
||||
```css
|
||||
/* In your global CSS */
|
||||
.tap-safe {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Custom active state to replace the highlight */
|
||||
.tap-safe:active {
|
||||
opacity: 0.8;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
<button className="tap-safe rounded-lg px-4 py-2 bg-blue-500">
|
||||
Click me
|
||||
</button>
|
||||
```
|
||||
|
||||
### Solution 3: Tailwind CSS Utility Class
|
||||
|
||||
Add a reusable utility in your Tailwind config:
|
||||
|
||||
```javascript
|
||||
// tailwind.config.js
|
||||
module.exports = {
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [
|
||||
function({ addUtilities }) {
|
||||
addUtilities({
|
||||
'.tap-highlight-none': {
|
||||
'-webkit-tap-highlight-color': 'transparent',
|
||||
},
|
||||
});
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
Then use it in components:
|
||||
|
||||
```tsx
|
||||
<button className="tap-highlight-none rounded-lg px-4 py-2">
|
||||
Click me
|
||||
</button>
|
||||
```
|
||||
|
||||
### Solution 4: Wrapper Component for Consistent Behavior
|
||||
|
||||
Create a reusable wrapper for all interactive rounded elements:
|
||||
|
||||
```tsx
|
||||
// components/ui/touch-safe-wrapper.tsx
|
||||
interface TouchSafeWrapperProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
borderRadius?: string;
|
||||
}
|
||||
|
||||
export function TouchSafeWrapper({
|
||||
children,
|
||||
className,
|
||||
borderRadius = "rounded-lg"
|
||||
}: TouchSafeWrapperProps) {
|
||||
return (
|
||||
<div className={cn(borderRadius, "overflow-hidden inline-flex", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
<TouchSafeWrapper>
|
||||
<button
|
||||
className="rounded-lg px-4 py-2 bg-blue-500"
|
||||
style={{ WebkitTapHighlightColor: "transparent" }}
|
||||
>
|
||||
Click me
|
||||
</button>
|
||||
</TouchSafeWrapper>
|
||||
```
|
||||
|
||||
### Complete Example: Card with Clickable Areas
|
||||
|
||||
```tsx
|
||||
function ProductCard({ product }: { product: Product }) {
|
||||
return (
|
||||
<div className="rounded-xl border p-4">
|
||||
<h3>{product.name}</h3>
|
||||
<p>{product.description}</p>
|
||||
|
||||
{/* Action buttons with tap-safe handling */}
|
||||
<div className="flex gap-2 mt-4">
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<button
|
||||
className="rounded-lg px-4 py-2 bg-blue-500 text-white"
|
||||
style={{ WebkitTapHighlightColor: "transparent" }}
|
||||
onClick={() => addToCart(product)}
|
||||
>
|
||||
Add to Cart
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<button
|
||||
className="rounded-lg px-4 py-2 border border-gray-300"
|
||||
style={{ WebkitTapHighlightColor: "transparent" }}
|
||||
onClick={() => viewDetails(product)}
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **WebKit tap highlight ignores border-radius** - This is browser behavior, not a CSS bug
|
||||
|
||||
2. **Always set `WebkitTapHighlightColor: "transparent"`** on interactive elements with rounded corners
|
||||
|
||||
3. **Use a wrapper with `overflow-hidden`** for the most reliable visual clipping
|
||||
|
||||
4. **Test on actual iOS devices** - Simulators and browser dev tools may not reproduce the issue
|
||||
|
||||
5. **Consider adding custom active states** to replace the removed tap feedback for better UX
|
||||
|
||||
6. **Create reusable components** that handle mobile touch behavior consistently
|
||||
|
||||
## Browser Support Notes
|
||||
|
||||
| Browser | Needs Fix |
|
||||
|---------|-----------|
|
||||
| iOS Safari | Yes |
|
||||
| Chrome on iOS | Yes (uses WebKit) |
|
||||
| Firefox on iOS | Yes (uses WebKit) |
|
||||
| Android Chrome | Usually no |
|
||||
| Desktop browsers | No |
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [MDN: -webkit-tap-highlight-color](https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-tap-highlight-color)
|
||||
- [WebKit Bug Tracker](https://bugs.webkit.org/)
|
||||
- [CSS Tricks: Handling Touch Events](https://css-tricks.com/snippets/css/remove-gray-highlight-when-tapping-links-in-mobile-safari/)
|
||||
255
.trellis/spec/frontend/ai-sdk-integration.md
Normal file
255
.trellis/spec/frontend/ai-sdk-integration.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# AI SDK Frontend Integration
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This guide covers frontend integration with the Vercel AI SDK using `@ai-sdk/react`. Key topics include:
|
||||
|
||||
- Using `@ai-sdk/react` for React integration
|
||||
- Streaming chat with the `useChat` hook
|
||||
- Tool call handling with proper format detection
|
||||
|
||||
## 2. Basic Chat with useChat
|
||||
|
||||
The `useChat` hook provides a simple interface for chat functionality:
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
|
||||
export function ChatPanel() {
|
||||
const { messages, input, handleInputChange, handleSubmit, status } = useChat({
|
||||
api: "/api/chat",
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{messages.map((message) => (
|
||||
<div key={message.id}>
|
||||
<strong>{message.role}:</strong> {message.content}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Type a message..."
|
||||
disabled={status === "streaming"}
|
||||
/>
|
||||
<button type="submit" disabled={status === "streaming"}>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Custom Transport with oRPC
|
||||
|
||||
When using oRPC instead of standard fetch:
|
||||
|
||||
```typescript
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { eventIteratorToStream } from "@orpc/client";
|
||||
import { orpcClient } from "@/lib/orpc-client";
|
||||
|
||||
export function ChatPanel({ sessionId }: { sessionId: string }) {
|
||||
const { messages, sendMessage, status } = useChat({
|
||||
id: sessionId,
|
||||
transport: {
|
||||
async sendMessages(options) {
|
||||
return eventIteratorToStream(
|
||||
await orpcClient.chat.send(
|
||||
{
|
||||
sessionId,
|
||||
messages: options.messages,
|
||||
},
|
||||
{ signal: options.abortSignal }
|
||||
)
|
||||
);
|
||||
},
|
||||
reconnectToStream() {
|
||||
throw new Error("Reconnect not supported");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// ... rest of component
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Tool Calls Handling
|
||||
|
||||
**CRITICAL**: Tool calls have TWO different formats that must both be handled:
|
||||
|
||||
### Format 1: Real-time Streaming
|
||||
|
||||
During streaming, tool results appear as:
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: "tool-createTask", // tool-{toolName}
|
||||
toolCallId: "call_abc123",
|
||||
state: "output-available",
|
||||
input: { title: "...", priority: "high" },
|
||||
output: { success: true, taskId: "task_xyz" } // Direct object
|
||||
}
|
||||
```
|
||||
|
||||
### Format 2: History Restore
|
||||
|
||||
When loading from history/database:
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: "tool-result",
|
||||
toolName: "createTask",
|
||||
toolCallId: "call_abc123",
|
||||
output: {
|
||||
type: "json",
|
||||
value: { success: true, taskId: "task_xyz" } // Nested in value
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Unified Handling Pattern
|
||||
|
||||
```typescript
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
|
||||
export function AssistantPanel({ sessionId }: { sessionId: string }) {
|
||||
const [createdItems, setCreatedItems] = useState<Map<string, CreatedItem>>(new Map());
|
||||
const toolCallsRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
const { messages, status } = useChat({
|
||||
id: sessionId,
|
||||
transport: { /* ... */ },
|
||||
|
||||
// Handle real-time tool results
|
||||
onData: (dataPart) => {
|
||||
const payload = typeof dataPart === "object" && "json" in dataPart
|
||||
? (dataPart as { json: unknown }).json
|
||||
: dataPart;
|
||||
|
||||
if (typeof payload === "object" && payload !== null && "type" in payload) {
|
||||
const { type, data } = payload as { type: string; data: any };
|
||||
|
||||
if (type === "tool-output-available" || type === "tool-result") {
|
||||
const { toolCallId, output } = data;
|
||||
const toolName = toolCallsRef.current.get(toolCallId);
|
||||
|
||||
if (toolName === "createTask" && output?.success) {
|
||||
setCreatedItems((prev) => {
|
||||
if (prev.has(toolCallId)) return prev;
|
||||
return new Map(prev).set(toolCallId, {
|
||||
id: output.taskId,
|
||||
title: output.title,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Handle history restore
|
||||
useEffect(() => {
|
||||
messages.forEach((message) => {
|
||||
if (message.role !== "assistant") return;
|
||||
|
||||
const parts = (message as any).parts || [];
|
||||
parts.forEach((part: any) => {
|
||||
// Match both formats
|
||||
const isRealTime = part.type === "tool-createTask" && part.state === "output-available";
|
||||
const isRestored = part.type === "tool-result" && part.toolName === "createTask";
|
||||
|
||||
if ((isRealTime || isRestored) && part.output) {
|
||||
const key = part.toolCallId || message.id;
|
||||
|
||||
// Extract output (handle nested structure)
|
||||
const rawOutput = part.output;
|
||||
const output = rawOutput?.type === "json" && rawOutput?.value
|
||||
? rawOutput.value
|
||||
: rawOutput;
|
||||
|
||||
if (output?.success) {
|
||||
setCreatedItems((prev) => {
|
||||
if (prev.has(key)) return prev;
|
||||
return new Map(prev).set(key, {
|
||||
id: output.taskId,
|
||||
title: output.title,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [messages, status]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
|
||||
{/* Display created items */}
|
||||
{Array.from(createdItems.values()).map((item) => (
|
||||
<CreatedItemCard key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Tool Call State Lifecycle
|
||||
|
||||
During streaming, tool parts go through these states:
|
||||
|
||||
| State | Description |
|
||||
|-------|-------------|
|
||||
| `input-streaming` | Tool input is being generated |
|
||||
| `input-available` | Complete input ready |
|
||||
| `output-available` | Tool executed, result available |
|
||||
| `output-error` | Tool execution failed |
|
||||
|
||||
## 6. Displaying Thought Process
|
||||
|
||||
Show users what the AI is "thinking":
|
||||
|
||||
```typescript
|
||||
const [thoughtSteps, setThoughtSteps] = useState<ThoughtStep[]>([]);
|
||||
|
||||
// In onData handler
|
||||
if (type === "tool-input-start" || type === "tool-call") {
|
||||
const { toolCallId, toolName, input } = data;
|
||||
toolCallsRef.current.set(toolCallId, toolName);
|
||||
|
||||
setThoughtSteps((prev) => [
|
||||
...prev,
|
||||
{ id: toolCallId, toolName, status: "pending", input },
|
||||
]);
|
||||
}
|
||||
|
||||
if (type === "tool-output-available") {
|
||||
setThoughtSteps((prev) =>
|
||||
prev.map((step) =>
|
||||
step.id === toolCallId
|
||||
? { ...step, status: "done", result: output }
|
||||
: step
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Best Practices Summary
|
||||
|
||||
| Rule | Description |
|
||||
|------|-------------|
|
||||
| Handle both tool formats | Real-time and history restore |
|
||||
| Use toolCallId as key | Correlate calls across formats |
|
||||
| Use useRef for toolName mapping | Avoid React state timing issues |
|
||||
| onData for real-time UI | useEffect for history restore |
|
||||
| Show thought process | Better UX for tool-heavy flows |
|
||||
464
.trellis/spec/frontend/api-integration.md
Normal file
464
.trellis/spec/frontend/api-integration.md
Normal file
@@ -0,0 +1,464 @@
|
||||
# API Integration
|
||||
|
||||
This document covers API integration patterns including oRPC client usage, real-time communication, and AI streaming.
|
||||
|
||||
## oRPC Client Usage
|
||||
|
||||
### Client Setup
|
||||
|
||||
```typescript
|
||||
// lib/orpc.ts
|
||||
import { createORPCClient } from '@your-app/api/client'; // Replace with your monorepo package path
|
||||
|
||||
export const orpcClient = createORPCClient({
|
||||
baseUrl: process.env.NEXT_PUBLIC_API_URL,
|
||||
});
|
||||
```
|
||||
|
||||
### Basic API Calls
|
||||
|
||||
```typescript
|
||||
// Simple GET
|
||||
const users = await orpcClient.users.list();
|
||||
|
||||
// GET with parameters
|
||||
const user = await orpcClient.users.get({ id: userId });
|
||||
|
||||
// POST (create)
|
||||
const newUser = await orpcClient.users.create({
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
});
|
||||
|
||||
// PUT/PATCH (update)
|
||||
const updatedUser = await orpcClient.users.update({
|
||||
id: userId,
|
||||
name: 'Jane Doe',
|
||||
});
|
||||
|
||||
// DELETE
|
||||
await orpcClient.users.delete({ id: userId });
|
||||
```
|
||||
|
||||
### With React Query
|
||||
|
||||
```typescript
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { orpcClient } from '@/lib/orpc';
|
||||
|
||||
// Query
|
||||
export function useUsers() {
|
||||
return useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => orpcClient.users.list(),
|
||||
});
|
||||
}
|
||||
|
||||
// Mutation
|
||||
export function useCreateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: orpcClient.users.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Query Patterns
|
||||
|
||||
### Pagination
|
||||
|
||||
```typescript
|
||||
interface PaginationParams {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export function usePaginatedOrders({ page, pageSize }: PaginationParams) {
|
||||
return useQuery({
|
||||
queryKey: ['orders', { page, pageSize }],
|
||||
queryFn: () => orpcClient.orders.list({ page, pageSize }),
|
||||
placeholderData: (prev) => prev, // Keep previous data while fetching
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Filtering and Sorting
|
||||
|
||||
```typescript
|
||||
interface OrderFilters {
|
||||
status?: string;
|
||||
customerId?: string;
|
||||
sortBy?: 'createdAt' | 'total';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export function useFilteredOrders(filters: OrderFilters) {
|
||||
return useQuery({
|
||||
queryKey: ['orders', filters],
|
||||
queryFn: () => orpcClient.orders.list(filters),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Prefetching
|
||||
|
||||
```typescript
|
||||
export function useOrdersWithPrefetch() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['orders', { page: 1 }],
|
||||
queryFn: () => orpcClient.orders.list({ page: 1 }),
|
||||
});
|
||||
|
||||
// Prefetch next page
|
||||
useEffect(() => {
|
||||
if (query.data?.hasNextPage) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['orders', { page: 2 }],
|
||||
queryFn: () => orpcClient.orders.list({ page: 2 }),
|
||||
});
|
||||
}
|
||||
}, [query.data, queryClient]);
|
||||
|
||||
return query;
|
||||
}
|
||||
```
|
||||
|
||||
## Real-time Communication
|
||||
|
||||
### WebSocket with Ably
|
||||
|
||||
```typescript
|
||||
// lib/ably.ts
|
||||
import Ably from 'ably';
|
||||
|
||||
export const ablyClient = new Ably.Realtime({
|
||||
authUrl: '/api/ably/auth',
|
||||
});
|
||||
|
||||
// Hook for real-time updates
|
||||
export function useRealtimeOrders() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
const channel = ablyClient.channels.get('orders');
|
||||
|
||||
channel.subscribe('order:created', (message) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] });
|
||||
});
|
||||
|
||||
channel.subscribe('order:updated', (message) => {
|
||||
const order = message.data;
|
||||
queryClient.setQueryData(['orders', order.id], order);
|
||||
});
|
||||
|
||||
return () => {
|
||||
channel.unsubscribe();
|
||||
};
|
||||
}, [queryClient]);
|
||||
}
|
||||
```
|
||||
|
||||
### WebSocket Connection Management
|
||||
|
||||
```typescript
|
||||
export function useWebSocket(channelName: string) {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const channelRef = useRef<Ably.RealtimeChannel | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const channel = ablyClient.channels.get(channelName);
|
||||
channelRef.current = channel;
|
||||
|
||||
channel.on('attached', () => setIsConnected(true));
|
||||
channel.on('detached', () => setIsConnected(false));
|
||||
|
||||
return () => {
|
||||
channel.detach();
|
||||
};
|
||||
}, [channelName]);
|
||||
|
||||
const subscribe = useCallback(
|
||||
(event: string, callback: (data: unknown) => void) => {
|
||||
channelRef.current?.subscribe(event, (message) => {
|
||||
callback(message.data);
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return { isConnected, subscribe };
|
||||
}
|
||||
```
|
||||
|
||||
## SSE/Streaming for AI Chat
|
||||
|
||||
### Basic SSE Pattern
|
||||
|
||||
```typescript
|
||||
export function useAIChat() {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
|
||||
const sendMessage = useCallback(async (content: string) => {
|
||||
setIsStreaming(true);
|
||||
|
||||
// Add user message
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: 'user', content },
|
||||
]);
|
||||
|
||||
// Create placeholder for assistant response
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: 'assistant', content: '' },
|
||||
]);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ai/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: content }),
|
||||
});
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (reader) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const lastMessage = updated[updated.length - 1];
|
||||
lastMessage.content += chunk;
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsStreaming(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { messages, sendMessage, isStreaming };
|
||||
}
|
||||
```
|
||||
|
||||
### Using Vercel AI SDK
|
||||
|
||||
```typescript
|
||||
import { useChat } from 'ai/react';
|
||||
|
||||
export function useAIChatWithSDK() {
|
||||
const {
|
||||
messages,
|
||||
input,
|
||||
handleInputChange,
|
||||
handleSubmit,
|
||||
isLoading,
|
||||
error,
|
||||
} = useChat({
|
||||
api: '/api/ai/chat',
|
||||
onFinish: (message) => {
|
||||
// Handle completed message
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
messages,
|
||||
input,
|
||||
handleInputChange,
|
||||
handleSubmit,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## AI Tool Calls Handling
|
||||
|
||||
AI responses may include tool calls (function calls). The format differs between real-time streaming and history restore.
|
||||
|
||||
### Real-time Streaming Format
|
||||
|
||||
During streaming, tool calls arrive incrementally:
|
||||
|
||||
```typescript
|
||||
interface StreamingToolCall {
|
||||
type: 'tool-call';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface StreamingToolResult {
|
||||
type: 'tool-result';
|
||||
toolCallId: string;
|
||||
result: unknown;
|
||||
}
|
||||
```
|
||||
|
||||
### History Restore Format
|
||||
|
||||
When loading chat history, tool calls are embedded in messages:
|
||||
|
||||
```typescript
|
||||
interface HistoryMessage {
|
||||
role: 'assistant';
|
||||
content: string;
|
||||
toolInvocations?: Array<{
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
state: 'pending' | 'result' | 'error';
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
### Unified Handler Pattern
|
||||
|
||||
```typescript
|
||||
interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
state: 'pending' | 'result' | 'error';
|
||||
}
|
||||
|
||||
function normalizeToolCall(
|
||||
data: StreamingToolCall | HistoryMessage['toolInvocations'][0]
|
||||
): ToolCall {
|
||||
// Handle streaming format
|
||||
if ('type' in data && data.type === 'tool-call') {
|
||||
return {
|
||||
id: data.toolCallId,
|
||||
name: data.toolName,
|
||||
args: data.args,
|
||||
state: 'pending',
|
||||
};
|
||||
}
|
||||
|
||||
// Handle history format
|
||||
return {
|
||||
id: data.toolCallId,
|
||||
name: data.toolName,
|
||||
args: data.args,
|
||||
result: data.result,
|
||||
state: data.state,
|
||||
};
|
||||
}
|
||||
|
||||
// Usage in component
|
||||
function ToolCallDisplay({ toolCall }: { toolCall: ToolCall }) {
|
||||
switch (toolCall.name) {
|
||||
case 'searchProducts':
|
||||
return <ProductSearchResult args={toolCall.args} result={toolCall.result} />;
|
||||
case 'createOrder':
|
||||
return <OrderCreationResult args={toolCall.args} result={toolCall.result} />;
|
||||
default:
|
||||
return <GenericToolResult toolCall={toolCall} />;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Handling Tool Call States
|
||||
|
||||
```typescript
|
||||
export function useToolCallHandler() {
|
||||
const [pendingToolCalls, setPendingToolCalls] = useState<Map<string, ToolCall>>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
const handleStreamChunk = useCallback((chunk: unknown) => {
|
||||
if (isToolCall(chunk)) {
|
||||
setPendingToolCalls((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(chunk.toolCallId, normalizeToolCall(chunk));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if (isToolResult(chunk)) {
|
||||
setPendingToolCalls((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(chunk.toolCallId);
|
||||
if (existing) {
|
||||
next.set(chunk.toolCallId, {
|
||||
...existing,
|
||||
result: chunk.result,
|
||||
state: 'result',
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { pendingToolCalls, handleStreamChunk };
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### API Error Handling
|
||||
|
||||
```typescript
|
||||
import { isORPCError } from '@your-app/api/client'; // Replace with your monorepo package path
|
||||
|
||||
export function useCreateOrder() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: orpcClient.orders.create,
|
||||
onError: (err) => {
|
||||
if (isORPCError(err)) {
|
||||
switch (err.code) {
|
||||
case 'UNAUTHORIZED':
|
||||
setError('Please sign in to continue');
|
||||
break;
|
||||
case 'VALIDATION_ERROR':
|
||||
setError('Please check your input');
|
||||
break;
|
||||
default:
|
||||
setError('Something went wrong');
|
||||
}
|
||||
} else {
|
||||
setError('Network error. Please try again.');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return { ...mutation, error };
|
||||
}
|
||||
```
|
||||
|
||||
### Retry Logic
|
||||
|
||||
```typescript
|
||||
export function useResilientQuery() {
|
||||
return useQuery({
|
||||
queryKey: ['data'],
|
||||
queryFn: () => orpcClient.data.get(),
|
||||
retry: 3,
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Centralize API Client**: Keep oRPC client configuration in one place
|
||||
2. **Use Query Keys Consistently**: Follow a hierarchical naming convention
|
||||
3. **Handle Loading States**: Always show feedback during API calls
|
||||
4. **Implement Error Boundaries**: Catch and display errors gracefully
|
||||
5. **Optimize Real-time**: Unsubscribe from channels when components unmount
|
||||
6. **Type Everything**: Leverage TypeScript for API response types
|
||||
748
.trellis/spec/frontend/authentication.md
Normal file
748
.trellis/spec/frontend/authentication.md
Normal file
@@ -0,0 +1,748 @@
|
||||
# Frontend Authentication with better-auth
|
||||
|
||||
This document provides guidelines for implementing client-side authentication using better-auth in a Next.js React application.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
better-auth provides a comprehensive authentication solution for React applications with:
|
||||
|
||||
- **Session Management**: Cookie-based sessions with automatic refresh
|
||||
- **Multiple Auth Methods**: Password, magic link, OAuth, and passkeys
|
||||
- **Type Safety**: Full TypeScript support with inferred types
|
||||
- **Plugin Architecture**: Extensible through plugins (2FA, organizations, admin, etc.)
|
||||
|
||||
### Key Concepts
|
||||
|
||||
- **Auth Client**: The main interface for all authentication operations
|
||||
- **Session Context**: React context for accessing session state across components
|
||||
- **Middleware**: Server-side route protection before rendering
|
||||
|
||||
## 2. Auth Client Setup
|
||||
|
||||
### Creating the Auth Client
|
||||
|
||||
Create a centralized auth client that can be imported throughout your application:
|
||||
|
||||
```typescript
|
||||
// packages/auth/client.ts
|
||||
import {
|
||||
adminClient,
|
||||
inferAdditionalFields,
|
||||
magicLinkClient,
|
||||
organizationClient,
|
||||
passkeyClient,
|
||||
twoFactorClient,
|
||||
} from "better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import type { auth } from ".";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [
|
||||
inferAdditionalFields<typeof auth>(),
|
||||
magicLinkClient(),
|
||||
organizationClient(),
|
||||
adminClient(),
|
||||
passkeyClient(),
|
||||
twoFactorClient(),
|
||||
],
|
||||
});
|
||||
|
||||
export type AuthClientErrorCodes = typeof authClient.$ERROR_CODES & {
|
||||
INVALID_INVITATION: string;
|
||||
};
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
The auth client supports various plugins based on your needs:
|
||||
|
||||
| Plugin | Purpose |
|
||||
|--------|---------|
|
||||
| `inferAdditionalFields` | Type inference for custom user fields |
|
||||
| `magicLinkClient` | Passwordless email login |
|
||||
| `organizationClient` | Multi-tenant organization support |
|
||||
| `adminClient` | Admin user management |
|
||||
| `passkeyClient` | WebAuthn/Passkey authentication |
|
||||
| `twoFactorClient` | Two-factor authentication |
|
||||
|
||||
## 3. Session Hook/Context
|
||||
|
||||
### Session Context Definition
|
||||
|
||||
Define the session context type and create the context:
|
||||
|
||||
```typescript
|
||||
// lib/session-context.ts
|
||||
import type { Session } from "@your-app/auth"; // Replace with your monorepo package path
|
||||
import React from "react";
|
||||
|
||||
export const SessionContext = React.createContext<
|
||||
| {
|
||||
session: Session["session"] | null;
|
||||
user: Session["user"] | null;
|
||||
loaded: boolean;
|
||||
reloadSession: () => Promise<void>;
|
||||
}
|
||||
| undefined
|
||||
>(undefined);
|
||||
```
|
||||
|
||||
### Session Provider Component
|
||||
|
||||
Wrap your application with a SessionProvider to manage session state:
|
||||
|
||||
```typescript
|
||||
// components/SessionProvider.tsx
|
||||
"use client";
|
||||
import { authClient } from "@your-app/auth/client"; // Replace with your monorepo package path
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { SessionContext } from "../lib/session-context";
|
||||
|
||||
// Query key for session caching
|
||||
export const sessionQueryKey = ["user", "session"] as const;
|
||||
|
||||
// Custom hook for fetching session
|
||||
export const useSessionQuery = () => {
|
||||
return useQuery({
|
||||
queryKey: sessionQueryKey,
|
||||
queryFn: async () => {
|
||||
const { data, error } = await authClient.getSession({
|
||||
query: {
|
||||
disableCookieCache: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || "Failed to fetch session");
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
};
|
||||
|
||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: session } = useSessionQuery();
|
||||
const [loaded, setLoaded] = useState(!!session);
|
||||
|
||||
useEffect(() => {
|
||||
if (session && !loaded) {
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [session, loaded]);
|
||||
|
||||
return (
|
||||
<SessionContext.Provider
|
||||
value={{
|
||||
loaded,
|
||||
session: session?.session ?? null,
|
||||
user: session?.user ?? null,
|
||||
reloadSession: async () => {
|
||||
const { data: newSession, error } = await authClient.getSession({
|
||||
query: {
|
||||
disableCookieCache: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || "Failed to fetch session");
|
||||
}
|
||||
|
||||
queryClient.setQueryData(sessionQueryKey, () => newSession);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SessionContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### useSession Hook
|
||||
|
||||
Create a convenient hook to access session data:
|
||||
|
||||
```typescript
|
||||
// hooks/use-session.ts
|
||||
import { useContext } from "react";
|
||||
import { SessionContext } from "../lib/session-context";
|
||||
|
||||
export const useSession = () => {
|
||||
const sessionContext = useContext(SessionContext);
|
||||
|
||||
if (sessionContext === undefined) {
|
||||
throw new Error("useSession must be used within SessionProvider");
|
||||
}
|
||||
|
||||
return sessionContext;
|
||||
};
|
||||
```
|
||||
|
||||
### Usage Example
|
||||
|
||||
```typescript
|
||||
function UserGreeting() {
|
||||
const { user, loaded } = useSession();
|
||||
|
||||
if (!loaded) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <div>Please log in</div>;
|
||||
}
|
||||
|
||||
return <div>Welcome, {user.name}!</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Protected Routes
|
||||
|
||||
### Middleware for Route Protection
|
||||
|
||||
Use Next.js middleware to protect routes at the server level:
|
||||
|
||||
```typescript
|
||||
// middleware.ts
|
||||
import { getSessionCookie } from "better-auth/cookies";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { withQuery } from "ufo";
|
||||
|
||||
export default async function middleware(req: NextRequest) {
|
||||
const { pathname, origin } = req.nextUrl;
|
||||
const sessionCookie = getSessionCookie(req);
|
||||
|
||||
// Protect /app routes
|
||||
if (pathname.startsWith("/app")) {
|
||||
if (!sessionCookie) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
withQuery("/auth/login", {
|
||||
redirectTo: pathname,
|
||||
}),
|
||||
origin,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Allow auth routes
|
||||
if (pathname.startsWith("/auth")) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!api|_next/static|_next/image|favicon.ico).*)",
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
### Client-Side Route Protection
|
||||
|
||||
For additional client-side protection, redirect authenticated users away from auth pages:
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useSession } from "@/hooks/use-session";
|
||||
|
||||
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const { user, loaded } = useSession();
|
||||
const redirectPath = "/app/dashboard";
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded && user) {
|
||||
router.replace(redirectPath);
|
||||
}
|
||||
}, [user, loaded, router]);
|
||||
|
||||
if (!loaded) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return null; // Will redirect
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
### Loading States
|
||||
|
||||
Always handle loading states to prevent flash of unauthorized content:
|
||||
|
||||
```typescript
|
||||
function ProtectedContent() {
|
||||
const { user, loaded } = useSession();
|
||||
|
||||
// Show loading while session is being fetched
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect or show unauthorized message
|
||||
if (!user) {
|
||||
return <Redirect to="/auth/login" />;
|
||||
}
|
||||
|
||||
return <DashboardContent user={user} />;
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Login/Logout Flows
|
||||
|
||||
### Email/Password Sign In
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { authClient } from "@your-app/auth/client"; // Replace with your monorepo package path
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
function LoginForm() {
|
||||
const router = useRouter();
|
||||
|
||||
const onSubmit = async (values: { email: string; password: string }) => {
|
||||
try {
|
||||
const { data, error } = await authClient.signIn.email({
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Handle 2FA redirect if enabled
|
||||
if ((data as any).twoFactorRedirect) {
|
||||
router.replace("/auth/verify");
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to dashboard
|
||||
router.replace("/app/dashboard");
|
||||
} catch (e) {
|
||||
// Handle error
|
||||
console.error("Login failed:", e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{/* Form fields */}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Magic Link Sign In
|
||||
|
||||
```typescript
|
||||
const signInWithMagicLink = async (email: string) => {
|
||||
const { error } = await authClient.signIn.magicLink({
|
||||
email,
|
||||
callbackURL: "/app/dashboard",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Show success message - user will receive email
|
||||
showNotification("Check your email for the login link");
|
||||
};
|
||||
```
|
||||
|
||||
### OAuth Sign In
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { authClient } from "@your-app/auth/client"; // Replace with your monorepo package path
|
||||
|
||||
function SocialSigninButton({ provider }: { provider: string }) {
|
||||
const redirectPath = "/app/dashboard";
|
||||
|
||||
const onSignin = () => {
|
||||
const callbackURL = new URL(redirectPath, window.location.origin);
|
||||
authClient.signIn.social({
|
||||
provider, // "google", "github", etc.
|
||||
callbackURL: callbackURL.toString(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={onSignin}>
|
||||
Sign in with {provider}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Passkey Sign In
|
||||
|
||||
```typescript
|
||||
const signInWithPasskey = async () => {
|
||||
try {
|
||||
await authClient.signIn.passkey();
|
||||
router.replace("/app/dashboard");
|
||||
} catch (e) {
|
||||
console.error("Passkey authentication failed:", e);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Sign Out
|
||||
|
||||
```typescript
|
||||
import { authClient } from "@your-app/auth/client"; // Replace with your monorepo package path
|
||||
|
||||
const onLogout = () => {
|
||||
authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: async () => {
|
||||
// Redirect to home or login page
|
||||
window.location.href = new URL("/", window.location.origin).toString();
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## 6. User Profile
|
||||
|
||||
### Accessing Current User
|
||||
|
||||
Use the `useSession` hook to access user data:
|
||||
|
||||
```typescript
|
||||
function UserProfile() {
|
||||
const { user, loaded } = useSession();
|
||||
|
||||
if (!loaded || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { name, email, image } = user;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<img src={image} alt={name} className="w-10 h-10 rounded-full" />
|
||||
<div>
|
||||
<p className="font-medium">{name}</p>
|
||||
<p className="text-sm text-gray-500">{email}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Updating User Profile
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { authClient } from "@your-app/auth/client"; // Replace with your monorepo package path
|
||||
import { useSession } from "@/hooks/use-session";
|
||||
|
||||
function ChangeNameForm() {
|
||||
const { user, reloadSession } = useSession();
|
||||
|
||||
const onSubmit = async ({ name }: { name: string }) => {
|
||||
const { error } = await authClient.updateUser({
|
||||
name,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
showError("Failed to update name");
|
||||
return;
|
||||
}
|
||||
|
||||
showSuccess("Name updated successfully");
|
||||
|
||||
// Reload session to reflect changes
|
||||
await reloadSession();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={user?.name ?? ""}
|
||||
{...register("name")}
|
||||
/>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Updating Other Profile Fields
|
||||
|
||||
```typescript
|
||||
// Update avatar
|
||||
const updateAvatar = async (imageUrl: string) => {
|
||||
const { error } = await authClient.updateUser({
|
||||
image: imageUrl,
|
||||
});
|
||||
|
||||
if (!error) {
|
||||
await reloadSession();
|
||||
}
|
||||
};
|
||||
|
||||
// Update language preference (if custom field)
|
||||
const updateLanguage = async (language: string) => {
|
||||
const { error } = await authClient.updateUser({
|
||||
language,
|
||||
});
|
||||
|
||||
if (!error) {
|
||||
await reloadSession();
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## 7. Server-Side Session Access
|
||||
|
||||
For server components, access the session directly:
|
||||
|
||||
```typescript
|
||||
// lib/server.ts
|
||||
import "server-only";
|
||||
import { auth } from "@your-app/auth"; // Replace with your monorepo package path
|
||||
import { headers } from "next/headers";
|
||||
import { cache } from "react";
|
||||
|
||||
export const getSession = cache(async () => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
query: {
|
||||
disableCookieCache: true,
|
||||
},
|
||||
});
|
||||
|
||||
return session;
|
||||
});
|
||||
|
||||
export const getActiveOrganization = cache(async (slug: string) => {
|
||||
try {
|
||||
const activeOrganization = await auth.api.getFullOrganization({
|
||||
query: {
|
||||
organizationSlug: slug,
|
||||
},
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
return activeOrganization;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Usage in Server Components
|
||||
|
||||
```typescript
|
||||
// app/(app)/dashboard/page.tsx
|
||||
import { getSession } from "@/lib/server";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Welcome, {session.user.name}</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 8. Best Practices
|
||||
|
||||
### Always Check Session Before Protected Operations
|
||||
|
||||
```typescript
|
||||
function DeleteAccountButton() {
|
||||
const { user, loaded } = useSession();
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!loaded || !user) {
|
||||
showError("Not authenticated");
|
||||
return;
|
||||
}
|
||||
|
||||
// Proceed with deletion
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={handleDelete} disabled={!loaded || !user}>
|
||||
Delete Account
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Handle Loading States Properly
|
||||
|
||||
```typescript
|
||||
function AuthenticatedComponent() {
|
||||
const { user, loaded } = useSession();
|
||||
|
||||
// Always handle loading state first
|
||||
if (!loaded) {
|
||||
return <Skeleton />;
|
||||
}
|
||||
|
||||
// Then handle unauthenticated state
|
||||
if (!user) {
|
||||
return <LoginPrompt />;
|
||||
}
|
||||
|
||||
// Finally render authenticated content
|
||||
return <ProtectedContent user={user} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Proper Redirect After Auth
|
||||
|
||||
```typescript
|
||||
function LoginForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const redirectTo = searchParams.get("redirectTo");
|
||||
|
||||
const onLoginSuccess = () => {
|
||||
// Redirect to original destination or default
|
||||
const destination = redirectTo ?? "/app/dashboard";
|
||||
router.replace(destination);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Invalidate Session Cache After Auth Changes
|
||||
|
||||
```typescript
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
function AuthComponent() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const onAuthChange = () => {
|
||||
// Invalidate session cache to trigger refetch
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: sessionQueryKey,
|
||||
});
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
const handleAuthError = (error: any) => {
|
||||
// Get error code from better-auth error
|
||||
const errorCode = error?.code;
|
||||
|
||||
// Map to user-friendly message
|
||||
const errorMessages: Record<string, string> = {
|
||||
INVALID_CREDENTIALS: "Invalid email or password",
|
||||
USER_NOT_FOUND: "No account found with this email",
|
||||
EMAIL_NOT_VERIFIED: "Please verify your email first",
|
||||
TOO_MANY_REQUESTS: "Too many attempts. Please try again later",
|
||||
};
|
||||
|
||||
const message = errorMessages[errorCode] ?? "An error occurred";
|
||||
showError(message);
|
||||
};
|
||||
```
|
||||
|
||||
### Security Considerations
|
||||
|
||||
1. **Never store sensitive auth data in localStorage** - better-auth uses secure HTTP-only cookies
|
||||
2. **Always validate sessions server-side** - Middleware protection is essential
|
||||
3. **Use HTTPS in production** - Required for secure cookies
|
||||
4. **Implement CSRF protection** - better-auth handles this automatically
|
||||
5. **Set appropriate session expiry** - Configure in server auth options
|
||||
|
||||
## 9. Common Patterns
|
||||
|
||||
### Conditional Rendering Based on Auth
|
||||
|
||||
```typescript
|
||||
function Navigation() {
|
||||
const { user, loaded } = useSession();
|
||||
|
||||
return (
|
||||
<nav>
|
||||
<Link href="/">Home</Link>
|
||||
{loaded && (
|
||||
<>
|
||||
{user ? (
|
||||
<>
|
||||
<Link href="/app/dashboard">Dashboard</Link>
|
||||
<LogoutButton />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/auth/login">Login</Link>
|
||||
<Link href="/auth/signup">Sign Up</Link>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Auth State Persistence Across Tabs
|
||||
|
||||
```typescript
|
||||
// Session is automatically synced via cookies
|
||||
// For real-time sync, listen to storage events
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === "auth-sync") {
|
||||
reloadSession();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("storage", handleStorageChange);
|
||||
return () => window.removeEventListener("storage", handleStorageChange);
|
||||
}, []);
|
||||
```
|
||||
|
||||
### Automatic Session Refresh
|
||||
|
||||
```typescript
|
||||
// Configure in useSessionQuery
|
||||
export const useSessionQuery = () => {
|
||||
return useQuery({
|
||||
queryKey: sessionQueryKey,
|
||||
queryFn: fetchSession,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchInterval: 10 * 60 * 1000, // Refetch every 10 minutes
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
};
|
||||
```
|
||||
454
.trellis/spec/frontend/components.md
Normal file
454
.trellis/spec/frontend/components.md
Normal file
@@ -0,0 +1,454 @@
|
||||
# Component Development Guidelines
|
||||
|
||||
This document covers component development patterns including Server vs Client components, semantic HTML, and UI best practices.
|
||||
|
||||
## Server vs Client Components
|
||||
|
||||
### Default to Server Components
|
||||
|
||||
Next.js App Router defaults to Server Components. Use them for:
|
||||
|
||||
- Data fetching
|
||||
- Accessing backend resources directly
|
||||
- Keeping sensitive data on the server
|
||||
- Reducing client-side JavaScript
|
||||
|
||||
```typescript
|
||||
// app/(app)/dashboard/page.tsx (Server Component)
|
||||
import { DashboardStats } from '@/modules/dashboard/components';
|
||||
|
||||
export default async function DashboardPage() {
|
||||
// Can fetch data directly
|
||||
const stats = await fetchDashboardStats();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Dashboard</h1>
|
||||
<DashboardStats data={stats} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### When to Use Client Components
|
||||
|
||||
Add `'use client'` directive only when you need:
|
||||
|
||||
- Event handlers (onClick, onChange, etc.)
|
||||
- useState, useEffect, or other React hooks
|
||||
- Browser-only APIs (localStorage, window)
|
||||
- Class components with lifecycle methods
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export function Counter() {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
return (
|
||||
<button onClick={() => setCount(count + 1)}>
|
||||
Count: {count}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Composition Pattern
|
||||
|
||||
Keep Server Components at the top, push Client Components down:
|
||||
|
||||
```typescript
|
||||
// Server Component (page.tsx)
|
||||
import { ProductList } from './ProductList';
|
||||
import { FilterSidebar } from './FilterSidebar'; // Client
|
||||
|
||||
export default async function ProductsPage() {
|
||||
const products = await fetchProducts();
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<FilterSidebar /> {/* Client component for interactivity */}
|
||||
<ProductList products={products} /> {/* Can be server or client */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Passing Server Data to Client Components
|
||||
|
||||
```typescript
|
||||
// Server Component
|
||||
export default async function Page() {
|
||||
const initialData = await fetchData();
|
||||
|
||||
return <InteractiveWidget initialData={initialData} />;
|
||||
}
|
||||
|
||||
// Client Component
|
||||
'use client';
|
||||
|
||||
export function InteractiveWidget({ initialData }: { initialData: Data }) {
|
||||
const [data, setData] = useState(initialData);
|
||||
// Interactive logic...
|
||||
}
|
||||
```
|
||||
|
||||
## Semantic HTML
|
||||
|
||||
### Use Proper Elements
|
||||
|
||||
```typescript
|
||||
// Bad: div for everything
|
||||
<div onClick={handleClick}>Click me</div>
|
||||
<div>
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
</div>
|
||||
|
||||
// Good: semantic elements
|
||||
<button onClick={handleClick}>Click me</button>
|
||||
<ul>
|
||||
<li>Item 1</li>
|
||||
<li>Item 2</li>
|
||||
</ul>
|
||||
```
|
||||
|
||||
### Button vs Div
|
||||
|
||||
Always use `<button>` for clickable actions:
|
||||
|
||||
```typescript
|
||||
// Bad: Non-semantic, no keyboard support, no accessibility
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={handleClick}
|
||||
>
|
||||
Save
|
||||
</div>
|
||||
|
||||
// Good: Semantic, keyboard accessible, proper focus
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className="..."
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
```
|
||||
|
||||
### Form Elements
|
||||
|
||||
```typescript
|
||||
// Bad: Missing labels, wrong elements
|
||||
<div>
|
||||
<span>Email</span>
|
||||
<input type="text" />
|
||||
</div>
|
||||
|
||||
// Good: Proper form structure
|
||||
<div>
|
||||
<label htmlFor="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
aria-describedby="email-error"
|
||||
/>
|
||||
{error && <p id="email-error" role="alert">{error}</p>}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Navigation
|
||||
|
||||
```typescript
|
||||
// Bad
|
||||
<div onClick={() => router.push('/about')}>About</div>
|
||||
|
||||
// Good
|
||||
<Link href="/about">About</Link>
|
||||
|
||||
// For programmatic navigation with button appearance
|
||||
<Link href="/about" className="btn btn-primary">
|
||||
About
|
||||
</Link>
|
||||
```
|
||||
|
||||
## Next.js Image Component
|
||||
|
||||
### Always Use next/image
|
||||
|
||||
```typescript
|
||||
// Bad: Raw img tag
|
||||
<img src="/hero.jpg" alt="Hero" />
|
||||
|
||||
// Good: Optimized Image component
|
||||
import Image from 'next/image';
|
||||
|
||||
<Image
|
||||
src="/hero.jpg"
|
||||
alt="Hero image"
|
||||
width={1200}
|
||||
height={600}
|
||||
priority // For above-the-fold images
|
||||
/>
|
||||
```
|
||||
|
||||
### Responsive Images
|
||||
|
||||
```typescript
|
||||
// Fill container
|
||||
<div className="relative h-64 w-full">
|
||||
<Image
|
||||
src="/banner.jpg"
|
||||
alt="Banner"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Remote Images
|
||||
|
||||
Configure domains in `next.config.js`:
|
||||
|
||||
```javascript
|
||||
// next.config.js
|
||||
module.exports = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'images.example.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Command Palette (cmdk)
|
||||
|
||||
### Basic Implementation
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { Command } from 'cmdk';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function CommandPalette() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Toggle with keyboard shortcut
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setOpen((open) => !open);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', down);
|
||||
return () => document.removeEventListener('keydown', down);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Command.Dialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
label="Global Command Menu"
|
||||
>
|
||||
<Command.Input placeholder="Type a command or search..." />
|
||||
<Command.List>
|
||||
<Command.Empty>No results found.</Command.Empty>
|
||||
|
||||
<Command.Group heading="Navigation">
|
||||
<Command.Item onSelect={() => router.push('/dashboard')}>
|
||||
Go to Dashboard
|
||||
</Command.Item>
|
||||
<Command.Item onSelect={() => router.push('/settings')}>
|
||||
Go to Settings
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
|
||||
<Command.Group heading="Actions">
|
||||
<Command.Item onSelect={handleNewOrder}>
|
||||
Create New Order
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
</Command.List>
|
||||
</Command.Dialog>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### With Search Results
|
||||
|
||||
```typescript
|
||||
export function SearchCommandPalette() {
|
||||
const [search, setSearch] = useState('');
|
||||
const { data: results, isLoading } = useSearch(search);
|
||||
|
||||
return (
|
||||
<Command.Dialog open={open} onOpenChange={setOpen}>
|
||||
<Command.Input
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder="Search..."
|
||||
/>
|
||||
<Command.List>
|
||||
{isLoading && <Command.Loading>Searching...</Command.Loading>}
|
||||
|
||||
<Command.Empty>No results found.</Command.Empty>
|
||||
|
||||
{results?.map((item) => (
|
||||
<Command.Item
|
||||
key={item.id}
|
||||
value={item.title}
|
||||
onSelect={() => handleSelect(item)}
|
||||
>
|
||||
{item.title}
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.List>
|
||||
</Command.Dialog>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Styling with Tailwind
|
||||
|
||||
### Component Styling Pattern
|
||||
|
||||
```typescript
|
||||
// Use className for styling
|
||||
export function Card({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border bg-card p-4 shadow-sm',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Conditional Styles
|
||||
|
||||
```typescript
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
className,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md font-medium',
|
||||
// Variants
|
||||
{
|
||||
'bg-primary text-primary-foreground': variant === 'primary',
|
||||
'bg-secondary text-secondary-foreground': variant === 'secondary',
|
||||
'border bg-transparent': variant === 'outline',
|
||||
},
|
||||
// Sizes
|
||||
{
|
||||
'h-8 px-3 text-sm': size === 'sm',
|
||||
'h-10 px-4': size === 'md',
|
||||
'h-12 px-6 text-lg': size === 'lg',
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Responsive Design
|
||||
|
||||
```typescript
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{items.map((item) => (
|
||||
<Card key={item.id}>{item.content}</Card>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
### Focus Management
|
||||
|
||||
```typescript
|
||||
export function Modal({ open, onClose, children }: ModalProps) {
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
closeButtonRef.current?.focus();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
{children}
|
||||
<button ref={closeButtonRef} onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### ARIA Labels
|
||||
|
||||
```typescript
|
||||
<button
|
||||
aria-label="Close dialog"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls="dropdown-menu"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="dropdown-menu"
|
||||
role="menu"
|
||||
aria-hidden={!isOpen}
|
||||
>
|
||||
{/* Menu items */}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Server First**: Default to Server Components
|
||||
2. **Semantic HTML**: Use the right element for the job
|
||||
3. **Optimize Images**: Always use next/image
|
||||
4. **Accessibility**: Include ARIA labels and keyboard support
|
||||
5. **Type Props**: Define TypeScript interfaces for all props
|
||||
6. **Composition**: Break large components into smaller pieces
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Using `div` for buttons and links
|
||||
- Using `img` instead of `next/image`
|
||||
- Adding `'use client'` at the top of every file
|
||||
- Inline styles instead of Tailwind classes
|
||||
- Missing accessibility attributes
|
||||
- Components with too many responsibilities
|
||||
381
.trellis/spec/frontend/css-layout.md
Normal file
381
.trellis/spec/frontend/css-layout.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# CSS & Layout Best Practices
|
||||
|
||||
This document covers CSS patterns, layout strategies, and cross-environment compatibility considerations.
|
||||
|
||||
## Flexbox Patterns
|
||||
|
||||
### Use items-stretch on Main Flex Containers
|
||||
|
||||
For full-height layouts where children should fill the available space:
|
||||
|
||||
```typescript
|
||||
// Good: items-stretch (default) allows children to fill height
|
||||
<div className="flex h-screen">
|
||||
<aside className="w-64 bg-gray-100">
|
||||
{/* Sidebar fills full height */}
|
||||
</aside>
|
||||
<main className="flex-1">
|
||||
{/* Main content fills full height */}
|
||||
</main>
|
||||
</div>
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Bad: items-center prevents children from filling container height
|
||||
<div className="flex h-screen items-center">
|
||||
<aside className="w-64 bg-gray-100">
|
||||
{/* Sidebar only as tall as its content */}
|
||||
</aside>
|
||||
<main className="flex-1">
|
||||
{/* Main content only as tall as its content */}
|
||||
</main>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Nested Flex Containers
|
||||
|
||||
```typescript
|
||||
<div className="flex h-screen flex-col">
|
||||
{/* Header - fixed height */}
|
||||
<header className="h-16 shrink-0 border-b">
|
||||
<nav>...</nav>
|
||||
</header>
|
||||
|
||||
{/* Main area - fills remaining space */}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Sidebar - fixed width, full height */}
|
||||
<aside className="w-64 shrink-0 overflow-y-auto border-r">
|
||||
<nav>...</nav>
|
||||
</aside>
|
||||
|
||||
{/* Content - fills remaining width */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="p-6">...</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### min-h-0 for Overflow Control
|
||||
|
||||
When using flex containers with scrollable children:
|
||||
|
||||
```typescript
|
||||
// Without min-h-0, content may overflow
|
||||
<div className="flex h-screen flex-col">
|
||||
<div className="flex-1">
|
||||
{/* This might overflow if content is tall */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// With min-h-0, overflow is properly contained
|
||||
<div className="flex h-screen flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{/* Content scrolls within container */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Parent-Child Styling Pattern
|
||||
|
||||
### Parent Provides External Styles
|
||||
|
||||
The parent component controls:
|
||||
- Positioning (absolute, relative, grid placement)
|
||||
- External spacing (margin, gap)
|
||||
- Size constraints (width, max-width)
|
||||
|
||||
```typescript
|
||||
// Parent component
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Card className="col-span-2" /> {/* Parent sets grid span */}
|
||||
<Card />
|
||||
</div>
|
||||
```
|
||||
|
||||
### Child Provides Internal Layout
|
||||
|
||||
The child component controls:
|
||||
- Internal padding
|
||||
- Internal layout (flex, grid)
|
||||
- Background, borders, shadows
|
||||
- Typography
|
||||
|
||||
```typescript
|
||||
// Child component
|
||||
export function Card({ className, children }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Internal styles owned by Card
|
||||
'rounded-lg border bg-white p-4 shadow-sm',
|
||||
// External styles from parent
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Complete Example
|
||||
|
||||
```typescript
|
||||
// Page layout (parent)
|
||||
export function DashboardPage() {
|
||||
return (
|
||||
<div className="grid gap-6 p-6 lg:grid-cols-3">
|
||||
{/* Parent controls: grid position, external spacing */}
|
||||
<StatsCard className="lg:col-span-2" />
|
||||
<ActivityFeed className="lg:row-span-2" />
|
||||
<RecentOrders />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Card component (child)
|
||||
export function StatsCard({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Child controls: internal padding, background, border
|
||||
'flex flex-col gap-4 rounded-xl bg-white p-6 shadow',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Internal layout */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Cross-Environment Testing
|
||||
|
||||
### Dev Mode (Turbopack) vs Production (Webpack)
|
||||
|
||||
CSS may behave differently between development and production builds:
|
||||
|
||||
```bash
|
||||
# Test in development (Turbopack)
|
||||
pnpm dev
|
||||
|
||||
# Test in production (Webpack)
|
||||
pnpm build && pnpm start
|
||||
```
|
||||
|
||||
### Common Differences
|
||||
|
||||
1. **CSS Order**: Tailwind classes may be applied in different orders
|
||||
2. **Purging**: Unused classes removed in production
|
||||
3. **Minification**: Class names optimized
|
||||
|
||||
### Testing Checklist
|
||||
|
||||
- [ ] Run `pnpm dev` and test all features
|
||||
- [ ] Run `pnpm build && pnpm start` and test again
|
||||
- [ ] Check for visual differences
|
||||
- [ ] Verify responsive breakpoints work
|
||||
- [ ] Test animations and transitions
|
||||
|
||||
## Mobile Touch Optimization
|
||||
|
||||
### Disable Tap Highlight
|
||||
|
||||
Prevent the default blue/gray highlight on mobile tap:
|
||||
|
||||
```typescript
|
||||
// Using Tailwind
|
||||
<button className="[-webkit-tap-highlight-color:transparent]">
|
||||
Tap me
|
||||
</button>
|
||||
|
||||
// Using inline styles (when needed)
|
||||
<button style={{ WebkitTapHighlightColor: 'transparent' }}>
|
||||
Tap me
|
||||
</button>
|
||||
|
||||
// Global reset in CSS
|
||||
@layer base {
|
||||
button, a, [role="button"] {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Touch-Friendly Sizing
|
||||
|
||||
```typescript
|
||||
// Minimum touch target: 44x44px
|
||||
<button className="min-h-[44px] min-w-[44px] p-3">
|
||||
<Icon size={20} />
|
||||
</button>
|
||||
|
||||
// For lists
|
||||
<ul className="divide-y">
|
||||
{items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<button className="w-full px-4 py-3 text-left">
|
||||
{item.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
```
|
||||
|
||||
### Prevent Pull-to-Refresh
|
||||
|
||||
When implementing custom scroll behaviors:
|
||||
|
||||
```typescript
|
||||
<div
|
||||
className="h-screen overflow-y-auto overscroll-contain"
|
||||
style={{ touchAction: 'pan-y' }}
|
||||
>
|
||||
{/* Scrollable content */}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Responsive Design Patterns
|
||||
|
||||
### Mobile-First Approach
|
||||
|
||||
```typescript
|
||||
// Start with mobile styles, add breakpoints for larger screens
|
||||
<div className="
|
||||
p-4 // Mobile: small padding
|
||||
md:p-6 // Tablet: medium padding
|
||||
lg:p-8 // Desktop: large padding
|
||||
">
|
||||
<h1 className="
|
||||
text-xl // Mobile: small heading
|
||||
md:text-2xl // Tablet: medium heading
|
||||
lg:text-3xl // Desktop: large heading
|
||||
">
|
||||
Title
|
||||
</h1>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Container Queries (Tailwind v4)
|
||||
|
||||
```typescript
|
||||
// Container-based responsive styles
|
||||
<div className="@container">
|
||||
<div className="
|
||||
flex flex-col
|
||||
@md:flex-row // Row layout when container >= md
|
||||
@lg:gap-6 // Larger gap when container >= lg
|
||||
">
|
||||
{/* Content */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Hiding/Showing Elements
|
||||
|
||||
```typescript
|
||||
// Hide on mobile, show on desktop
|
||||
<div className="hidden lg:block">
|
||||
Desktop only content
|
||||
</div>
|
||||
|
||||
// Show on mobile, hide on desktop
|
||||
<div className="lg:hidden">
|
||||
Mobile only content
|
||||
</div>
|
||||
```
|
||||
|
||||
## Z-Index Management
|
||||
|
||||
### Establish a Scale
|
||||
|
||||
```css
|
||||
/* In your CSS or Tailwind config */
|
||||
:root {
|
||||
--z-dropdown: 10;
|
||||
--z-sticky: 20;
|
||||
--z-fixed: 30;
|
||||
--z-modal-backdrop: 40;
|
||||
--z-modal: 50;
|
||||
--z-popover: 60;
|
||||
--z-tooltip: 70;
|
||||
}
|
||||
```
|
||||
|
||||
### Tailwind Config
|
||||
|
||||
```javascript
|
||||
// tailwind.config.js
|
||||
module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
zIndex: {
|
||||
dropdown: '10',
|
||||
sticky: '20',
|
||||
fixed: '30',
|
||||
modalBackdrop: '40',
|
||||
modal: '50',
|
||||
popover: '60',
|
||||
tooltip: '70',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```typescript
|
||||
<div className="z-modal">Modal content</div>
|
||||
<div className="z-tooltip">Tooltip</div>
|
||||
```
|
||||
|
||||
## Animation Best Practices
|
||||
|
||||
### Use CSS Transitions
|
||||
|
||||
```typescript
|
||||
<button className="
|
||||
transition-colors duration-200 ease-out
|
||||
hover:bg-primary-dark
|
||||
">
|
||||
Hover me
|
||||
</button>
|
||||
```
|
||||
|
||||
### Respect Motion Preferences
|
||||
|
||||
```typescript
|
||||
// Disable animations for users who prefer reduced motion
|
||||
<div className="
|
||||
transition-transform duration-300
|
||||
motion-reduce:transition-none
|
||||
hover:scale-105
|
||||
motion-reduce:hover:scale-100
|
||||
">
|
||||
Animated element
|
||||
</div>
|
||||
```
|
||||
|
||||
### Hardware Acceleration
|
||||
|
||||
```typescript
|
||||
// Use transform for smooth animations
|
||||
<div className="
|
||||
translate-x-0 transition-transform
|
||||
group-hover:translate-x-2
|
||||
">
|
||||
Slides on hover
|
||||
</div>
|
||||
```
|
||||
|
||||
## Best Practices Summary
|
||||
|
||||
1. **items-stretch**: Default for main flex containers
|
||||
2. **Parent External, Child Internal**: Clear separation of concerns
|
||||
3. **Test Both Modes**: Always verify in dev AND production
|
||||
4. **Touch Optimization**: Disable tap highlight, ensure touch targets
|
||||
5. **Mobile First**: Build up from smallest screens
|
||||
6. **Consistent Z-Index**: Use a defined scale
|
||||
7. **Respect Accessibility**: Honor motion preferences
|
||||
189
.trellis/spec/frontend/directory-structure.md
Normal file
189
.trellis/spec/frontend/directory-structure.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# Directory Structure
|
||||
|
||||
This document describes the module organization and folder conventions for the frontend application.
|
||||
|
||||
## Overview
|
||||
|
||||
```
|
||||
app/ # Next.js App Router
|
||||
├── (marketing)/ # Public marketing pages (i18n)
|
||||
│ └── [locale]/ # Locale-based routing
|
||||
└── (app)/ # Protected application routes
|
||||
└── app/ # Main application routes
|
||||
modules/ # Feature modules
|
||||
├── [feature]/ # Feature module
|
||||
│ ├── components/ # UI components
|
||||
│ ├── hooks/ # Custom hooks
|
||||
│ ├── context/ # React Context
|
||||
│ ├── lib/ # Utilities and data transforms
|
||||
│ └── types/ # Frontend view model types
|
||||
├── shared/ # Shared components across features
|
||||
└── ui/ # UI component library
|
||||
middleware.ts # Authentication & routing middleware
|
||||
```
|
||||
|
||||
## Module Structure
|
||||
|
||||
### Feature Module Pattern
|
||||
|
||||
Each feature module should follow this structure:
|
||||
|
||||
```
|
||||
modules/dashboard/
|
||||
├── components/
|
||||
│ ├── DashboardHeader.tsx
|
||||
│ ├── StatsCard.tsx
|
||||
│ ├── ActivityFeed.tsx
|
||||
│ └── index.ts # Barrel export
|
||||
├── hooks/
|
||||
│ ├── useDashboardStats.ts
|
||||
│ ├── useActivityFeed.ts
|
||||
│ └── index.ts
|
||||
├── context/
|
||||
│ ├── DashboardContext.tsx
|
||||
│ └── index.ts
|
||||
├── lib/
|
||||
│ ├── formatters.ts # Data formatting utilities
|
||||
│ ├── transformers.ts # API response transformers
|
||||
│ └── constants.ts # Feature-specific constants
|
||||
├── types/
|
||||
│ └── index.ts # View model types
|
||||
└── index.ts # Public API of the module
|
||||
```
|
||||
|
||||
### Component Organization
|
||||
|
||||
```
|
||||
components/
|
||||
├── [ComponentName].tsx # Main component file
|
||||
├── [ComponentName].test.tsx # Unit tests (if applicable)
|
||||
└── index.ts # Barrel export
|
||||
```
|
||||
|
||||
### Hooks Organization
|
||||
|
||||
```
|
||||
hooks/
|
||||
├── useFeatureData.ts # Data fetching hooks
|
||||
├── useFeatureActions.ts # Mutation hooks
|
||||
├── useFeatureState.ts # Local state hooks
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
## Shared Modules
|
||||
|
||||
### `modules/shared/`
|
||||
|
||||
Components and utilities shared across multiple features:
|
||||
|
||||
```
|
||||
shared/
|
||||
├── components/
|
||||
│ ├── Layout/ # Layout components
|
||||
│ ├── Navigation/ # Navigation components
|
||||
│ ├── DataTable/ # Reusable data tables
|
||||
│ └── Forms/ # Form components
|
||||
├── hooks/
|
||||
│ ├── useUser.ts # Current user hook
|
||||
│ ├── useOrganization.ts # Organization context
|
||||
│ └── usePermissions.ts # Permission checks
|
||||
└── lib/
|
||||
├── api.ts # API client configuration
|
||||
└── utils.ts # Shared utilities
|
||||
```
|
||||
|
||||
### `modules/ui/`
|
||||
|
||||
Low-level UI components (design system):
|
||||
|
||||
```
|
||||
ui/
|
||||
├── Button/
|
||||
├── Input/
|
||||
├── Select/
|
||||
├── Dialog/
|
||||
├── Toast/
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Files
|
||||
|
||||
| Type | Convention | Example |
|
||||
|------|------------|---------|
|
||||
| Components | PascalCase | `UserProfile.tsx` |
|
||||
| Hooks | camelCase with `use` prefix | `useUserProfile.ts` |
|
||||
| Context | PascalCase with `Context` suffix | `UserContext.tsx` |
|
||||
| Utilities | camelCase | `formatDate.ts` |
|
||||
| Constants | camelCase or SCREAMING_SNAKE_CASE | `constants.ts` |
|
||||
| Types | PascalCase | `types.ts` or `UserTypes.ts` |
|
||||
|
||||
### Exports
|
||||
|
||||
Use barrel exports (`index.ts`) for clean imports:
|
||||
|
||||
```typescript
|
||||
// modules/dashboard/components/index.ts
|
||||
export { DashboardHeader } from './DashboardHeader';
|
||||
export { StatsCard } from './StatsCard';
|
||||
export { ActivityFeed } from './ActivityFeed';
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Usage
|
||||
import { DashboardHeader, StatsCard } from '@/modules/dashboard/components';
|
||||
```
|
||||
|
||||
## Route-Module Mapping
|
||||
|
||||
Routes in `app/(app)/` should map to modules in `modules/`:
|
||||
|
||||
```
|
||||
app/(app)/
|
||||
├── dashboard/
|
||||
│ └── page.tsx -> modules/dashboard/
|
||||
├── users/
|
||||
│ ├── page.tsx -> modules/users/
|
||||
│ └── [id]/
|
||||
│ └── page.tsx -> modules/users/ (detail view)
|
||||
├── settings/
|
||||
│ └── page.tsx -> modules/settings/
|
||||
└── orders/
|
||||
├── page.tsx -> modules/orders/
|
||||
└── [id]/
|
||||
└── page.tsx -> modules/orders/ (detail view)
|
||||
```
|
||||
|
||||
## Import Path Aliases
|
||||
|
||||
Configure in `tsconfig.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@/modules/*": ["./modules/*"],
|
||||
"@/components/*": ["./components/*"],
|
||||
"@/lib/*": ["./lib/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Colocation**: Keep related files close together
|
||||
2. **Single Responsibility**: Each module should have one clear purpose
|
||||
3. **Explicit Dependencies**: Import what you need, avoid implicit globals
|
||||
4. **Barrel Exports**: Use `index.ts` for public APIs
|
||||
5. **Private by Default**: Only export what needs to be shared
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
- Deeply nested folder structures (max 3-4 levels)
|
||||
- Circular dependencies between modules
|
||||
- Mixing feature code with shared utilities
|
||||
- Importing internal module files directly (use barrel exports)
|
||||
- Creating "utils" folders that become dumping grounds
|
||||
328
.trellis/spec/frontend/hooks.md
Normal file
328
.trellis/spec/frontend/hooks.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# Hook Development Patterns
|
||||
|
||||
This document covers React hook patterns for data fetching, mutations, and state management using React Query with oRPC.
|
||||
|
||||
## Query Hooks
|
||||
|
||||
### Basic Query Pattern
|
||||
|
||||
```typescript
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { orpcClient } from '@/lib/orpc';
|
||||
|
||||
export function useUsers() {
|
||||
return useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => orpcClient.users.list(),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Query with Parameters
|
||||
|
||||
```typescript
|
||||
export function useUser(userId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['users', userId],
|
||||
queryFn: () => orpcClient.users.get({ id: userId }),
|
||||
enabled: !!userId, // Only fetch when userId is available
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Query with Filters
|
||||
|
||||
```typescript
|
||||
interface UseOrdersOptions {
|
||||
status?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export function useOrders(options: UseOrdersOptions = {}) {
|
||||
const { status, page = 1, pageSize = 20 } = options;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['orders', { status, page, pageSize }],
|
||||
queryFn: () => orpcClient.orders.list({ status, page, pageSize }),
|
||||
placeholderData: (previousData) => previousData, // Keep previous data while fetching
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Mutation Hooks
|
||||
|
||||
### Basic Mutation Pattern
|
||||
|
||||
```typescript
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { orpcClient } from '@/lib/orpc';
|
||||
|
||||
export function useCreateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateUserInput) => orpcClient.users.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Mutation with Optimistic Updates
|
||||
|
||||
```typescript
|
||||
type OrderListData = Awaited<ReturnType<typeof orpcClient.orders.list>>;
|
||||
|
||||
export function useUpdateOrderStatus() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
||||
orpcClient.orders.updateStatus({ id, status }),
|
||||
|
||||
onMutate: async ({ id, status }) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: ['orders'] });
|
||||
|
||||
// Snapshot previous value
|
||||
const previousOrders = queryClient.getQueryData<OrderListData>(['orders']);
|
||||
|
||||
// Optimistically update
|
||||
queryClient.setQueryData<OrderListData>(['orders'], (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
items: old.items.map((order) =>
|
||||
order.id === id ? { ...order, status } : order
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return { previousOrders };
|
||||
},
|
||||
|
||||
onError: (_err, _variables, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousOrders) {
|
||||
queryClient.setQueryData(['orders'], context.previousOrders);
|
||||
}
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
// Always refetch after mutation
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Overriding Mutation Callbacks
|
||||
|
||||
When overriding mutation callbacks at the call site, you MUST add explicit generics to maintain type safety:
|
||||
|
||||
### Problem: Lost Type Safety
|
||||
|
||||
```typescript
|
||||
// Hook definition
|
||||
export function useDeleteUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => orpcClient.users.delete({ id }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Bad: Overriding without generics loses type safety
|
||||
const deleteUser = useDeleteUser();
|
||||
deleteUser.mutate(userId, {
|
||||
onSuccess: (data) => {
|
||||
// 'data' is typed as 'unknown' here!
|
||||
console.log(data.id); // TypeScript error or runtime error
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Solution: Explicit Generics
|
||||
|
||||
```typescript
|
||||
// Infer types for the mutation
|
||||
type DeleteUserData = Awaited<ReturnType<typeof orpcClient.users.delete>>;
|
||||
type DeleteUserVariables = string;
|
||||
|
||||
// Good: Add explicit generics when overriding callbacks
|
||||
deleteUser.mutate<DeleteUserData, Error, DeleteUserVariables>(userId, {
|
||||
onSuccess: (data) => {
|
||||
// 'data' is properly typed
|
||||
console.log(data.id); // Works correctly
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Alternative: Define Types in Hook
|
||||
|
||||
```typescript
|
||||
// Export types from the hook file
|
||||
export type DeleteUserMutationData = Awaited<
|
||||
ReturnType<typeof orpcClient.users.delete>
|
||||
>;
|
||||
|
||||
// Usage with exported types
|
||||
deleteUser.mutate(userId, {
|
||||
onSuccess: (data: DeleteUserMutationData) => {
|
||||
console.log(data.id);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Using orpcClient Directly in Hooks
|
||||
|
||||
Inside hooks, use `orpcClient` directly instead of wrapping with `useMutation`:
|
||||
|
||||
### DO: Direct orpcClient Usage
|
||||
|
||||
```typescript
|
||||
export function useOrderActions() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateOrder = useMutation({
|
||||
mutationFn: (data: UpdateOrderInput) => orpcClient.orders.update(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteOrder = useMutation({
|
||||
mutationFn: (id: string) => orpcClient.orders.delete({ id }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
updateOrder: updateOrder.mutate,
|
||||
deleteOrder: deleteOrder.mutate,
|
||||
isUpdating: updateOrder.isPending,
|
||||
isDeleting: deleteOrder.isPending,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### DON'T: Nested Hooks
|
||||
|
||||
```typescript
|
||||
// Bad: Don't create hooks that use other mutation hooks
|
||||
export function useOrderActions() {
|
||||
// Don't do this - creates unnecessary abstraction
|
||||
const updateMutation = useUpdateOrder();
|
||||
const deleteMutation = useDeleteOrder();
|
||||
|
||||
return {
|
||||
updateOrder: updateMutation.mutate,
|
||||
deleteOrder: deleteMutation.mutate,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Compound Hooks
|
||||
|
||||
Combine related queries and mutations into a single hook:
|
||||
|
||||
```typescript
|
||||
export function useProduct(productId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['products', productId],
|
||||
queryFn: () => orpcClient.products.get({ id: productId }),
|
||||
enabled: !!productId,
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: (data: UpdateProductInput) =>
|
||||
orpcClient.products.update({ id: productId, ...data }),
|
||||
onSuccess: (updatedProduct) => {
|
||||
queryClient.setQueryData(['products', productId], updatedProduct);
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => orpcClient.products.delete({ id: productId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
product: query.data,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
updateProduct: update.mutate,
|
||||
deleteProduct: remove.mutate,
|
||||
isUpdating: update.isPending,
|
||||
isDeleting: remove.isPending,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Infinite Query Pattern
|
||||
|
||||
```typescript
|
||||
export function useInfiniteOrders() {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ['orders', 'infinite'],
|
||||
queryFn: ({ pageParam = 1 }) =>
|
||||
orpcClient.orders.list({ page: pageParam, pageSize: 20 }),
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.hasMore ? lastPage.page + 1 : undefined,
|
||||
initialPageParam: 1,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Dependent Queries
|
||||
|
||||
```typescript
|
||||
export function useUserOrders(userId: string) {
|
||||
// First query: get user
|
||||
const userQuery = useQuery({
|
||||
queryKey: ['users', userId],
|
||||
queryFn: () => orpcClient.users.get({ id: userId }),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
// Second query: depends on user data
|
||||
const ordersQuery = useQuery({
|
||||
queryKey: ['orders', { userId }],
|
||||
queryFn: () => orpcClient.orders.list({ userId }),
|
||||
enabled: !!userQuery.data, // Only run when user is loaded
|
||||
});
|
||||
|
||||
return {
|
||||
user: userQuery.data,
|
||||
orders: ordersQuery.data,
|
||||
isLoading: userQuery.isLoading || ordersQuery.isLoading,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Single Responsibility**: Each hook should have one clear purpose
|
||||
2. **Consistent Naming**: `useXxx` for hooks, `useXxxQuery` for queries, `useXxxMutation` for mutations
|
||||
3. **Error Handling**: Always consider error states in your hooks
|
||||
4. **Loading States**: Expose loading states for UI feedback
|
||||
5. **Cache Keys**: Use consistent, hierarchical query keys
|
||||
6. **Type Safety**: Always maintain proper TypeScript types
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Forgetting to invalidate related queries after mutations
|
||||
- Not handling race conditions with `cancelQueries`
|
||||
- Missing `enabled` flag for conditional queries
|
||||
- Not providing explicit generics when overriding callbacks
|
||||
- Creating too many small hooks instead of compound hooks
|
||||
125
.trellis/spec/frontend/index.md
Normal file
125
.trellis/spec/frontend/index.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Next.js Frontend Development Guidelines
|
||||
|
||||
> Universal frontend development guidelines for Next.js full-stack applications with React + TypeScript + TailwindCSS.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: Next.js 15, React 19
|
||||
- **Language**: TypeScript (strict mode)
|
||||
- **Styling**: TailwindCSS 4, Radix UI
|
||||
- **API**: oRPC (OpenAPI RPC), React Query (TanStack Query)
|
||||
- **URL State**: nuqs
|
||||
- **Auth**: better-auth
|
||||
- **AI**: Vercel AI SDK (@ai-sdk/react)
|
||||
|
||||
---
|
||||
|
||||
## Documentation Files
|
||||
|
||||
| File | Description | Priority |
|
||||
| ---------------------------------------------------- | ---------------------------------------------------- | ------------- |
|
||||
| [components.md](./components.md) | Server/Client components, semantic HTML, next/image | **Must Read** |
|
||||
| [authentication.md](./authentication.md) | better-auth client, session, protected routes | **Must Read** |
|
||||
| [orpc-usage.md](./orpc-usage.md) | Type-safe API calls, React Query integration | **Must Read** |
|
||||
| [hooks.md](./hooks.md) | Query and mutation hook patterns | Reference |
|
||||
| [api-integration.md](./api-integration.md) | oRPC client, real-time, AI streaming | Reference |
|
||||
| [state-management.md](./state-management.md) | URL state with nuqs, React Context patterns | Reference |
|
||||
| [directory-structure.md](./directory-structure.md) | Project structure and module conventions | Reference |
|
||||
| [type-safety.md](./type-safety.md) | TypeScript guidelines, type inference, Zod | Reference |
|
||||
| [css-layout.md](./css-layout.md) | CSS patterns, flexbox, responsive, touch | Reference |
|
||||
| [ai-sdk-integration.md](./ai-sdk-integration.md) | useChat hook, streaming, tool call handling | Reference |
|
||||
| [quality.md](./quality.md) | Pre-commit checklist and code quality standards | Reference |
|
||||
|
||||
---
|
||||
|
||||
## Quick Navigation by Task
|
||||
|
||||
### Before Starting Development
|
||||
|
||||
| Task | Document |
|
||||
| --------------------------------- | -------------------------------------------------- |
|
||||
| Understand project structure | [directory-structure.md](./directory-structure.md) |
|
||||
| Learn Server vs Client components | [components.md](./components.md) |
|
||||
| Set up authentication | [authentication.md](./authentication.md) |
|
||||
|
||||
### During Development
|
||||
|
||||
| Task | Document |
|
||||
| --------------------------- | -------------------------------------------------- |
|
||||
| Make type-safe API calls | [orpc-usage.md](./orpc-usage.md) |
|
||||
| Create custom hooks | [hooks.md](./hooks.md) |
|
||||
| Manage application state | [state-management.md](./state-management.md) |
|
||||
| Build UI components | [components.md](./components.md) |
|
||||
| Ensure type safety | [type-safety.md](./type-safety.md) |
|
||||
| Integrate AI features | [ai-sdk-integration.md](./ai-sdk-integration.md) |
|
||||
| Handle CSS & layout | [css-layout.md](./css-layout.md) |
|
||||
|
||||
### Before Committing
|
||||
|
||||
| Task | Document |
|
||||
| ----------------------- | -------------------------------- |
|
||||
| Run quality checklist | [quality.md](./quality.md) |
|
||||
| Verify CSS in both envs | [css-layout.md](./css-layout.md) |
|
||||
| Check type safety | [type-safety.md](./type-safety.md) |
|
||||
|
||||
---
|
||||
|
||||
## Core Rules Summary
|
||||
|
||||
| Rule | Reference |
|
||||
| ------------------------------------------------------------ | -------------------------------------------------- |
|
||||
| **Default to Server Components** | [components.md](./components.md) |
|
||||
| **Use `<button>` for clickable actions, not `<div>`** | [components.md](./components.md) |
|
||||
| **Always use `next/image` instead of `<img>`** | [components.md](./components.md) |
|
||||
| **Import types from backend, never redefine them** | [type-safety.md](./type-safety.md) |
|
||||
| **No `any` types or `@ts-expect-error` in new code** | [type-safety.md](./type-safety.md) |
|
||||
| **Use oRPC client for API calls (not raw fetch)** | [orpc-usage.md](./orpc-usage.md) |
|
||||
| **Use oRPC generated query keys (not manual strings)** | [orpc-usage.md](./orpc-usage.md) |
|
||||
| **Store shareable state in URL with nuqs** | [state-management.md](./state-management.md) |
|
||||
| **Use `items-stretch` on main flex containers** | [css-layout.md](./css-layout.md) |
|
||||
| **Handle both tool call formats (streaming + history)** | [ai-sdk-integration.md](./ai-sdk-integration.md) |
|
||||
| **Always check session loading state before rendering** | [authentication.md](./authentication.md) |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
+--------------------------------------------------------------+
|
||||
| Next.js Application |
|
||||
| |
|
||||
| app/ modules/ |
|
||||
| ├── (marketing)/ ├── [feature]/ |
|
||||
| │ └── [locale]/ │ ├── components/ |
|
||||
| └── (app)/ │ ├── hooks/ |
|
||||
| └── [routes]/ │ ├── context/ |
|
||||
| │ └── lib/ |
|
||||
| ├── shared/ |
|
||||
| └── ui/ |
|
||||
+-------------------------------+------------------------------+
|
||||
|
|
||||
oRPC (type-safe RPC) | React Query (cache)
|
||||
|
|
||||
+-------------------------------+------------------------------+
|
||||
| API Layer (Server) |
|
||||
| +--------------+ +----------------+ +------------------+ |
|
||||
| | oRPC | | better-auth | | AI SDK | |
|
||||
| | Router | | Sessions | | Streaming | |
|
||||
| +--------------+ +----------------+ +------------------+ |
|
||||
+--------------------------------------------------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Read the Must-Read documents** - Components, authentication, and oRPC usage
|
||||
2. **Set up your project structure** - Follow [directory-structure.md](./directory-structure.md)
|
||||
3. **Configure TypeScript paths** - See [type-safety.md](./type-safety.md)
|
||||
4. **Set up API client** - Use patterns from [orpc-usage.md](./orpc-usage.md)
|
||||
5. **Build components** - Follow [components.md](./components.md) and [hooks.md](./hooks.md)
|
||||
6. **Before committing** - Complete the [quality.md](./quality.md) checklist
|
||||
|
||||
---
|
||||
|
||||
**Language**: All documentation is written in **English**.
|
||||
662
.trellis/spec/frontend/orpc-usage.md
Normal file
662
.trellis/spec/frontend/orpc-usage.md
Normal file
@@ -0,0 +1,662 @@
|
||||
# oRPC Frontend Usage Guidelines
|
||||
|
||||
This document provides comprehensive guidelines for using oRPC in frontend applications, covering client setup, React Query integration, and best practices.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
oRPC (OpenAPI RPC) provides type-safe RPC-style API calls with automatic TypeScript type inference. When combined with React Query (TanStack Query), it offers a powerful solution for data fetching, caching, and state synchronization.
|
||||
|
||||
**Key Benefits:**
|
||||
- End-to-end type safety from backend to frontend
|
||||
- Automatic query key generation
|
||||
- Seamless React Query integration
|
||||
- Built-in error handling
|
||||
|
||||
## 2. Client Setup
|
||||
|
||||
### Basic Client Configuration
|
||||
|
||||
```typescript
|
||||
// lib/orpc-client.ts
|
||||
import { createORPCClient, onError } from "@orpc/client";
|
||||
import { RPCLink } from "@orpc/client/fetch";
|
||||
import type { ApiRouterClient } from "@your-app/api/orpc/router"; // Replace with your monorepo package path
|
||||
|
||||
const link = new RPCLink({
|
||||
url: "/api/rpc",
|
||||
headers: async () => {
|
||||
// Client-side: return empty headers (cookies handled automatically)
|
||||
if (typeof window !== "undefined") {
|
||||
return {};
|
||||
}
|
||||
// Server-side: forward request headers for SSR
|
||||
const { headers } = await import("next/headers");
|
||||
return Object.fromEntries(await headers());
|
||||
},
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
// Ignore abort errors (e.g., from React strict mode)
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
console.error(error);
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export const orpcClient: ApiRouterClient = createORPCClient(link);
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- The `ApiRouterClient` type ensures full type safety
|
||||
- Headers handling differs between client and server environments
|
||||
- Error interceptors provide centralized error logging
|
||||
|
||||
## 3. React Query Integration
|
||||
|
||||
### Creating Query Utilities
|
||||
|
||||
```typescript
|
||||
// lib/orpc-query-utils.ts
|
||||
import { createTanstackQueryUtils } from "@orpc/tanstack-query";
|
||||
import { orpcClient } from "./orpc-client";
|
||||
|
||||
export const orpc = createTanstackQueryUtils(orpcClient);
|
||||
```
|
||||
|
||||
The `orpc` object provides utilities for generating query options and keys that integrate seamlessly with React Query.
|
||||
|
||||
## 4. Query Patterns
|
||||
|
||||
### 4.1 Basic Query with useQuery
|
||||
|
||||
```typescript
|
||||
import { orpc } from "@/lib/orpc-query-utils";
|
||||
import { orpcClient } from "@/lib/orpc-client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
// Derive types from the client
|
||||
type ItemResult = Awaited<ReturnType<(typeof orpcClient)["items"]["get"]>>;
|
||||
|
||||
export function useItem(itemId: string | null) {
|
||||
const hasItemId = typeof itemId === "string" && itemId.trim().length > 0;
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery<ItemResult | undefined>({
|
||||
...orpc.items.get.queryOptions({
|
||||
input: { itemId: itemId ?? "" },
|
||||
}),
|
||||
enabled: hasItemId,
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // Keep in garbage collection for 10 minutes
|
||||
});
|
||||
|
||||
return {
|
||||
item: data?.item ?? null,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Infinite Query with Cursor Pagination
|
||||
|
||||
```typescript
|
||||
import { orpc } from "@/lib/orpc-query-utils";
|
||||
import { orpcClient } from "@/lib/orpc-client";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import type { InfiniteData } from "@tanstack/react-query";
|
||||
|
||||
type ListResult = Awaited<ReturnType<(typeof orpcClient)["items"]["list"]>>;
|
||||
type ListCursor = { lastUpdatedAt: string; id: string } | undefined;
|
||||
type ListQueryKey = ReturnType<typeof orpc.items.list.queryKey>;
|
||||
|
||||
interface UseItemListOptions {
|
||||
categoryId: string | null;
|
||||
filters?: {
|
||||
isActive?: boolean;
|
||||
search?: string;
|
||||
};
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useItemList(options: UseItemListOptions) {
|
||||
const { categoryId, filters, enabled = true } = options;
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useInfiniteQuery<
|
||||
ListResult,
|
||||
Error,
|
||||
InfiniteData<ListResult, ListCursor>,
|
||||
ListQueryKey,
|
||||
ListCursor
|
||||
>({
|
||||
queryKey: orpc.items.list.queryKey({
|
||||
input: {
|
||||
categoryId: categoryId ?? "",
|
||||
filters,
|
||||
},
|
||||
}),
|
||||
queryFn: async ({ pageParam }): Promise<ListResult> => {
|
||||
if (!categoryId) {
|
||||
throw new Error("Category ID is required");
|
||||
}
|
||||
return await orpcClient.items.list({
|
||||
categoryId,
|
||||
limit: 20,
|
||||
cursor: pageParam,
|
||||
filters,
|
||||
});
|
||||
},
|
||||
initialPageParam: undefined,
|
||||
getNextPageParam: (lastPage): ListCursor =>
|
||||
lastPage.nextCursor ?? undefined,
|
||||
enabled: enabled && !!categoryId,
|
||||
});
|
||||
|
||||
// Flatten all pages into a single array
|
||||
const items = data?.pages.flatMap((page) => page.items) ?? [];
|
||||
|
||||
return {
|
||||
items,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Batch Queries with useQueries
|
||||
|
||||
```typescript
|
||||
import { orpc } from "@/lib/orpc-query-utils";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface UseBatchItemsOptions {
|
||||
itemIds: string[];
|
||||
enabled?: boolean;
|
||||
staleTime?: number;
|
||||
}
|
||||
|
||||
export function useBatchItems(options: UseBatchItemsOptions) {
|
||||
const { itemIds, enabled = true, staleTime = 5 * 60 * 1000 } = options;
|
||||
|
||||
const queries = useQueries({
|
||||
queries: itemIds.map((itemId) => ({
|
||||
...orpc.items.get.queryOptions({
|
||||
input: { itemId },
|
||||
}),
|
||||
enabled: enabled && !!itemId,
|
||||
staleTime,
|
||||
})),
|
||||
});
|
||||
|
||||
// Build a map for easy lookup
|
||||
const itemsMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
queries.forEach((query, index) => {
|
||||
const itemId = itemIds[index];
|
||||
if (itemId && query.data) {
|
||||
map.set(itemId, {
|
||||
data: query.data,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
});
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [queries, itemIds]);
|
||||
|
||||
return {
|
||||
itemsMap,
|
||||
isLoading: queries.some((q) => q.isLoading),
|
||||
isAllLoaded: queries.every((q) => q.isSuccess || q.isError),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 Query Key Management
|
||||
|
||||
oRPC provides automatic query key generation:
|
||||
|
||||
```typescript
|
||||
// Get query key with input parameters
|
||||
const queryKey = orpc.items.list.queryKey({
|
||||
input: { categoryId: "123", filters: { isActive: true } },
|
||||
});
|
||||
|
||||
// Get base key (without input) for broader invalidation
|
||||
const baseKey = orpc.items.list.key();
|
||||
|
||||
// Usage in cache invalidation
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.items.list.key(), // Invalidates all items.list queries
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.items.list.queryKey({
|
||||
input: { categoryId: "123" },
|
||||
}), // Invalidates specific query
|
||||
});
|
||||
```
|
||||
|
||||
## 5. Mutation Patterns
|
||||
|
||||
### 5.1 Basic Mutation
|
||||
|
||||
```typescript
|
||||
import { orpc } from "@/lib/orpc-query-utils";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface UseConnectServiceOptions {
|
||||
onSuccess?: (data: ConnectOutput) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export function useConnectService(options: UseConnectServiceOptions = {}) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<ConnectOutput, Error, ConnectInput>({
|
||||
...orpc.services.connect.mutationOptions(),
|
||||
onSuccess: (data) => {
|
||||
// Invalidate related queries
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.services.default.key(),
|
||||
});
|
||||
options.onSuccess?.(data);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Connection failed", {
|
||||
description: error.message,
|
||||
});
|
||||
options.onError?.(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Mutation with Optimistic Updates
|
||||
|
||||
```typescript
|
||||
import { orpc } from "@/lib/orpc-query-utils";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function useUpdateItem() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
{ success: boolean },
|
||||
Error,
|
||||
{ itemId: string; isActive: boolean },
|
||||
{ previousQueries: [readonly unknown[], unknown][] }
|
||||
>({
|
||||
...orpc.items.update.mutationOptions(),
|
||||
onMutate: async ({ itemId, isActive }) => {
|
||||
// Cancel outgoing refetches to avoid overwriting optimistic update
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: orpc.items.list.key(),
|
||||
});
|
||||
|
||||
// Snapshot current data for rollback
|
||||
const previousQueries = queryClient.getQueriesData({
|
||||
queryKey: orpc.items.list.key(),
|
||||
});
|
||||
|
||||
// Optimistically update the cache
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: orpc.items.list.key() },
|
||||
(old: unknown) => {
|
||||
const data = old as {
|
||||
pages?: Array<{
|
||||
items: Array<{ id: string; isActive: boolean }>;
|
||||
}>;
|
||||
};
|
||||
if (!data?.pages) return old;
|
||||
|
||||
return {
|
||||
...data,
|
||||
pages: data.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((item) =>
|
||||
item.id === itemId ? { ...item, isActive } : item
|
||||
),
|
||||
})),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return { previousQueries };
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousQueries) {
|
||||
for (const [queryKey, data] of context.previousQueries) {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
}
|
||||
}
|
||||
toast.error("Failed to update item");
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Optionally invalidate related queries
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.items.counts.key(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Optimistic Delete (Remove from List)
|
||||
|
||||
```typescript
|
||||
export function useDeleteItem() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
{ success: boolean },
|
||||
Error,
|
||||
{ itemId: string },
|
||||
{ previousQueries: [readonly unknown[], unknown][] }
|
||||
>({
|
||||
...orpc.items.delete.mutationOptions(),
|
||||
onMutate: async ({ itemId }) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: orpc.items.list.key(),
|
||||
});
|
||||
|
||||
const previousQueries = queryClient.getQueriesData({
|
||||
queryKey: orpc.items.list.key(),
|
||||
});
|
||||
|
||||
// Optimistically remove from all lists
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: orpc.items.list.key() },
|
||||
(old: unknown) => {
|
||||
const data = old as {
|
||||
pages?: Array<{
|
||||
items: Array<{ id: string }>;
|
||||
nextCursor: unknown;
|
||||
hasMore: boolean;
|
||||
}>;
|
||||
};
|
||||
if (!data?.pages) return old;
|
||||
|
||||
return {
|
||||
...data,
|
||||
pages: data.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter((item) => item.id !== itemId),
|
||||
})),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return { previousQueries };
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
if (context?.previousQueries) {
|
||||
for (const [queryKey, data] of context.previousQueries) {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
}
|
||||
}
|
||||
toast.error("Failed to delete item");
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Item deleted");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.items.counts.key(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Direct Client Calls
|
||||
|
||||
### 6.1 When to Use Direct Client vs useMutation
|
||||
|
||||
**Use `useMutation` when:**
|
||||
- You need loading/error states in UI
|
||||
- You want automatic retry behavior
|
||||
- You need optimistic updates
|
||||
- You want built-in cache invalidation hooks
|
||||
|
||||
**Use direct `orpcClient` calls when:**
|
||||
- Inside `mutationFn` for custom logic (see 6.2)
|
||||
- In event handlers where you need sequential operations
|
||||
- When you need to transform input before calling API
|
||||
- In server components or API routes
|
||||
|
||||
### 6.2 Custom Mutation Function
|
||||
|
||||
When you need to add custom logic, transform inputs, or handle complex scenarios:
|
||||
|
||||
```typescript
|
||||
import { orpcClient } from "@/lib/orpc-client";
|
||||
import { orpc } from "@/lib/orpc-query-utils";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export function useCreateItem(options = {}) {
|
||||
const { user } = useSession();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
CreateItemOutput,
|
||||
Error,
|
||||
Omit<CreateItemInput, "userId"> // userId will be added automatically
|
||||
>({
|
||||
mutationKey: orpc.items.create.mutationKey(),
|
||||
mutationFn: async (input) => {
|
||||
// Add authentication
|
||||
if (!user?.id) {
|
||||
throw new Error("User not authenticated");
|
||||
}
|
||||
|
||||
// Transform input before calling API
|
||||
const fullInput: CreateItemInput = {
|
||||
...input,
|
||||
userId: user.id,
|
||||
};
|
||||
|
||||
// Direct client call with transformed input
|
||||
return orpcClient.items.create(fullInput);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data.success) {
|
||||
toast.success("Item created successfully");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.items.list.key(),
|
||||
});
|
||||
options.onSuccess?.(data);
|
||||
} else {
|
||||
// Handle API-level errors
|
||||
const errorMessage = data.error || "Failed to create item";
|
||||
toast.error("Failed to create item", {
|
||||
description: errorMessage,
|
||||
});
|
||||
options.onError?.(new Error(errorMessage));
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to create item", {
|
||||
description: error.message,
|
||||
});
|
||||
options.onError?.(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 Type Inference
|
||||
|
||||
Derive types directly from the client for maximum type safety:
|
||||
|
||||
```typescript
|
||||
import type { orpcClient } from "@/lib/orpc-client";
|
||||
|
||||
// Infer return type from client method
|
||||
type ItemResult = Awaited<ReturnType<(typeof orpcClient)["items"]["get"]>>;
|
||||
|
||||
// Infer input type from client method
|
||||
type CreateItemInput = Parameters<(typeof orpcClient)["items"]["create"]>[0];
|
||||
```
|
||||
|
||||
## 7. Best Practices
|
||||
|
||||
### 7.1 Query Key Consistency
|
||||
|
||||
Always use oRPC's generated query keys for consistency:
|
||||
|
||||
```typescript
|
||||
// GOOD: Use generated query keys
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.items.list.key(),
|
||||
});
|
||||
|
||||
// GOOD: Use specific query key with input
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.items.list.queryKey({ input: { categoryId: "123" } }),
|
||||
});
|
||||
|
||||
// BAD: Manually constructed keys
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["items", "list"], // Don't do this
|
||||
});
|
||||
```
|
||||
|
||||
### 7.2 Error Handling
|
||||
|
||||
Implement consistent error handling with toast notifications:
|
||||
|
||||
```typescript
|
||||
import { toast } from "sonner";
|
||||
|
||||
// In mutation hooks
|
||||
onError: (error) => {
|
||||
toast.error("Operation failed", {
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
|
||||
// Handle API-level errors in onSuccess
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
toast.error("Operation failed", {
|
||||
description: data.error || "Unknown error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Handle success...
|
||||
},
|
||||
```
|
||||
|
||||
### 7.3 Loading States
|
||||
|
||||
Use appropriate loading state properties:
|
||||
|
||||
```typescript
|
||||
const { isLoading, isFetching, isPending } = useQuery(...);
|
||||
const { isPending, isSuccess, isError } = useMutation(...);
|
||||
const { isFetchingNextPage, hasNextPage } = useInfiniteQuery(...);
|
||||
|
||||
// In components
|
||||
{isLoading && <Skeleton />}
|
||||
{isPending && <Button disabled>Saving...</Button>}
|
||||
{isFetchingNextPage && <LoadingSpinner />}
|
||||
```
|
||||
|
||||
### 7.4 Cache Configuration
|
||||
|
||||
Set appropriate cache times based on data characteristics:
|
||||
|
||||
```typescript
|
||||
// Frequently changing data
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
gcTime: 60 * 1000, // 1 minute
|
||||
|
||||
// Moderately stable data
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
|
||||
// Stable/static data
|
||||
staleTime: 30 * 60 * 1000, // 30 minutes
|
||||
gcTime: 60 * 60 * 1000, // 1 hour
|
||||
```
|
||||
|
||||
### 7.5 Input Validation in Hooks
|
||||
|
||||
Always validate inputs before making API calls:
|
||||
|
||||
```typescript
|
||||
export function useItem(itemId: string | null) {
|
||||
const hasItemId = typeof itemId === "string" && itemId.trim().length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasItemId) {
|
||||
console.warn("[useItem] Invalid itemId provided. Request skipped.");
|
||||
}
|
||||
}, [hasItemId]);
|
||||
|
||||
return useQuery({
|
||||
...orpc.items.get.queryOptions({
|
||||
input: { itemId: itemId ?? "" },
|
||||
}),
|
||||
enabled: hasItemId, // Prevent invalid requests
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 7.6 Partial Success Handling
|
||||
|
||||
Handle batch operations that may partially succeed:
|
||||
|
||||
```typescript
|
||||
onSuccess: (result) => {
|
||||
if (result.failed === 0) {
|
||||
toast.success(`${result.processed} items updated`);
|
||||
} else if (result.processed > 0) {
|
||||
toast.warning(
|
||||
`${result.processed} of ${result.total} items updated, ${result.failed} failed`
|
||||
);
|
||||
// Refresh to get correct state for failed items
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: orpc.items.list.key(),
|
||||
});
|
||||
} else {
|
||||
toast.error("Failed to update items");
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
## 8. Common Patterns Summary
|
||||
|
||||
| Pattern | Hook | Use Case |
|
||||
|---------|------|----------|
|
||||
| Single item fetch | `useQuery` | Detail pages, single record |
|
||||
| List with pagination | `useInfiniteQuery` | Lists, feeds, search results |
|
||||
| Multiple items | `useQueries` | Batch preloading, related items |
|
||||
| Create/Update/Delete | `useMutation` | Form submissions, actions |
|
||||
| Optimistic updates | `useMutation` + `onMutate` | Real-time UI updates |
|
||||
| Custom mutation logic | `useMutation` + `mutationFn` | Auth injection, input transformation |
|
||||
|
||||
## 9. Migration Notes
|
||||
|
||||
When migrating from other data fetching approaches:
|
||||
|
||||
1. Replace manual fetch calls with `orpcClient` methods
|
||||
2. Replace manual query keys with `orpc.xxx.queryKey()`
|
||||
3. Use `orpc.xxx.queryOptions()` and `mutationOptions()` for React Query integration
|
||||
4. Leverage TypeScript inference from the client types
|
||||
137
.trellis/spec/frontend/quality.md
Normal file
137
.trellis/spec/frontend/quality.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Pre-commit Checklist
|
||||
|
||||
Complete this checklist before committing frontend code changes.
|
||||
|
||||
## Type Safety
|
||||
|
||||
- [ ] No `@ts-expect-error` or `@ts-ignore` comments added
|
||||
- [ ] No `any` types in new code
|
||||
- [ ] API response types are inferred or imported from backend (not redefined)
|
||||
- [ ] Cache updates in React Query are properly typed
|
||||
- [ ] When overriding mutation callbacks, explicit generics are provided
|
||||
|
||||
## Component Development
|
||||
|
||||
- [ ] Server Components used by default; `'use client'` only when necessary
|
||||
- [ ] Semantic HTML elements used (button, not div for clicks)
|
||||
- [ ] `next/image` used instead of `<img>` tags
|
||||
- [ ] Proper ARIA labels and accessibility attributes added
|
||||
- [ ] Props have TypeScript interfaces defined
|
||||
|
||||
## API Integration
|
||||
|
||||
- [ ] API calls use oRPC client (not raw fetch for internal APIs)
|
||||
- [ ] React Query hooks follow established patterns
|
||||
- [ ] Loading and error states handled
|
||||
- [ ] Optimistic updates include rollback logic
|
||||
- [ ] Real-time subscriptions cleaned up on unmount
|
||||
|
||||
## State Management
|
||||
|
||||
- [ ] Shareable state stored in URL with nuqs
|
||||
- [ ] Context used sparingly (not for server data)
|
||||
- [ ] URL and Context synchronized where necessary
|
||||
- [ ] No duplicate state across different systems
|
||||
|
||||
## CSS & Layout
|
||||
|
||||
- [ ] `items-stretch` used on main flex containers (not `items-center`)
|
||||
- [ ] Parent provides external styles; child provides internal layout
|
||||
- [ ] Mobile touch: `WebkitTapHighlightColor: "transparent"` applied
|
||||
- [ ] Touch targets are minimum 44x44px
|
||||
- [ ] Responsive breakpoints tested
|
||||
|
||||
## Cross-Environment Testing
|
||||
|
||||
- [ ] Tested in development mode (`pnpm dev`)
|
||||
- [ ] Tested in production mode (`pnpm build && pnpm start`)
|
||||
- [ ] No visual differences between dev and prod
|
||||
- [ ] Animations respect `prefers-reduced-motion`
|
||||
|
||||
## Code Quality
|
||||
|
||||
- [ ] No console.log statements left in code
|
||||
- [ ] Unused imports removed
|
||||
- [ ] Components follow single responsibility principle
|
||||
- [ ] File and function names follow conventions
|
||||
- [ ] Barrel exports updated if new files added
|
||||
|
||||
## Documentation
|
||||
|
||||
- [ ] Complex logic has inline comments
|
||||
- [ ] New hooks have JSDoc comments
|
||||
- [ ] API changes reflected in backend documentation
|
||||
|
||||
---
|
||||
|
||||
## Quick Commands
|
||||
|
||||
```bash
|
||||
# Type check
|
||||
pnpm type-check
|
||||
|
||||
# Lint
|
||||
pnpm lint
|
||||
|
||||
# Format
|
||||
pnpm format
|
||||
|
||||
# Build (catches production-only issues)
|
||||
pnpm build
|
||||
|
||||
# Run all checks
|
||||
pnpm lint && pnpm type-check && pnpm build
|
||||
```
|
||||
|
||||
## Common Issues to Watch
|
||||
|
||||
### Type Safety
|
||||
```typescript
|
||||
// Bad
|
||||
queryClient.setQueryData(['users'], (old: any) => ...)
|
||||
|
||||
// Good
|
||||
queryClient.setQueryData<UserListData>(['users'], (old) => ...)
|
||||
```
|
||||
|
||||
### Components
|
||||
```typescript
|
||||
// Bad
|
||||
<div onClick={handleClick}>Click me</div>
|
||||
|
||||
// Good
|
||||
<button onClick={handleClick}>Click me</button>
|
||||
```
|
||||
|
||||
### Images
|
||||
```typescript
|
||||
// Bad
|
||||
<img src="/hero.jpg" alt="Hero" />
|
||||
|
||||
// Good
|
||||
import Image from 'next/image';
|
||||
<Image src="/hero.jpg" alt="Hero" width={1200} height={600} />
|
||||
```
|
||||
|
||||
### Layout
|
||||
```typescript
|
||||
// Bad - children won't fill height
|
||||
<div className="flex h-screen items-center">
|
||||
|
||||
// Good - children fill available height
|
||||
<div className="flex h-screen">
|
||||
```
|
||||
|
||||
### Mobile Touch
|
||||
```typescript
|
||||
// Bad - shows tap highlight on mobile
|
||||
<button onClick={handleClick}>Tap</button>
|
||||
|
||||
// Good - no tap highlight
|
||||
<button
|
||||
onClick={handleClick}
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
>
|
||||
Tap
|
||||
</button>
|
||||
```
|
||||
372
.trellis/spec/frontend/state-management.md
Normal file
372
.trellis/spec/frontend/state-management.md
Normal file
@@ -0,0 +1,372 @@
|
||||
# State Management
|
||||
|
||||
This document covers state management patterns including URL state with nuqs, React Context guidelines, and synchronization strategies.
|
||||
|
||||
## State Categories
|
||||
|
||||
| Category | Tool | When to Use |
|
||||
|----------|------|-------------|
|
||||
| Server State | React Query | API data, cached responses |
|
||||
| URL State | nuqs | Filters, pagination, selected items |
|
||||
| Local UI State | useState | Transient UI (modals, dropdowns) |
|
||||
| Shared UI State | Context | Cross-component UI state |
|
||||
|
||||
## URL State with nuqs
|
||||
|
||||
### Why URL State?
|
||||
|
||||
- Shareable: Users can share links with specific state
|
||||
- Bookmarkable: Browser history navigation works
|
||||
- SEO-friendly: Search engines can index different states
|
||||
- Persistent: Survives page refreshes
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { useQueryState } from 'nuqs';
|
||||
|
||||
export function useOrderFilters() {
|
||||
const [status, setStatus] = useQueryState('status');
|
||||
const [page, setPage] = useQueryState('page', {
|
||||
parse: (value) => parseInt(value, 10) || 1,
|
||||
serialize: String,
|
||||
});
|
||||
|
||||
return { status, setStatus, page, setPage };
|
||||
}
|
||||
```
|
||||
|
||||
### With Default Values
|
||||
|
||||
```typescript
|
||||
import { useQueryState, parseAsInteger, parseAsString } from 'nuqs';
|
||||
|
||||
export function useProductFilters() {
|
||||
const [category, setCategory] = useQueryState('category', {
|
||||
defaultValue: 'all',
|
||||
parse: parseAsString,
|
||||
});
|
||||
|
||||
const [page, setPage] = useQueryState('page', {
|
||||
defaultValue: 1,
|
||||
parse: parseAsInteger,
|
||||
});
|
||||
|
||||
const [sortBy, setSortBy] = useQueryState('sort', {
|
||||
defaultValue: 'newest',
|
||||
});
|
||||
|
||||
return {
|
||||
category,
|
||||
setCategory,
|
||||
page,
|
||||
setPage,
|
||||
sortBy,
|
||||
setSortBy,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Complex Filter Objects
|
||||
|
||||
```typescript
|
||||
import { useQueryStates, parseAsString, parseAsInteger } from 'nuqs';
|
||||
|
||||
const filterParsers = {
|
||||
search: parseAsString.withDefault(''),
|
||||
category: parseAsString.withDefault('all'),
|
||||
minPrice: parseAsInteger,
|
||||
maxPrice: parseAsInteger,
|
||||
page: parseAsInteger.withDefault(1),
|
||||
};
|
||||
|
||||
export function useAdvancedFilters() {
|
||||
const [filters, setFilters] = useQueryStates(filterParsers);
|
||||
|
||||
const updateFilter = <K extends keyof typeof filters>(
|
||||
key: K,
|
||||
value: (typeof filters)[K]
|
||||
) => {
|
||||
setFilters({ [key]: value, page: 1 }); // Reset page on filter change
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setFilters({
|
||||
search: '',
|
||||
category: 'all',
|
||||
minPrice: null,
|
||||
maxPrice: null,
|
||||
page: 1,
|
||||
});
|
||||
};
|
||||
|
||||
return { filters, updateFilter, resetFilters };
|
||||
}
|
||||
```
|
||||
|
||||
### Shallow Routing
|
||||
|
||||
Prevent full page reloads when updating URL state:
|
||||
|
||||
```typescript
|
||||
const [tab, setTab] = useQueryState('tab', {
|
||||
shallow: true, // Default is true in nuqs
|
||||
history: 'push', // or 'replace'
|
||||
});
|
||||
```
|
||||
|
||||
## React Context Guidelines
|
||||
|
||||
### When to Use Context
|
||||
|
||||
- Theme/appearance settings
|
||||
- User preferences
|
||||
- Feature flags
|
||||
- Cross-cutting concerns (toast notifications, modals)
|
||||
|
||||
### When NOT to Use Context
|
||||
|
||||
- Server data (use React Query instead)
|
||||
- Form state (use form libraries)
|
||||
- Single-component state (use useState)
|
||||
- State that should be in URL
|
||||
|
||||
### Context Pattern
|
||||
|
||||
```typescript
|
||||
// context/DashboardContext.tsx
|
||||
import { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
interface DashboardState {
|
||||
sidebarCollapsed: boolean;
|
||||
activeWidget: string | null;
|
||||
}
|
||||
|
||||
interface DashboardContextValue extends DashboardState {
|
||||
toggleSidebar: () => void;
|
||||
setActiveWidget: (widget: string | null) => void;
|
||||
}
|
||||
|
||||
const DashboardContext = createContext<DashboardContextValue | null>(null);
|
||||
|
||||
export function DashboardProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<DashboardState>({
|
||||
sidebarCollapsed: false,
|
||||
activeWidget: null,
|
||||
});
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
sidebarCollapsed: !prev.sidebarCollapsed,
|
||||
}));
|
||||
};
|
||||
|
||||
const setActiveWidget = (widget: string | null) => {
|
||||
setState((prev) => ({ ...prev, activeWidget: widget }));
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardContext.Provider
|
||||
value={{ ...state, toggleSidebar, setActiveWidget }}
|
||||
>
|
||||
{children}
|
||||
</DashboardContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDashboard() {
|
||||
const context = useContext(DashboardContext);
|
||||
if (!context) {
|
||||
throw new Error('useDashboard must be used within DashboardProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
```
|
||||
|
||||
### Split Context for Performance
|
||||
|
||||
Separate frequently-changing values to prevent unnecessary re-renders:
|
||||
|
||||
```typescript
|
||||
// Separate contexts for state and actions
|
||||
const DashboardStateContext = createContext<DashboardState | null>(null);
|
||||
const DashboardActionsContext = createContext<DashboardActions | null>(null);
|
||||
|
||||
export function DashboardProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<DashboardState>(initialState);
|
||||
|
||||
// Memoize actions to prevent re-renders
|
||||
const actions = useMemo(
|
||||
() => ({
|
||||
toggleSidebar: () =>
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
sidebarCollapsed: !prev.sidebarCollapsed,
|
||||
})),
|
||||
setActiveWidget: (widget: string | null) =>
|
||||
setState((prev) => ({ ...prev, activeWidget: widget })),
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardStateContext.Provider value={state}>
|
||||
<DashboardActionsContext.Provider value={actions}>
|
||||
{children}
|
||||
</DashboardActionsContext.Provider>
|
||||
</DashboardStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// Separate hooks for state and actions
|
||||
export function useDashboardState() {
|
||||
const context = useContext(DashboardStateContext);
|
||||
if (!context) throw new Error('Missing DashboardProvider');
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useDashboardActions() {
|
||||
const context = useContext(DashboardActionsContext);
|
||||
if (!context) throw new Error('Missing DashboardProvider');
|
||||
return context;
|
||||
}
|
||||
```
|
||||
|
||||
## Context and URL Synchronization
|
||||
|
||||
When state needs to be both in context (for easy access) and URL (for shareability):
|
||||
|
||||
### Pattern: URL as Source of Truth
|
||||
|
||||
```typescript
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { createContext, useContext, ReactNode } from 'react';
|
||||
|
||||
interface FilterContextValue {
|
||||
selectedId: string | null;
|
||||
setSelectedId: (id: string | null) => void;
|
||||
view: 'grid' | 'list';
|
||||
setView: (view: 'grid' | 'list') => void;
|
||||
}
|
||||
|
||||
const FilterContext = createContext<FilterContextValue | null>(null);
|
||||
|
||||
export function FilterProvider({ children }: { children: ReactNode }) {
|
||||
// URL state as the single source of truth
|
||||
const [selectedId, setSelectedId] = useQueryState('selected');
|
||||
const [view, setView] = useQueryState('view', {
|
||||
defaultValue: 'grid' as const,
|
||||
parse: (v) => (v === 'list' ? 'list' : 'grid'),
|
||||
});
|
||||
|
||||
return (
|
||||
<FilterContext.Provider
|
||||
value={{
|
||||
selectedId,
|
||||
setSelectedId,
|
||||
view,
|
||||
setView,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</FilterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useFilters() {
|
||||
const context = useContext(FilterContext);
|
||||
if (!context) throw new Error('Missing FilterProvider');
|
||||
return context;
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Sync Context to URL
|
||||
|
||||
When context state needs to be reflected in URL for specific scenarios:
|
||||
|
||||
```typescript
|
||||
export function useSyncToUrl() {
|
||||
const { selectedId } = useItemSelection(); // From context
|
||||
const [, setUrlSelectedId] = useQueryState('selected');
|
||||
|
||||
// Sync context changes to URL
|
||||
useEffect(() => {
|
||||
setUrlSelectedId(selectedId);
|
||||
}, [selectedId, setUrlSelectedId]);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Initialize Context from URL
|
||||
|
||||
```typescript
|
||||
export function SelectionProvider({ children }: { children: ReactNode }) {
|
||||
// Read initial value from URL
|
||||
const [urlSelectedId] = useQueryState('selected');
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(
|
||||
urlSelectedId // Initialize from URL
|
||||
);
|
||||
|
||||
// Keep context in sync with URL changes
|
||||
useEffect(() => {
|
||||
setSelectedId(urlSelectedId);
|
||||
}, [urlSelectedId]);
|
||||
|
||||
return (
|
||||
<SelectionContext.Provider value={{ selectedId, setSelectedId }}>
|
||||
{children}
|
||||
</SelectionContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## State Debugging
|
||||
|
||||
### React Query DevTools
|
||||
|
||||
```typescript
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<AppContent />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Context Debug Component
|
||||
|
||||
```typescript
|
||||
function DebugContext() {
|
||||
const state = useDashboardState();
|
||||
|
||||
if (process.env.NODE_ENV !== 'development') return null;
|
||||
|
||||
return (
|
||||
<pre className="fixed bottom-4 right-4 p-2 bg-black/80 text-white text-xs">
|
||||
{JSON.stringify(state, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **URL First**: Default to URL state for shareable data
|
||||
2. **Minimal Context**: Keep context small and focused
|
||||
3. **Separate Concerns**: Don't mix server state with UI state
|
||||
4. **Type Everything**: Use TypeScript for all state types
|
||||
5. **Default Values**: Always provide sensible defaults
|
||||
6. **Single Source**: Avoid duplicating state across systems
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Storing server data in context (use React Query)
|
||||
- Using context for form state (use form libraries)
|
||||
- Deep nesting of providers
|
||||
- Not memoizing context actions
|
||||
- Duplicating URL state in useState
|
||||
278
.trellis/spec/frontend/type-safety.md
Normal file
278
.trellis/spec/frontend/type-safety.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# Type Safety Guidelines
|
||||
|
||||
This document covers TypeScript best practices for maintaining type safety across the frontend application.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Import types from backend, never redefine them**
|
||||
2. **Use type inference wherever possible**
|
||||
3. **Avoid type assertions and escape hatches**
|
||||
4. **Leverage oRPC for end-to-end type safety**
|
||||
|
||||
## Importing Backend Types
|
||||
|
||||
### DO: Import from API Package
|
||||
|
||||
```typescript
|
||||
// Good: Import types from the API package
|
||||
import type { User, Order, Product } from '@your-app/api/modules/users/types'; // Replace with your monorepo package path
|
||||
import type { OrderStatus } from '@your-app/api/modules/orders/types'; // Replace with your monorepo package path
|
||||
```
|
||||
|
||||
### DON'T: Redefine Backend Types
|
||||
|
||||
```typescript
|
||||
// Bad: Redefining types that exist in backend
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// Bad: Creating parallel type definitions
|
||||
type OrderStatus = 'pending' | 'processing' | 'completed';
|
||||
```
|
||||
|
||||
## Type Inference from API Client
|
||||
|
||||
### Using `Awaited<ReturnType>` Pattern
|
||||
|
||||
Infer types directly from API client calls to ensure frontend types stay in sync with backend:
|
||||
|
||||
```typescript
|
||||
import { orpcClient } from '@/lib/orpc';
|
||||
|
||||
// Infer the response type from the API client
|
||||
type UsersResponse = Awaited<ReturnType<typeof orpcClient.users.list>>;
|
||||
|
||||
// Infer a single item type from array response
|
||||
type User = UsersResponse['items'][number];
|
||||
|
||||
// Infer input types
|
||||
type CreateUserInput = Parameters<typeof orpcClient.users.create>[0];
|
||||
```
|
||||
|
||||
### Type Inference in Hooks
|
||||
|
||||
```typescript
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { orpcClient } from '@/lib/orpc';
|
||||
|
||||
// The return type is automatically inferred
|
||||
export function useUsers() {
|
||||
return useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => orpcClient.users.list(),
|
||||
});
|
||||
}
|
||||
|
||||
// For complex transformations, use explicit inference
|
||||
type UserListData = Awaited<ReturnType<typeof orpcClient.users.list>>;
|
||||
|
||||
export function useFormattedUsers() {
|
||||
return useQuery({
|
||||
queryKey: ['users', 'formatted'],
|
||||
queryFn: async () => {
|
||||
const data = await orpcClient.users.list();
|
||||
return transformUsers(data);
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Forbidden Patterns
|
||||
|
||||
### NO @ts-expect-error for Custom Fields
|
||||
|
||||
Never use type suppression to access fields that don't exist in the type:
|
||||
|
||||
```typescript
|
||||
// Bad: Suppressing type errors
|
||||
// @ts-expect-error - customField exists at runtime
|
||||
const value = user.customField;
|
||||
|
||||
// Bad: Using any to bypass type checking
|
||||
const value = (user as any).customField;
|
||||
```
|
||||
|
||||
**Solution**: If a field exists at runtime but not in types, update the backend type definition.
|
||||
|
||||
### NO `any` Type in Cache Updates
|
||||
|
||||
React Query cache updates must maintain type safety:
|
||||
|
||||
```typescript
|
||||
// Bad: Using any in cache updates
|
||||
queryClient.setQueryData(['users'], (old: any) => {
|
||||
return old.map((user: any) => /* ... */);
|
||||
});
|
||||
|
||||
// Good: Properly typed cache updates
|
||||
queryClient.setQueryData<UserListData>(['users'], (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
items: old.items.map((user) => /* ... */),
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
### NO Type Assertions Without Validation
|
||||
|
||||
```typescript
|
||||
// Bad: Blind type assertion
|
||||
const user = data as User;
|
||||
|
||||
// Good: Runtime validation with Zod
|
||||
import { userSchema } from '@your-app/api/modules/users/types'; // Replace with your monorepo package path
|
||||
const user = userSchema.parse(data);
|
||||
|
||||
// Good: Type guard
|
||||
function isUser(data: unknown): data is User {
|
||||
return (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'id' in data &&
|
||||
'email' in data
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## View Model Types
|
||||
|
||||
When the frontend needs additional computed properties, create view models that extend backend types:
|
||||
|
||||
```typescript
|
||||
// types/index.ts
|
||||
import type { Order } from '@your-app/api/modules/orders/types'; // Replace with your monorepo package path
|
||||
|
||||
// Extend backend type with frontend-specific computed properties
|
||||
export interface OrderViewModel extends Order {
|
||||
formattedTotal: string;
|
||||
statusLabel: string;
|
||||
isEditable: boolean;
|
||||
}
|
||||
|
||||
// Transform function
|
||||
export function toOrderViewModel(order: Order): OrderViewModel {
|
||||
return {
|
||||
...order,
|
||||
formattedTotal: formatCurrency(order.total),
|
||||
statusLabel: getStatusLabel(order.status),
|
||||
isEditable: order.status === 'draft',
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Generic Type Patterns
|
||||
|
||||
### API Response Wrapper
|
||||
|
||||
```typescript
|
||||
// Generic paginated response type
|
||||
type PaginatedResponse<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
// Usage with inference
|
||||
type UserListResponse = PaginatedResponse<User>;
|
||||
```
|
||||
|
||||
### Hook Return Types
|
||||
|
||||
```typescript
|
||||
// Explicit return type for complex hooks
|
||||
interface UseOrderActionsReturn {
|
||||
updateOrder: (id: string, data: UpdateOrderInput) => Promise<void>;
|
||||
deleteOrder: (id: string) => Promise<void>;
|
||||
isUpdating: boolean;
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
export function useOrderActions(): UseOrderActionsReturn {
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
## Working with External Data
|
||||
|
||||
### API Responses
|
||||
|
||||
```typescript
|
||||
// Always validate external data
|
||||
import { z } from 'zod';
|
||||
|
||||
const externalDataSchema = z.object({
|
||||
id: z.string(),
|
||||
value: z.number(),
|
||||
});
|
||||
|
||||
async function fetchExternalData() {
|
||||
const response = await fetch('/api/external');
|
||||
const data = await response.json();
|
||||
return externalDataSchema.parse(data);
|
||||
}
|
||||
```
|
||||
|
||||
### Local Storage
|
||||
|
||||
```typescript
|
||||
// Type-safe local storage wrapper
|
||||
function getStoredValue<T>(key: string, schema: z.ZodType<T>): T | null {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (!stored) return null;
|
||||
|
||||
try {
|
||||
return schema.parse(JSON.parse(stored));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## TypeScript Configuration
|
||||
|
||||
Ensure strict mode is enabled in `tsconfig.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUncheckedIndexedAccess": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Type Utilities
|
||||
|
||||
```typescript
|
||||
// Extract array element type
|
||||
type ArrayElement<T> = T extends (infer E)[] ? E : never;
|
||||
|
||||
// Make specific properties optional
|
||||
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||
|
||||
// Make specific properties required
|
||||
type RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;
|
||||
|
||||
// Non-nullable
|
||||
type NonNullableFields<T> = {
|
||||
[K in keyof T]: NonNullable<T[K]>;
|
||||
};
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
Before committing, verify:
|
||||
|
||||
- [ ] No `@ts-expect-error` or `@ts-ignore` comments added
|
||||
- [ ] No `any` types in new code
|
||||
- [ ] All API response types are inferred or imported from backend
|
||||
- [ ] Cache updates are properly typed
|
||||
- [ ] External data is validated with Zod schemas
|
||||
417
.trellis/spec/guides/cross-layer-thinking-guide.md
Normal file
417
.trellis/spec/guides/cross-layer-thinking-guide.md
Normal file
@@ -0,0 +1,417 @@
|
||||
# Cross-Layer Thinking Guide
|
||||
|
||||
> **Purpose**: Pre-implementation checklist for features that span multiple layers.
|
||||
>
|
||||
> **Core Principle**: 30 minutes of thinking saves 3 hours of debugging.
|
||||
|
||||
---
|
||||
|
||||
## When to Use This Guide
|
||||
|
||||
Use this guide when your feature:
|
||||
|
||||
- Touches 3+ layers (Server Component, Client Component, oRPC, Database)
|
||||
- Involves data transformation between layers
|
||||
- Has real-time or event-driven components
|
||||
- Receives data from external sources (APIs, webhooks, file uploads)
|
||||
|
||||
---
|
||||
|
||||
## Pre-Implementation Checklist
|
||||
|
||||
Before writing code, answer these questions:
|
||||
|
||||
### 1. Layer Identification
|
||||
|
||||
**Which layers does this feature touch?**
|
||||
|
||||
- [ ] Server Components (RSC - data fetching, static rendering)
|
||||
- [ ] Client Components (interactivity, browser APIs, React hooks)
|
||||
- [ ] API Routes / oRPC Procedures (validation, business logic)
|
||||
- [ ] Middleware (auth checks, redirects, header manipulation)
|
||||
- [ ] Database (Drizzle ORM queries, migrations)
|
||||
- [ ] Server Actions (form handling, progressive enhancement)
|
||||
- [ ] External Services (third-party APIs, webhooks)
|
||||
|
||||
### 2. Data Flow Direction
|
||||
|
||||
**How does data flow?**
|
||||
|
||||
```
|
||||
Read Flow: DB -> Drizzle -> oRPC Handler -> API Response -> React Query -> Component -> UI
|
||||
Write Flow: UI -> Form/Action -> oRPC Mutation -> Handler -> Drizzle -> DB
|
||||
SSR Flow: DB -> Drizzle -> oRPC Handler -> Server Component -> HTML -> Client Hydration
|
||||
```
|
||||
|
||||
- [ ] Read-only (data flows from DB to UI)
|
||||
- [ ] Write-only (data flows from UI to DB)
|
||||
- [ ] Bidirectional (both directions)
|
||||
- [ ] Server-rendered (data fetched in Server Components)
|
||||
- [ ] Client-fetched (data fetched via React Query in Client Components)
|
||||
|
||||
### 3. Data Format at Each Layer
|
||||
|
||||
**What format is the data at each boundary?**
|
||||
|
||||
| Layer | Format | Example |
|
||||
| ---------------- | ----------------------- | ----------------------------------------------- |
|
||||
| Database | SQL types | `TEXT`, `INTEGER`, `TIMESTAMP`, `JSONB` |
|
||||
| Drizzle ORM | TypeScript types | `string`, `number`, `Date`, `Record<>` |
|
||||
| oRPC Handler | Zod-validated objects | `{ id: string, createdAt: Date }` |
|
||||
| oRPC Response | Serialized JSON | `{ id: "abc", createdAt: "2024-01-01T..." }` |
|
||||
| React Query | Cached response | Same as oRPC response (deserialized) |
|
||||
| Server Component | Props (must serialize) | No functions, no Date objects, no class instances |
|
||||
| Client Component | React state | Component props, hook return values |
|
||||
| UI | Rendered output | HTML, Tailwind-styled elements |
|
||||
|
||||
### 3.1 Serialization Boundary (CRITICAL!)
|
||||
|
||||
**Design Principle**: Data crossing the Server/Client Component boundary must be serializable.
|
||||
|
||||
| Serializable (OK) | NOT Serializable (WILL BREAK) |
|
||||
| ----------------------- | --------------------------------- |
|
||||
| `string`, `number` | `Date` objects |
|
||||
| `boolean`, `null` | `Map`, `Set` |
|
||||
| Plain objects, arrays | Functions, class instances |
|
||||
| `undefined` (as absent) | `BigInt`, `Symbol` |
|
||||
|
||||
**Common serialization trap**:
|
||||
|
||||
```typescript
|
||||
// BAD - Date objects don't serialize across RSC boundary
|
||||
async function ItemPage() {
|
||||
const item = await orpcClient.items.get({ itemId: "123" });
|
||||
// item.createdAt might be a Date object from Drizzle
|
||||
return <ClientItem item={item} />; // Date becomes string or breaks!
|
||||
}
|
||||
|
||||
// GOOD - Convert to serializable format before passing to Client Component
|
||||
async function ItemPage() {
|
||||
const item = await orpcClient.items.get({ itemId: "123" });
|
||||
return <ClientItem item={{
|
||||
...item,
|
||||
createdAt: item.createdAt.toISOString(), // Explicit string conversion
|
||||
}} />;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Data Transformation Points
|
||||
|
||||
**Where does format change? Who is responsible?**
|
||||
|
||||
| From | To | Transformer | Location |
|
||||
| ----------------- | ------------------- | ------------------- | -------------------------- |
|
||||
| DB timestamp | JS Date | Drizzle ORM | Automatic |
|
||||
| JS Date | ISO string | oRPC serialization | API response |
|
||||
| ISO string | Display string | React component | UI layer |
|
||||
| User input | Validated data | Zod schema | oRPC input validation |
|
||||
| JSONB column | TypeScript object | Drizzle + cast | Query layer |
|
||||
|
||||
### 5. Boundary Questions (Critical!)
|
||||
|
||||
For each layer boundary, ask:
|
||||
|
||||
**RSC / Client Component Boundary:**
|
||||
|
||||
- What data is the Server Component passing as props?
|
||||
- Is all of it serializable? (no functions, no Date objects, no Maps)
|
||||
- Could this data be fetched directly in the Client Component via React Query instead?
|
||||
- Does the Client Component need to refetch or mutate this data?
|
||||
|
||||
**Client Component / oRPC Boundary:**
|
||||
|
||||
- What format does the oRPC response return?
|
||||
- How does React Query cache and deserialize it?
|
||||
- What happens if the response format changes?
|
||||
- Are query keys consistent for cache invalidation?
|
||||
|
||||
**oRPC Handler / Database Boundary:**
|
||||
|
||||
- Are timestamps handled consistently? (ISO strings vs Date objects)
|
||||
- Are IDs strings or numbers?
|
||||
- What about null vs undefined?
|
||||
- Does Drizzle transform types automatically?
|
||||
- Are JSONB columns properly cast?
|
||||
|
||||
**Middleware / Route Boundary:**
|
||||
|
||||
- Is auth checked in middleware, oRPC procedure, or both?
|
||||
- What happens if middleware redirects but the API call continues?
|
||||
- Are headers properly forwarded in SSR context?
|
||||
|
||||
### 6. Authentication Context
|
||||
|
||||
**Where is auth available?**
|
||||
|
||||
| Layer | Auth Method | Notes |
|
||||
| ---------------- | -------------------------------------- | --------------------------------------- |
|
||||
| Middleware | `getSession()` from headers/cookies | Runs before route handler |
|
||||
| Server Component | `getSession()` or `auth()` helper | Can redirect on the server |
|
||||
| Client Component | `useSession()` hook | May need loading state |
|
||||
| oRPC Procedure | `protectedProcedure` middleware | Throws UNAUTHORIZED if no session |
|
||||
| API Route | `getSession()` from request headers | Manual check needed |
|
||||
|
||||
**Common auth pitfall**:
|
||||
|
||||
```typescript
|
||||
// BAD - Auth checked in middleware but not in oRPC procedure
|
||||
// If someone calls the API directly, auth is bypassed!
|
||||
export const middleware = NextResponse.next(); // auth check here
|
||||
export const getSecret = publicProcedure.handler(...); // no auth check!
|
||||
|
||||
// GOOD - Auth in oRPC procedure (always enforced)
|
||||
export const getSecret = protectedProcedure.handler(...);
|
||||
```
|
||||
|
||||
### 7. Edge Cases
|
||||
|
||||
- [ ] What if the data is empty/null?
|
||||
- [ ] What if the database query fails?
|
||||
- [ ] What if the oRPC call times out?
|
||||
- [ ] What if a referenced entity doesn't exist?
|
||||
- [ ] What if the user navigates away mid-mutation?
|
||||
- [ ] What if React Query returns stale data?
|
||||
- [ ] What if the user's session expires mid-operation?
|
||||
- [ ] What if the same mutation fires twice (double-click)?
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern A: Server Component Data Fetch
|
||||
|
||||
**Layers**: Server Component -> oRPC Client -> Handler -> Database
|
||||
|
||||
**Data Flow**:
|
||||
|
||||
```
|
||||
1. Server Component: Calls oRPC client directly (server-side)
|
||||
2. oRPC Handler: Validates auth, queries database
|
||||
3. Drizzle: Returns typed results
|
||||
4. Server Component: Renders HTML with data
|
||||
5. Client: Receives pre-rendered HTML
|
||||
```
|
||||
|
||||
**Common Issues**:
|
||||
|
||||
- **Serialization**: Server Components can render Date objects directly, but cannot pass them as props to Client Components
|
||||
- **No cache**: Server-side oRPC calls bypass React Query cache; consider prefetching
|
||||
- **Waterfall**: Sequential server-side calls create request waterfalls; use `Promise.all` for parallel fetching
|
||||
|
||||
### Pattern B: Client Component with React Query
|
||||
|
||||
**Layers**: Client Component -> React Query -> oRPC Client -> Handler -> Database
|
||||
|
||||
**Data Flow**:
|
||||
|
||||
```
|
||||
1. Client Component: Mounts, triggers useQuery
|
||||
2. React Query: Checks cache, calls oRPC client if stale
|
||||
3. oRPC Client: Sends HTTP request to API route
|
||||
4. oRPC Handler: Validates input/auth, queries DB
|
||||
5. Response: JSON back through React Query
|
||||
6. Client Component: Re-renders with data
|
||||
```
|
||||
|
||||
**Common Issues**:
|
||||
|
||||
- **Loading states**: Must handle `isLoading`, `isError`, `isPending` properly
|
||||
- **Stale data**: Configure `staleTime` and `gcTime` appropriately
|
||||
- **Cache invalidation**: Use `orpc.xxx.key()` for consistent invalidation after mutations
|
||||
- **Enabled flag**: Disable queries when required parameters are missing
|
||||
|
||||
### Pattern C: Mutation with Optimistic Update
|
||||
|
||||
**Layers**: Client Component -> useMutation -> oRPC Client -> Handler -> Database
|
||||
|
||||
**Data Flow**:
|
||||
|
||||
```
|
||||
1. User: Triggers action (click, form submit)
|
||||
2. onMutate: Optimistically update React Query cache
|
||||
3. oRPC Client: Sends mutation request
|
||||
4. Handler: Validates, writes to DB
|
||||
5. onSuccess: Invalidate related queries
|
||||
6. onError: Rollback optimistic update from snapshot
|
||||
```
|
||||
|
||||
**Common Issues**:
|
||||
|
||||
- **Rollback complexity**: Must snapshot all affected queries before optimistic update
|
||||
- **Type safety**: Cache manipulation needs explicit type annotations
|
||||
- **Race conditions**: Cancel outgoing refetches before optimistic update (`cancelQueries`)
|
||||
- **Partial failures**: Batch operations may partially succeed
|
||||
|
||||
### Pattern D: Server Action (Form Handling)
|
||||
|
||||
**Layers**: Form -> Server Action -> oRPC Client / DB -> Revalidate
|
||||
|
||||
**Data Flow**:
|
||||
|
||||
```
|
||||
1. User: Submits form
|
||||
2. Server Action: Receives FormData, validates
|
||||
3. Action: Calls oRPC client or DB directly
|
||||
4. Action: Calls revalidatePath/revalidateTag
|
||||
5. Page: Re-renders with updated data
|
||||
```
|
||||
|
||||
**Common Issues**:
|
||||
|
||||
- **Progressive enhancement**: Forms work without JS when using Server Actions
|
||||
- **Validation**: Validate on both client (UX) and server (security)
|
||||
- **Redirect vs revalidate**: Choose the right post-action behavior
|
||||
- **Error handling**: Server Action errors need proper error boundaries
|
||||
|
||||
### Pattern E: Middleware + API Route Auth
|
||||
|
||||
**Layers**: Request -> Middleware -> API Route / oRPC -> Handler
|
||||
|
||||
**Data Flow**:
|
||||
|
||||
```
|
||||
1. Request: Arrives at Next.js server
|
||||
2. Middleware: Checks auth, may redirect to login
|
||||
3. API Route: Handles oRPC request
|
||||
4. oRPC Middleware: Validates session (protectedProcedure)
|
||||
5. Handler: Executes business logic
|
||||
```
|
||||
|
||||
**Common Issues**:
|
||||
|
||||
- **Double auth check**: Middleware protects pages, oRPC protects API; both are needed
|
||||
- **Header forwarding**: SSR requests must forward cookies/headers to oRPC client
|
||||
- **Middleware scope**: Don't run auth middleware on public assets or API routes that handle their own auth
|
||||
|
||||
---
|
||||
|
||||
## Lessons from Common Bugs
|
||||
|
||||
| Bug | Root Cause | Prevention |
|
||||
| ------------------------------- | --------------------------------------------------------- | --------------------------------------------------- |
|
||||
| `Date` props break hydration | Date objects passed from Server to Client Component | Convert to ISO string before passing as props |
|
||||
| Stale data after mutation | Forgot to invalidate React Query cache | Always invalidate with `orpc.xxx.key()` in onSuccess |
|
||||
| Auth bypass on API | Auth only in middleware, not in oRPC procedure | Always use `protectedProcedure` for protected data |
|
||||
| `BigInt` serialization error | Database returns BigInt, JSON.stringify fails | Cast to number or string before response |
|
||||
| Query fires with null ID | `enabled` flag not set on conditional queries | Always guard with `enabled: !!requiredParam` |
|
||||
| Cache key mismatch | Manual query key doesn't match oRPC generated key | Always use `orpc.xxx.key()` or `orpc.xxx.queryKey()` |
|
||||
| N+1 queries in handler | Fetching related data in a loop | Use `inArray()` for batch queries |
|
||||
| Hydration mismatch | Server and client render different output (e.g., locale) | Ensure consistent data between server and client |
|
||||
| Headers not forwarded in SSR | oRPC client doesn't forward cookies in server context | Configure client to forward headers in SSR mode |
|
||||
|
||||
---
|
||||
|
||||
## Checklist Template
|
||||
|
||||
Copy this for your feature:
|
||||
|
||||
```markdown
|
||||
## Feature: [Name]
|
||||
|
||||
### Layers Involved
|
||||
|
||||
- [ ] Server Component
|
||||
- [ ] Client Component
|
||||
- [ ] oRPC Procedure
|
||||
- [ ] Middleware
|
||||
- [ ] Database
|
||||
- [ ] Server Action
|
||||
- [ ] External Service
|
||||
|
||||
### Data Flow
|
||||
|
||||
[Describe the flow]
|
||||
|
||||
### Format at Each Layer
|
||||
|
||||
| Layer | Format |
|
||||
| ----- | ------ |
|
||||
| ... | ... |
|
||||
|
||||
### Transformation Points
|
||||
|
||||
| From | To | Who |
|
||||
| ---- | --- | --- |
|
||||
| ... | ... | ... |
|
||||
|
||||
### Auth Strategy
|
||||
|
||||
- Middleware: [yes/no, what it checks]
|
||||
- oRPC: [publicProcedure/protectedProcedure/adminProcedure]
|
||||
|
||||
### Edge Cases Considered
|
||||
|
||||
- [ ] Empty/null data
|
||||
- [ ] Invalid format / serialization
|
||||
- [ ] Operation failure / timeout
|
||||
- [ ] User cancellation / navigation
|
||||
- [ ] Session expiry mid-operation
|
||||
- [ ] Double submission
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cross-Layer Review Mindset
|
||||
|
||||
### The Comparison Trap
|
||||
|
||||
**Wrong thinking**: "This line wasn't changed, so it must be correct."
|
||||
|
||||
```
|
||||
Comparison thinking (surface level):
|
||||
Before: new Date() -> After: new Date() -> "No change, must be fine"
|
||||
|
||||
Global thinking (design level):
|
||||
Design intent: ISO strings across RSC boundary -> Current: Date object -> "This is a bug"
|
||||
```
|
||||
|
||||
**Key insight**: Review validates "system state is correct", not just "change is correct".
|
||||
|
||||
### Data Outlet Checklist
|
||||
|
||||
Every review must cover ALL data outlets:
|
||||
|
||||
```
|
||||
Data Outlets:
|
||||
|-- oRPC Response (handler -> client)
|
||||
|-- Server Component Props (RSC -> Client Component)
|
||||
|-- React Query Cache (shared across components)
|
||||
|-- URL State (nuqs, searchParams)
|
||||
|-- Server Action Return (action -> form)
|
||||
|-- Any external interface
|
||||
```
|
||||
|
||||
Ask: **"Is the format correct at EACH outlet?"**
|
||||
|
||||
### Review Three Questions
|
||||
|
||||
Before finishing any cross-layer review:
|
||||
|
||||
1. **Outlet Question**: Have I checked ALL data outlets, not just the "core" one?
|
||||
2. **Design Question**: Does existing code match design principles? (Not "is the change correct?")
|
||||
3. **Checklist Question**: Could my checklist itself be wrong?
|
||||
|
||||
### Validation vs Verification
|
||||
|
||||
| Approach | Focus | Risk |
|
||||
| --------------- | ---------------------------- | ------------------------------------ |
|
||||
| **Incremental** | "Is this change correct?" | Misses pre-existing bugs |
|
||||
| **Global** | "Is the system correct now?" | More thorough, catches legacy issues |
|
||||
|
||||
Always prefer global verification for cross-layer features.
|
||||
|
||||
---
|
||||
|
||||
## When Things Go Wrong
|
||||
|
||||
If you encounter a cross-layer bug:
|
||||
|
||||
1. **Identify the boundary** - Where exactly does it fail?
|
||||
2. **Log at boundaries** - Add logging before and after each transformation
|
||||
3. **Check assumptions** - What format did you expect vs what you got?
|
||||
4. **Test in isolation** - Can you reproduce with a simple test case?
|
||||
5. **Document the fix** - Add to "Lessons from Common Bugs" table
|
||||
|
||||
---
|
||||
|
||||
**Language**: All documentation should be written in **English**.
|
||||
122
.trellis/spec/guides/index.md
Normal file
122
.trellis/spec/guides/index.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Thinking Guides for Next.js Full-Stack Projects
|
||||
|
||||
> **Purpose**: Systematic thinking guides to catch issues before they become bugs.
|
||||
>
|
||||
> **Core Philosophy**: 30 minutes of thinking saves 3 hours of debugging.
|
||||
|
||||
---
|
||||
|
||||
## Why Thinking Guides?
|
||||
|
||||
**Most bugs and tech debt come from "didn't think of that"**, not from lack of skill:
|
||||
|
||||
- Didn't think about what happens at layer boundaries -> cross-layer bugs
|
||||
- Didn't think about code patterns repeating -> duplicated code everywhere
|
||||
- Didn't think about edge cases -> runtime errors
|
||||
- Didn't think about future maintainers -> unreadable code
|
||||
|
||||
These guides help you **ask the right questions before coding**.
|
||||
|
||||
---
|
||||
|
||||
## Available Thinking Guides
|
||||
|
||||
| Guide | Purpose | When to Use |
|
||||
| ----------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------ |
|
||||
| [Cross-Layer Thinking](./cross-layer-thinking-guide.md) | Think through data flow across layers | Before implementing features that span 3+ layers |
|
||||
| [Pre-Implementation Checklist](./pre-implementation-checklist.md) | Verify readiness before coding | Before starting any feature implementation |
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: When to Use Which Guide
|
||||
|
||||
### Cross-Layer Issues
|
||||
|
||||
Use [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) when:
|
||||
|
||||
- [ ] Feature touches 3+ layers (Server Component, Client Component, oRPC, Database)
|
||||
- [ ] Data format changes between layers
|
||||
- [ ] Multiple consumers need the same data
|
||||
- [ ] You're not sure where to put some logic
|
||||
- [ ] Integrates with external services or third-party APIs
|
||||
|
||||
### Before Writing Code
|
||||
|
||||
Use [Pre-Implementation Checklist](./pre-implementation-checklist.md) when:
|
||||
|
||||
- [ ] About to add a constant or config value
|
||||
- [ ] About to implement new logic
|
||||
- [ ] About to define a type or Zod schema
|
||||
- [ ] About to create a component or hook
|
||||
- [ ] About to add an oRPC procedure
|
||||
- [ ] Feels like you've seen similar code before
|
||||
|
||||
---
|
||||
|
||||
## The Pre-Modification Rule (CRITICAL)
|
||||
|
||||
> **Before changing ANY value, ALWAYS search first!**
|
||||
|
||||
```bash
|
||||
# Search for the value you're about to change
|
||||
rg "value_to_change" --type ts
|
||||
|
||||
# Check how many files define this value
|
||||
rg "CONFIG_NAME" --type ts -c
|
||||
```
|
||||
|
||||
This single habit prevents most "forgot to update X" bugs.
|
||||
|
||||
---
|
||||
|
||||
## Next.js-Specific Layers
|
||||
|
||||
In Next.js full-stack projects with oRPC and Drizzle, these are the typical layers:
|
||||
|
||||
```
|
||||
Server Components (RSC - data fetching, static rendering)
|
||||
|
|
||||
v
|
||||
Client Components ('use client' - interactivity, React Query)
|
||||
|
|
||||
v
|
||||
API Routes / oRPC Router (type-safe RPC, middleware, validation)
|
||||
|
|
||||
v
|
||||
Service / Business Logic (shared utilities, domain rules)
|
||||
|
|
||||
v
|
||||
Database Layer (Drizzle ORM, PostgreSQL, migrations)
|
||||
```
|
||||
|
||||
Each boundary is a potential source of bugs due to:
|
||||
|
||||
- **Serialization** - Only serializable data crosses the RSC/Client boundary (no functions, no Date objects, no Maps)
|
||||
- **Type mismatches** - Zod schemas on oRPC may not match what the frontend expects
|
||||
- **Auth context** - Session availability differs between Server Components, API routes, and middleware
|
||||
- **Rendering mode** - Server Components vs Client Components have different capabilities and constraints
|
||||
- **Async timing** - React Query caching, stale data, and race conditions
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Search Before Write** - Always search for existing patterns before creating new ones
|
||||
2. **Think Before Code** - 5 minutes of checklist saves 50 minutes of debugging
|
||||
3. **Document Assumptions** - Make implicit assumptions explicit
|
||||
4. **Verify All Layers** - Changes often need updates in multiple places
|
||||
5. **Learn From Bugs** - Add lessons to these guides after fixing non-trivial bugs
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Found a new "didn't think of that" moment? Add it:
|
||||
|
||||
1. If it's a **general thinking pattern** -> Add to existing guide or create new one
|
||||
2. If it caused a bug -> Add to "Lessons Learned" section in the relevant guide
|
||||
3. If it's **project-specific** -> Create a separate project-specific guide
|
||||
|
||||
---
|
||||
|
||||
**Language**: All documentation should be written in **English**.
|
||||
289
.trellis/spec/guides/pre-implementation-checklist.md
Normal file
289
.trellis/spec/guides/pre-implementation-checklist.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# Pre-Implementation Checklist
|
||||
|
||||
> **Purpose**: Ask the right questions **before** writing code to avoid common architectural mistakes.
|
||||
|
||||
---
|
||||
|
||||
## Why This Checklist?
|
||||
|
||||
Most code quality issues aren't caught during implementation--they're **designed in** from the start:
|
||||
|
||||
| Problem | Root Cause | Cost |
|
||||
| -------------------------------------- | --------------------------------------------- | -------------------------- |
|
||||
| Constants duplicated across 5 files | Didn't ask "will this be used elsewhere?" | Refactoring + testing |
|
||||
| Same logic repeated in multiple hooks | Didn't ask "does this pattern exist?" | Creating abstraction later |
|
||||
| Cross-layer type mismatches | Didn't ask "who else consumes this?" | Debugging + fixing |
|
||||
| Zod schema redefined in frontend | Didn't ask "is this type already exported?" | Inconsistent validation |
|
||||
| oRPC procedure duplicates existing one | Didn't ask "does a similar endpoint exist?" | API surface bloat |
|
||||
|
||||
**This checklist catches these issues before they become code.**
|
||||
|
||||
---
|
||||
|
||||
## The Checklist
|
||||
|
||||
### 1. Constants & Configuration
|
||||
|
||||
Before adding any constant or config value:
|
||||
|
||||
- [ ] **Cross-package usage?** Will this value be used in both frontend app and API package?
|
||||
- If yes -> Put in a shared package (e.g., `@your-app/utils` or `@your-app/config`)
|
||||
- Example: `MAX_UPLOAD_SIZE` used by both file upload UI and oRPC validation
|
||||
|
||||
- [ ] **Multiple consumers?** Will this value be used in 2+ files within the same package?
|
||||
- If yes -> Put in a shared constants file for that package
|
||||
- Example: Don't define `DEBOUNCE_MS = 300` in each hook file
|
||||
|
||||
- [ ] **Magic number?** Is this a hardcoded value that could change?
|
||||
- If yes -> Extract to named constant with comment explaining why
|
||||
- Example: `PAGINATION_LIMIT: 50 // oRPC default page size`
|
||||
|
||||
- [ ] **Environment-dependent?** Does this differ between dev/staging/production?
|
||||
- If yes -> Use environment variables with proper validation
|
||||
- Example: API URLs, feature flags, third-party API keys
|
||||
|
||||
### 2. Logic & Patterns
|
||||
|
||||
Before implementing any logic:
|
||||
|
||||
- [ ] **Pattern exists?** Search for similar patterns in the codebase first
|
||||
|
||||
```bash
|
||||
# Example: Before implementing debounced search
|
||||
rg "debounce" src/ packages/
|
||||
rg "useDebounce" src/ packages/
|
||||
```
|
||||
|
||||
- [ ] **Will repeat?** Will this exact logic be needed in 2+ places?
|
||||
- If yes -> Create a shared hook/utility **first**, then use it
|
||||
- Example: `useDebounce` instead of repeating debounce logic in 5 hooks
|
||||
|
||||
- [ ] **React Query pattern?** Is there an existing query/mutation hook for this data?
|
||||
- Search before creating: `rg "orpc.items" src/`
|
||||
- Check if you can extend an existing hook rather than creating a new one
|
||||
|
||||
- [ ] **Server or client?** Does this logic need interactivity?
|
||||
- If no -> Keep it in a Server Component (default)
|
||||
- If yes -> Extract only the interactive part into a Client Component
|
||||
|
||||
### 3. Types & Schemas
|
||||
|
||||
Before defining types:
|
||||
|
||||
- [ ] **Zod schema exists?** Is there already a Zod schema for this data shape?
|
||||
- Check the API module's `types.ts`: `rg "Schema = z.object" packages/api/`
|
||||
- Derive TypeScript types from Zod schemas with `z.infer<typeof schema>`
|
||||
- Never manually define a TypeScript interface that duplicates a Zod schema
|
||||
|
||||
- [ ] **Existing type?** Does a similar type already exist?
|
||||
- Search before creating: `rg "interface.*YourTypeName\|type.*YourTypeName" src/ packages/`
|
||||
|
||||
- [ ] **Cross-layer type?** Is this type used across the oRPC boundary?
|
||||
- If yes -> Define the Zod schema in the API module's `types.ts`, export the inferred type
|
||||
- Frontend should import types from the API package, not redefine them
|
||||
|
||||
- [ ] **Derived from client?** Can you infer the type from the oRPC client?
|
||||
```typescript
|
||||
// Prefer this over manually defining types
|
||||
type ItemResult = Awaited<ReturnType<(typeof orpcClient)["items"]["get"]>>;
|
||||
```
|
||||
|
||||
### 4. UI Components
|
||||
|
||||
Before creating UI components:
|
||||
|
||||
- [ ] **Server or Client Component?** Does this component need:
|
||||
- Event handlers (onClick, onChange)? -> `'use client'`
|
||||
- React hooks (useState, useEffect)? -> `'use client'`
|
||||
- Browser APIs (window, document)? -> `'use client'`
|
||||
- None of the above? -> Keep as Server Component (default)
|
||||
|
||||
- [ ] **Similar component exists?** Search before creating
|
||||
- `rg "function.*YourComponent\|export.*YourComponent" src/`
|
||||
|
||||
- [ ] **Visual-logic consistency?** If there's already a visual distinction (icon, color, label) for a concept, does your logic match?
|
||||
|
||||
- [ ] **State lifecycle?** Will this component unmount during normal user flow?
|
||||
- If yes -> Consider where state should persist (URL params with nuqs, parent, context)
|
||||
|
||||
### 5. API Routes & oRPC Procedures
|
||||
|
||||
Before writing an API route or oRPC procedure:
|
||||
|
||||
- [ ] **Existing procedure?** Does a similar oRPC procedure already exist?
|
||||
- Check the module's `router.ts`: `rg "Router = {" packages/api/`
|
||||
- Can you extend an existing procedure rather than creating a new one?
|
||||
|
||||
- [ ] **Correct HTTP method?**
|
||||
- GET for read operations (queries)
|
||||
- POST for create operations (mutations)
|
||||
- PUT/PATCH for update operations
|
||||
- DELETE for removal operations
|
||||
|
||||
- [ ] **Authentication level?** Which base procedure to use?
|
||||
- Public data -> `publicProcedure`
|
||||
- User-specific data -> `protectedProcedure`
|
||||
- Admin operations -> `adminProcedure`
|
||||
|
||||
- [ ] **Input/output schemas defined?** Both should be Zod schemas in `types.ts`
|
||||
|
||||
### 6. Dependencies
|
||||
|
||||
Before adding a dependency:
|
||||
|
||||
- [ ] **Already installed?** Check `package.json` across all packages
|
||||
```bash
|
||||
rg "\"dependency-name\"" package.json packages/*/package.json
|
||||
```
|
||||
|
||||
- [ ] **Built-in alternative?** Can you use a native API or existing utility instead?
|
||||
- Example: `structuredClone()` instead of `lodash.cloneDeep`
|
||||
|
||||
- [ ] **Bundle impact?** Will this significantly increase the client bundle?
|
||||
- If yes -> Consider dynamic import or server-only usage
|
||||
|
||||
---
|
||||
|
||||
## Quick Decision Tree
|
||||
|
||||
```
|
||||
Adding a value/constant?
|
||||
|-- Used in both app AND api package? -> shared package (@your-app/utils)
|
||||
|-- Used in 2+ files within same package? -> shared constants file
|
||||
+-- Single file only? -> Local constant is fine
|
||||
|
||||
Adding logic/behavior?
|
||||
|-- Similar pattern exists? -> Extend or reuse existing
|
||||
|-- Will be used in 2+ places? -> Create shared hook/utility first
|
||||
+-- Single use only? -> Implement directly (but document pattern)
|
||||
|
||||
Adding a type?
|
||||
|-- Zod schema exists? -> Use z.infer<typeof schema>
|
||||
|-- Crosses oRPC boundary? -> Define in API types.ts, import elsewhere
|
||||
|-- Can derive from client? -> Use Awaited<ReturnType<...>>
|
||||
+-- Local only? -> Define locally
|
||||
|
||||
Adding a component?
|
||||
|-- Needs interactivity? -> 'use client'
|
||||
|-- Pure display? -> Server Component (default)
|
||||
+-- Mix of both? -> Split into Server wrapper + Client interactive part
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What to Verify Across Layers
|
||||
|
||||
When implementing a feature that spans Server Component -> API -> Database, verify:
|
||||
|
||||
| Layer | Check |
|
||||
| ---------------- | ------------------------------------------------------------------ |
|
||||
| Server Component | Data fetched correctly? Props serializable? No client-only APIs? |
|
||||
| Client Component | Loading/error states handled? React Query cache invalidated? |
|
||||
| oRPC Procedure | Input validated? Auth checked? Output schema matches? |
|
||||
| Database Query | No N+1 queries? Proper indexes? Transactions where needed? |
|
||||
| Zod Schemas | Input and output schemas consistent? Date/null handling correct? |
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
### Redefining Backend Types
|
||||
|
||||
```typescript
|
||||
// DON'T: Manually define types that mirror Zod schemas
|
||||
interface Item {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// DO: Import or infer from the source of truth
|
||||
import type { Item } from "@your-app/api/modules/items/types";
|
||||
// or
|
||||
type Item = Awaited<ReturnType<(typeof orpcClient)["items"]["get"]>>["item"];
|
||||
```
|
||||
|
||||
### Manual Query Keys
|
||||
|
||||
```typescript
|
||||
// DON'T: Manually construct query keys
|
||||
queryClient.invalidateQueries({ queryKey: ["items", "list"] });
|
||||
|
||||
// DO: Use oRPC generated keys
|
||||
queryClient.invalidateQueries({ queryKey: orpc.items.list.key() });
|
||||
```
|
||||
|
||||
### Unnecessary Client Components
|
||||
|
||||
```typescript
|
||||
// DON'T: Mark everything as 'use client'
|
||||
'use client';
|
||||
export function ItemCard({ item }) {
|
||||
return <div>{item.name}</div>; // No interactivity needed!
|
||||
}
|
||||
|
||||
// DO: Keep as Server Component when possible
|
||||
export function ItemCard({ item }) {
|
||||
return <div>{item.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Fetch in Client Components When Server Would Work
|
||||
|
||||
```typescript
|
||||
// DON'T: Fetch in Client Component when data could come from Server Component
|
||||
'use client';
|
||||
export function ItemList() {
|
||||
const { data } = useQuery(orpc.items.list.queryOptions({ input: {} }));
|
||||
return <ul>{data?.items.map(...)}</ul>;
|
||||
}
|
||||
|
||||
// DO: Fetch in Server Component, pass as props (when no interactivity needed)
|
||||
export async function ItemList() {
|
||||
const data = await orpcClient.items.list({});
|
||||
return <ul>{data.items.map(...)}</ul>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## When to Use This Checklist
|
||||
|
||||
| Trigger | Action |
|
||||
| ------------------------------------------ | ------------------------- |
|
||||
| About to add a constant | Run through Section 1 |
|
||||
| About to implement logic | Run through Section 2 |
|
||||
| About to define a type or schema | Run through Section 3 |
|
||||
| About to create a component | Run through Section 4 |
|
||||
| About to add an oRPC procedure | Run through Section 5 |
|
||||
| About to add a dependency | Run through Section 6 |
|
||||
| Feels like you've seen similar code before | **STOP** and search first |
|
||||
|
||||
---
|
||||
|
||||
## Relationship to Other Guides
|
||||
|
||||
| Guide | Focus | Timing |
|
||||
| ------------------------------------------------------------- | ------------------------- | --------------------------- |
|
||||
| **Pre-Implementation Checklist** (this) | Questions before coding | Before writing code |
|
||||
| [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) | Data flow across layers | Complex feature planning |
|
||||
|
||||
**Ideal workflow:**
|
||||
|
||||
1. Read this checklist before coding
|
||||
2. Use Cross-Layer guide for features spanning multiple layers
|
||||
|
||||
---
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
| Date | Issue | Lesson |
|
||||
| ---- | ---------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| - | Zod schema redefined in frontend and backend | Always derive frontend types from the API package's Zod schemas |
|
||||
| - | `useQuery` used where Server Component sufficed | Ask "does this need interactivity?" before reaching for React Query |
|
||||
| - | Manual query keys diverged from oRPC keys | Always use `orpc.xxx.key()` or `orpc.xxx.queryKey()` for cache ops |
|
||||
| - | Type defined in both app and api package | Cross-boundary types must be defined once in the API module |
|
||||
|
||||
---
|
||||
|
||||
**Core Principle**: 5 minutes of checklist thinking saves 50 minutes of refactoring.
|
||||
315
.trellis/spec/shared/code-quality.md
Normal file
315
.trellis/spec/shared/code-quality.md
Normal file
@@ -0,0 +1,315 @@
|
||||
# Code Quality Guidelines
|
||||
|
||||
> Mandatory code quality rules for all Next.js full-stack applications.
|
||||
|
||||
---
|
||||
|
||||
## No Non-Null Assertions
|
||||
|
||||
**NEVER** use non-null assertions (`!`). They bypass TypeScript's null checking and lead to runtime errors.
|
||||
|
||||
```typescript
|
||||
// FORBIDDEN
|
||||
const name = user!.name;
|
||||
const value = data!.items![0]!;
|
||||
|
||||
// REQUIRED - Use explicit checks
|
||||
const user = getUser();
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
const name = user.name;
|
||||
|
||||
// REQUIRED - Use optional chaining with fallback
|
||||
const value = data?.items?.[0] ?? defaultValue;
|
||||
|
||||
// REQUIRED - Use local variable after null check
|
||||
const project = getProject(id);
|
||||
if (!project) {
|
||||
return { success: false, reason: 'Project not found' };
|
||||
}
|
||||
const projectName = project.name;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## No `any` Type
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
function process(data: any) { ... }
|
||||
|
||||
// GOOD - Use proper types
|
||||
function process(data: ProcessInput) { ... }
|
||||
|
||||
// GOOD - Use unknown for truly unknown data
|
||||
function parseJSON(input: string): unknown {
|
||||
return JSON.parse(input);
|
||||
}
|
||||
|
||||
// BAD - any in cache updates
|
||||
queryClient.setQueryData(['users'], (old: any) => ...);
|
||||
|
||||
// GOOD - Properly typed cache updates
|
||||
queryClient.setQueryData<UserListData>(['users'], (old) => {
|
||||
if (!old) return old;
|
||||
return { ...old, items: old.items.filter((u) => u.id !== deletedId) };
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## No `@ts-expect-error` / `@ts-ignore`
|
||||
|
||||
```typescript
|
||||
// FORBIDDEN
|
||||
// @ts-expect-error - field exists at runtime
|
||||
const value = user.customField;
|
||||
|
||||
// @ts-ignore
|
||||
doSomething(invalidArg);
|
||||
|
||||
// REQUIRED - Fix the type issue at the source
|
||||
// If a field exists at runtime but not in types, update the type definition.
|
||||
doSomething(validArg);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## No `console.log`
|
||||
|
||||
Use structured logging instead of `console.log`. This applies to both frontend and backend code.
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
console.log('User created:', userId);
|
||||
console.log('Error:', error);
|
||||
|
||||
// GOOD - Backend: use structured logger
|
||||
logger.info('user_created', { userId });
|
||||
logger.error('operation_failed', { error, operationId });
|
||||
|
||||
// GOOD - Frontend: remove debug logs before commit
|
||||
// Use browser dev tools for debugging, not console.log
|
||||
```
|
||||
|
||||
**Exception**: `console.warn` and `console.error` are acceptable in frontend code for development-time warnings that will not appear in production.
|
||||
|
||||
---
|
||||
|
||||
## Import Ordering
|
||||
|
||||
Organize imports in this order, separated by blank lines:
|
||||
|
||||
```typescript
|
||||
// 1. Node built-ins
|
||||
import path from 'node:path';
|
||||
|
||||
// 2. External packages
|
||||
import { z } from 'zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
// 3. Internal workspace packages
|
||||
import type { User } from '@your-app/api/modules/users/types';
|
||||
|
||||
// 4. Local imports (relative paths)
|
||||
import { formatDate } from './utils';
|
||||
import type { Props } from './types';
|
||||
```
|
||||
|
||||
Always use `import type` for type-only imports:
|
||||
|
||||
```typescript
|
||||
// GOOD
|
||||
import type { User, Project } from './types';
|
||||
import { createUser } from './procedures';
|
||||
|
||||
// BAD - Mixed imports without type annotation
|
||||
import { User, createUser } from './types';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Files and Directories
|
||||
|
||||
| Type | Convention | Example |
|
||||
| --------------- | --------------------------- | --------------------------- |
|
||||
| React Component | PascalCase | `UserProfile.tsx` |
|
||||
| Hook | camelCase with `use` prefix | `useProject.ts` |
|
||||
| Utility | kebab-case | `date-utils.ts` |
|
||||
| Type file | kebab-case or `types.ts` | `types.ts`, `user-types.ts` |
|
||||
| Test file | Same name + `.test` | `date-utils.test.ts` |
|
||||
| Directory | kebab-case | `user-profile/` |
|
||||
|
||||
### Variables and Functions
|
||||
|
||||
| Type | Convention | Example |
|
||||
| -------------- | ------------------------------------------- | ---------------------------------- |
|
||||
| Variable | camelCase | `userName`, `isActive` |
|
||||
| Constant | SCREAMING_SNAKE_CASE | `MAX_RETRY_COUNT` |
|
||||
| Function | camelCase | `getUserById` |
|
||||
| Class | PascalCase | `UserService` |
|
||||
| Type/Interface | PascalCase | `UserInput`, `ProjectOutput` |
|
||||
| Enum | PascalCase (type), SCREAMING_SNAKE (values) | `enum Status { ACTIVE, INACTIVE }` |
|
||||
|
||||
### Boolean Variables
|
||||
|
||||
Use `is`, `has`, `should`, `can` prefixes:
|
||||
|
||||
```typescript
|
||||
// GOOD
|
||||
const isLoading = true;
|
||||
const hasPermission = user.role === 'admin';
|
||||
const shouldRefresh = Date.now() > expiresAt;
|
||||
const canEdit = isOwner || hasPermission;
|
||||
|
||||
// BAD
|
||||
const loading = true;
|
||||
const permission = user.role === 'admin';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Never Swallow Errors
|
||||
|
||||
```typescript
|
||||
// BAD - Silent failure
|
||||
try {
|
||||
await dangerousOperation();
|
||||
} catch (e) {
|
||||
// nothing
|
||||
}
|
||||
|
||||
// GOOD - Log and handle
|
||||
try {
|
||||
await dangerousOperation();
|
||||
} catch (error) {
|
||||
logger.error('operation_failed', { error });
|
||||
throw new AppError('Operation failed', 'OPERATION_FAILED');
|
||||
}
|
||||
```
|
||||
|
||||
### Consistent Error Response Format
|
||||
|
||||
All API responses must use the standard `success` + `reason` format:
|
||||
|
||||
```typescript
|
||||
// Success
|
||||
return {
|
||||
success: true,
|
||||
reason: 'Operation completed successfully',
|
||||
data: result,
|
||||
};
|
||||
|
||||
// Error
|
||||
return {
|
||||
success: false,
|
||||
reason: 'Insufficient permissions to perform this action',
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dead Code Elimination
|
||||
|
||||
- Remove unused imports (Biome enforces this automatically)
|
||||
- Remove commented-out code blocks
|
||||
- Remove unused variables, functions, and types
|
||||
- Remove unreachable code after `return`, `throw`, `break`, `continue`
|
||||
|
||||
```typescript
|
||||
// BAD - Dead code
|
||||
function processOrder(order: Order) {
|
||||
// const oldLogic = order.items.map(...);
|
||||
const result = newLogic(order);
|
||||
return result;
|
||||
cleanup(); // unreachable
|
||||
}
|
||||
|
||||
// GOOD - Clean
|
||||
function processOrder(order: Order) {
|
||||
return newLogic(order);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lint and Type Check Before Commit
|
||||
|
||||
```bash
|
||||
# MUST pass before every commit
|
||||
pnpm lint
|
||||
pnpm type-check
|
||||
|
||||
# Production build check (catches additional issues)
|
||||
pnpm build
|
||||
|
||||
# Or combined
|
||||
pnpm lint && pnpm type-check && pnpm build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
### Test File Location
|
||||
|
||||
```
|
||||
src/
|
||||
__tests__/ # Integration tests
|
||||
api.test.ts
|
||||
app/
|
||||
feature/
|
||||
page.tsx
|
||||
page.test.tsx # Co-located test (when appropriate)
|
||||
```
|
||||
|
||||
### Test Structure (AAA Pattern)
|
||||
|
||||
```typescript
|
||||
describe('OrderService', () => {
|
||||
describe('createOrder', () => {
|
||||
it('should create an order with valid input', async () => {
|
||||
// Arrange
|
||||
const input = { items: [{ productId: '1', quantity: 2 }] };
|
||||
|
||||
// Act
|
||||
const result = await createOrder(input);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.order.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should reject empty order', async () => {
|
||||
// Arrange
|
||||
const input = { items: [] };
|
||||
|
||||
// Act
|
||||
const result = await createOrder(input);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Rule | Reason |
|
||||
| ------------------------------ | ------------------- |
|
||||
| No `!` assertions | Runtime errors |
|
||||
| No `any` type | Type safety |
|
||||
| No `@ts-expect-error` | Masks real issues |
|
||||
| No `console.log` | Use structured logs |
|
||||
| Lint + typecheck before commit | Consistent code |
|
||||
| Structured errors | Consistent handling |
|
||||
| Never swallow errors | Debuggability |
|
||||
| Remove dead code | Maintainability |
|
||||
173
.trellis/spec/shared/dependencies.md
Normal file
173
.trellis/spec/shared/dependencies.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Dependencies & Versions
|
||||
|
||||
> Adjust versions to your project. These represent a known-working combination as of the time of writing. Pin or widen ranges to match your stability requirements.
|
||||
|
||||
---
|
||||
|
||||
## Runtime Environment
|
||||
|
||||
| Dependency | Version | Description |
|
||||
|------------|---------|-------------|
|
||||
| Node.js | >=20 | JavaScript runtime |
|
||||
| pnpm | ^10.x | Package manager |
|
||||
|
||||
---
|
||||
|
||||
## Core Framework
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| next | ^15.x | React framework for production |
|
||||
| react | ^19.x | UI library |
|
||||
| react-dom | ^19.x | React DOM renderer |
|
||||
| typescript | ^5.x | TypeScript language |
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
### API Layer
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| hono | ^4.x | Lightweight web framework |
|
||||
| @orpc/server | ^1.x | oRPC server implementation |
|
||||
| @orpc/client | ^1.x | oRPC client |
|
||||
| @orpc/zod | ^1.x | oRPC Zod integration |
|
||||
| @orpc/openapi | ^1.x | OpenAPI schema generation |
|
||||
| zod | ^4.x | Schema validation |
|
||||
|
||||
### Database
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| drizzle-orm | ^0.44.x | TypeScript ORM |
|
||||
| drizzle-kit | ^0.31.x | Drizzle CLI tools |
|
||||
| drizzle-zod | ^0.8.x | Drizzle + Zod integration |
|
||||
| pg | ^8.x | PostgreSQL client |
|
||||
|
||||
### Authentication
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| better-auth | ^1.x | Authentication library |
|
||||
|
||||
### Caching & Queue
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| @upstash/redis | ^1.x | Redis client (Upstash) |
|
||||
| @upstash/qstash | ^2.x | Message queue |
|
||||
|
||||
---
|
||||
|
||||
## AI Integration
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| ai | ^5.x | Vercel AI SDK core |
|
||||
| @ai-sdk/react | ^2.x | AI SDK React hooks |
|
||||
| @ai-sdk/openai | ^2.x | OpenAI provider |
|
||||
| @ai-sdk/anthropic | ^2.x | Anthropic provider |
|
||||
|
||||
---
|
||||
|
||||
## Frontend
|
||||
|
||||
### UI Components
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| @radix-ui/* | latest | Headless UI primitives |
|
||||
| lucide-react | ^0.x | Icon library |
|
||||
| cmdk | ^1.x | Command palette |
|
||||
| sonner | ^2.x | Toast notifications |
|
||||
|
||||
### Styling
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| tailwindcss | ^4.x | Utility-first CSS (v4 config format) |
|
||||
| @tailwindcss/postcss | ^4.x | PostCSS plugin |
|
||||
| tailwind-merge | ^3.x | Tailwind class merging |
|
||||
| class-variance-authority | ^0.7.x | Variant management |
|
||||
| clsx | ^2.x | Class name utility |
|
||||
|
||||
### State Management
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| @tanstack/react-query | ^5.x | Data fetching & caching |
|
||||
| @orpc/tanstack-query | ^1.x | oRPC + React Query bridge |
|
||||
| nuqs | ^2.x | URL state management |
|
||||
| react-hook-form | ^7.x | Form state management |
|
||||
| @hookform/resolvers | ^5.x | Form validation resolvers |
|
||||
|
||||
### Internationalization
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| next-intl | ^4.x | Next.js i18n |
|
||||
|
||||
### Utilities
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| date-fns | ^4.x | Date utilities |
|
||||
| es-toolkit | ^1.x | Utility functions |
|
||||
| nanoid | ^5.x | ID generation |
|
||||
| p-limit | ^7.x | Concurrency control |
|
||||
|
||||
---
|
||||
|
||||
## Monitoring & Logging
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| @sentry/nextjs | ^10.x | Error tracking |
|
||||
|
||||
---
|
||||
|
||||
## Development Tools
|
||||
|
||||
### Build & Bundling
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| turbo | ^2.x | Monorepo build system |
|
||||
| tsx | ^4.x | TypeScript executor |
|
||||
|
||||
### Code Quality
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| @biomejs/biome | ^2.x | Linter & formatter |
|
||||
| husky | ^9.x | Git hooks |
|
||||
|
||||
### Testing
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| @playwright/test | ^1.x | E2E testing |
|
||||
|
||||
---
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **React 19**: Major version with breaking changes from React 18
|
||||
2. **Next.js 15**: App Router is the primary routing pattern
|
||||
3. **TailwindCSS 4**: Uses the new v4 configuration format (not `tailwind.config.js`)
|
||||
4. **Zod 4**: Latest version with improved TypeScript support
|
||||
5. **Monorepo**: Use `@your-app/*` for internal workspace package references
|
||||
|
||||
---
|
||||
|
||||
## Updating Dependencies
|
||||
|
||||
When updating dependencies:
|
||||
|
||||
1. Check compatibility with React 19 and Next.js 15
|
||||
2. Update pnpm overrides if changing React or Drizzle versions
|
||||
3. Run `pnpm install` from the root directory
|
||||
4. Run `pnpm type-check` to verify TypeScript compatibility
|
||||
5. Run `pnpm build` to ensure production build works
|
||||
66
.trellis/spec/shared/index.md
Normal file
66
.trellis/spec/shared/index.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Shared Development Guidelines
|
||||
|
||||
> These guidelines apply to all Next.js full-stack applications using this architecture.
|
||||
|
||||
---
|
||||
|
||||
## Documentation Files
|
||||
|
||||
| File | Description | When to Read |
|
||||
| -------------------------------------- | ------------------------------------ | ----------------------- |
|
||||
| [code-quality.md](./code-quality.md) | Code quality mandatory rules | Always |
|
||||
| [typescript.md](./typescript.md) | TypeScript best practices | Type-related decisions |
|
||||
| [dependencies.md](./dependencies.md) | Dependency versions and constraints | Adding/updating deps |
|
||||
|
||||
---
|
||||
|
||||
## Quick Navigation
|
||||
|
||||
| Task | File |
|
||||
| --------------------------- | -------------------------------------- |
|
||||
| Code quality rules | [code-quality.md](./code-quality.md) |
|
||||
| Type annotations | [typescript.md](./typescript.md) |
|
||||
| Dependency management | [dependencies.md](./dependencies.md) |
|
||||
|
||||
---
|
||||
|
||||
## Core Rules (MANDATORY)
|
||||
|
||||
| Rule | File |
|
||||
| ----------------------------------------- | -------------------------------------- |
|
||||
| No non-null assertions (`!`) | [code-quality.md](./code-quality.md) |
|
||||
| No `any` type | [code-quality.md](./code-quality.md) |
|
||||
| No `@ts-expect-error` / `@ts-ignore` | [code-quality.md](./code-quality.md) |
|
||||
| No `console.log` (use structured logging) | [code-quality.md](./code-quality.md) |
|
||||
| Zod-first type definitions | [typescript.md](./typescript.md) |
|
||||
| Import types from backend, never redefine | [typescript.md](./typescript.md) |
|
||||
| Standard response format (`success` + `reason`) | [typescript.md](./typescript.md) |
|
||||
|
||||
---
|
||||
|
||||
## Before Every Commit
|
||||
|
||||
- [ ] `pnpm lint` - 0 errors
|
||||
- [ ] `pnpm type-check` - 0 errors
|
||||
- [ ] `pnpm build` - production build succeeds
|
||||
- [ ] No `any` types in new code
|
||||
- [ ] No non-null assertions (`!`)
|
||||
- [ ] No `@ts-expect-error` or `@ts-ignore` comments
|
||||
- [ ] No `console.log` statements (use `logger`)
|
||||
- [ ] Tests pass (if applicable)
|
||||
|
||||
---
|
||||
|
||||
## Code Review Checklist
|
||||
|
||||
- [ ] Types are explicit, not `any`
|
||||
- [ ] API inputs/outputs have Zod schemas
|
||||
- [ ] Error handling returns structured responses
|
||||
- [ ] No duplicate type definitions (import from source of truth)
|
||||
- [ ] Naming follows conventions (files: kebab-case, components: PascalCase)
|
||||
- [ ] Unused imports and dead code removed
|
||||
- [ ] No swallowed errors (silent `catch` blocks)
|
||||
|
||||
---
|
||||
|
||||
**Language**: All documentation must be written in **English**.
|
||||
441
.trellis/spec/shared/typescript.md
Normal file
441
.trellis/spec/shared/typescript.md
Normal file
@@ -0,0 +1,441 @@
|
||||
# TypeScript Best Practices
|
||||
|
||||
> TypeScript guidelines for Next.js full-stack applications.
|
||||
|
||||
---
|
||||
|
||||
## Zod-First Type Definitions
|
||||
|
||||
Define Zod schemas first, then infer TypeScript types from them. Never define types manually when a Zod schema exists.
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
// 1. Define the schema (single source of truth)
|
||||
export const createUserInputSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
role: z.enum(['admin', 'member', 'viewer']),
|
||||
});
|
||||
|
||||
export const createUserOutputSchema = z.object({
|
||||
success: z.boolean(),
|
||||
reason: z.string(),
|
||||
user: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
// 2. Derive types from schemas
|
||||
export type CreateUserInput = z.infer<typeof createUserInputSchema>;
|
||||
export type CreateUserOutput = z.infer<typeof createUserOutputSchema>;
|
||||
|
||||
// BAD - Manual type that duplicates schema
|
||||
interface CreateUserInput {
|
||||
name: string;
|
||||
email: string;
|
||||
role: 'admin' | 'member' | 'viewer';
|
||||
}
|
||||
```
|
||||
|
||||
### Reusable Base Schemas
|
||||
|
||||
```typescript
|
||||
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(),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Type Inference from API
|
||||
|
||||
Import types from the backend or infer them from the API client. Never redefine backend types on the frontend.
|
||||
|
||||
### Import from Backend Package
|
||||
|
||||
```typescript
|
||||
// GOOD - Import from the API package
|
||||
import type { User, Order } from '@your-app/api/modules/users/types';
|
||||
import type { OrderStatus } from '@your-app/api/modules/orders/types';
|
||||
|
||||
// BAD - Redefining types that exist in backend
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Infer from API Client
|
||||
|
||||
```typescript
|
||||
import { orpcClient } from '@/lib/orpc';
|
||||
|
||||
// Infer the response type from the API client
|
||||
type UsersResponse = Awaited<ReturnType<typeof orpcClient.users.list>>;
|
||||
|
||||
// Infer a single item type from array response
|
||||
type User = UsersResponse['items'][number];
|
||||
|
||||
// Infer input types
|
||||
type CreateUserInput = Parameters<typeof orpcClient.users.create>[0];
|
||||
```
|
||||
|
||||
### Type Inference in Hooks
|
||||
|
||||
```typescript
|
||||
// The return type is automatically inferred from oRPC
|
||||
export function useUsers() {
|
||||
return useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => orpcClient.users.list(),
|
||||
});
|
||||
}
|
||||
|
||||
// For complex transformations, use explicit inference
|
||||
type UserListData = Awaited<ReturnType<typeof orpcClient.users.list>>;
|
||||
|
||||
export function useFormattedUsers() {
|
||||
return useQuery({
|
||||
queryKey: ['users', 'formatted'],
|
||||
queryFn: async () => {
|
||||
const data = await orpcClient.users.list();
|
||||
return transformUsers(data);
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Discriminated Unions
|
||||
|
||||
Use discriminated unions for types that can be one of several shapes. Use strict equality (`=== true`) for narrowing.
|
||||
|
||||
### TypeScript Discriminated Union
|
||||
|
||||
```typescript
|
||||
type Result<T> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: string };
|
||||
|
||||
const result: Result<User> = doSomething();
|
||||
|
||||
// CORRECT: Use === true for narrowing
|
||||
if (result.success === true) {
|
||||
console.log(result.data); // TypeScript knows data exists
|
||||
} else {
|
||||
console.log(result.error); // TypeScript knows error exists
|
||||
}
|
||||
```
|
||||
|
||||
### Zod Discriminated Union
|
||||
|
||||
```typescript
|
||||
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(),
|
||||
}),
|
||||
]);
|
||||
|
||||
type Notification = z.infer<typeof notificationSchema>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Generic Patterns
|
||||
|
||||
### Generic Result Type
|
||||
|
||||
```typescript
|
||||
type Result<T> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: string };
|
||||
|
||||
function createResult<T>(data: T): Result<T> {
|
||||
return { success: true, data };
|
||||
}
|
||||
```
|
||||
|
||||
### Generic Paginated Response
|
||||
|
||||
```typescript
|
||||
type PaginatedResponse<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
type UserListResponse = PaginatedResponse<User>;
|
||||
```
|
||||
|
||||
### Generic with Constraints
|
||||
|
||||
```typescript
|
||||
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
|
||||
return obj[key];
|
||||
}
|
||||
```
|
||||
|
||||
### Common Utility Types
|
||||
|
||||
```typescript
|
||||
// Extract array element type
|
||||
type ArrayElement<T> = T extends (infer E)[] ? E : never;
|
||||
|
||||
// Make specific properties optional
|
||||
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||
|
||||
// Make specific properties required
|
||||
type RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Standard Response Format
|
||||
|
||||
All API responses must include `success` and `reason` fields.
|
||||
|
||||
```typescript
|
||||
// Output schema pattern
|
||||
export const operationResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
reason: z.string(),
|
||||
data: z.unknown().optional(),
|
||||
});
|
||||
|
||||
// Success response
|
||||
return {
|
||||
success: true,
|
||||
reason: 'User created successfully',
|
||||
user: { id, name, email },
|
||||
};
|
||||
|
||||
// Error response
|
||||
return {
|
||||
success: false,
|
||||
reason: 'Email address is already in use',
|
||||
};
|
||||
```
|
||||
|
||||
### Batch Operation Response
|
||||
|
||||
```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(),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Forbidden Patterns
|
||||
|
||||
### No `any`
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
function process(data: any) { ... }
|
||||
|
||||
// GOOD
|
||||
function process(data: unknown) { ... }
|
||||
function process(data: ProcessInput) { ... }
|
||||
```
|
||||
|
||||
### No Non-null Assertion
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
const name = user!.name;
|
||||
const first = items[0]!;
|
||||
|
||||
// GOOD
|
||||
if (user) {
|
||||
const name = user.name;
|
||||
}
|
||||
|
||||
const first = items[0];
|
||||
if (!first) {
|
||||
return { success: false, reason: 'No items found' };
|
||||
}
|
||||
```
|
||||
|
||||
### No `@ts-expect-error` / `@ts-ignore`
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
// @ts-expect-error - customField exists at runtime
|
||||
const value = user.customField;
|
||||
|
||||
// GOOD - Update the type definition instead
|
||||
interface User {
|
||||
customField: string;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### No Type Assertions Without Validation
|
||||
|
||||
```typescript
|
||||
// BAD - Blind assertion
|
||||
const user = data as User;
|
||||
|
||||
// GOOD - Runtime validation with Zod
|
||||
const user = userSchema.parse(data);
|
||||
|
||||
// GOOD - Type guard
|
||||
function isUser(data: unknown): data is User {
|
||||
return (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'id' in data &&
|
||||
'email' in data
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Type Imports
|
||||
|
||||
Always use `import type` for type-only imports:
|
||||
|
||||
```typescript
|
||||
// GOOD
|
||||
import type { User, Project } from './types';
|
||||
import { createUser } from './procedures';
|
||||
|
||||
// Also acceptable
|
||||
import { type User, createUser } from './types';
|
||||
|
||||
// BAD
|
||||
import { User, createUser } from './types';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Explicit Return Types for Exports
|
||||
|
||||
Always annotate explicit return types on exported functions:
|
||||
|
||||
```typescript
|
||||
// BAD - Implicit return type
|
||||
export function getUser(id: string) {
|
||||
return db.query.users.findFirst({ where: eq(users.id, id) });
|
||||
}
|
||||
|
||||
// GOOD - Explicit return type
|
||||
export function getUser(id: string): Promise<User | undefined> {
|
||||
return db.query.users.findFirst({ where: eq(users.id, id) });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TypeScript Configuration
|
||||
|
||||
Ensure strict mode is enabled:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUncheckedIndexedAccess": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Drizzle Type Inference
|
||||
|
||||
```typescript
|
||||
// Infer types from Drizzle tables
|
||||
type User = typeof userTable.$inferSelect;
|
||||
type NewUser = typeof userTable.$inferInsert;
|
||||
|
||||
// Combine with Zod via drizzle-zod
|
||||
import { createSelectSchema, createInsertSchema } from 'drizzle-zod';
|
||||
const userSelectSchema = createSelectSchema(userTable);
|
||||
const userInsertSchema = createInsertSchema(userTable);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## View Model Types
|
||||
|
||||
When the frontend needs computed properties, extend backend types rather than redefining them:
|
||||
|
||||
```typescript
|
||||
import type { Order } from '@your-app/api/modules/orders/types';
|
||||
|
||||
export interface OrderViewModel extends Order {
|
||||
formattedTotal: string;
|
||||
statusLabel: string;
|
||||
isEditable: boolean;
|
||||
}
|
||||
|
||||
export function toOrderViewModel(order: Order): OrderViewModel {
|
||||
return {
|
||||
...order,
|
||||
formattedTotal: formatCurrency(order.total),
|
||||
statusLabel: getStatusLabel(order.status),
|
||||
isEditable: order.status === 'draft',
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Practice | Reason |
|
||||
| --------------------------- | ----------------------------- |
|
||||
| Zod-first types | Single source of truth |
|
||||
| Import, don't redefine | No type drift |
|
||||
| `=== true` for unions | Proper narrowing |
|
||||
| Generics for reuse | DRY, type-safe |
|
||||
| `success` + `reason` format | Consistent API responses |
|
||||
| No `any` | Type safety |
|
||||
| No `!` assertions | Runtime safety |
|
||||
| No `@ts-expect-error` | Masks real issues |
|
||||
| `import type` | Clear separation, tree-shake |
|
||||
| Explicit return types | Documentation, catch errors |
|
||||
Reference in New Issue
Block a user